makearmy-app/components/forms/SettingsSubmit.tsx
2025-10-02 23:12:24 -04:00

959 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
/* ────────────────────────────────────────────────────────────
* Types
* ──────────────────────────────────────────────────────────── */
export type Target =
| "settings_fiber"
| "settings_co2gan"
| "settings_co2gal"
| "settings_uv";
type Opt = { id: string; label: string };
type Me = {
id: string;
username?: string;
display_name?: string;
first_name?: string;
last_name?: string;
email?: string;
};
export type FormValues = {
setting_title: string;
setting_notes?: string;
// files (id string or File at the UI level)
photo?: string | File | null;
screen?: string | File | null;
// relations / numerics
mat?: any;
mat_coat?: any;
mat_color?: any;
mat_opacity?: any;
mat_thickness?: number | null;
source?: any;
lens?: any;
focus?: number | null;
// flags
laser_soft?: string | null;
repeat_all?: number | null;
// repeaters
fill_settings: any[];
line_settings: any[];
raster_settings: any[];
};
type CreateProps = {
mode?: "create"; // default
initialTarget?: Target;
initialValues?: Partial<FormValues>;
onSaved?: (id: string | number) => void;
};
type EditProps = {
mode: "edit";
initialTarget: Target; // locked target
submissionId: string | number; // external submission_id
initialValues: FormValues; // full snapshot to edit
onSaved?: (id: string | number) => void;
};
export type SettingsSubmitProps = CreateProps | EditProps;
/* ──────────────────────────────────────────────────────────── */
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
// Local enums (no schema introspection)
const FILL_TYPE_OPTIONS = [
{ label: "UniDirectional", value: "uni" },
{ label: "BiDirectional", value: "bi" },
{ label: "Offset Fill", value: "offset" },
];
const RASTER_TYPE_OPTIONS = [
{ label: "UniDirectional", value: "uni" },
{ label: "BiDirectional", value: "bi" },
{ label: "Offset Fill", value: "offset" },
];
const RASTER_DITHER_OPTIONS = [
{ label: "Threshold", value: "threshold" },
{ label: "Ordered", value: "ordered" },
{ label: "Atkinson", value: "atkinson" },
{ label: "Dither", value: "dither" },
{ label: "Stucki", value: "stucki" },
{ label: "Jarvis", value: "jarvis" },
{ label: "Newsprint", value: "newsprint" },
{ label: "Halftone", value: "halftone" },
{ label: "Sketch", value: "sketch" },
{ label: "Grayscale", value: "grayscale" },
];
const toOpts = (arr: { label: string; value: string }[]): Opt[] =>
arr.map((x) => ({ id: x.value, label: x.label }));
function shortId(s?: string) {
if (!s) return "";
return s.length <= 12 ? s : `${s.slice(0, 8)}${s.slice(-4)}`;
}
/* ────────────────────────────────────────────────────────────
* Options helpers
* ──────────────────────────────────────────────────────────── */
function useOptions(path: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
// helpers for the "laser_source" nm filtering
const parseNum = (v: any): number | null => {
if (v == null) return null;
const m = String(v).match(/(\d+(\.\d+)?)/);
return m ? Number(m[1]) : null;
};
const nmRangeFor = (target?: string | null): [number, number] | null => {
if (!target) return null;
const t = target.toLowerCase();
if (t === "fiber") return [1000, 9000];
if (t === "uv") return [300, 400];
if (t === "co2-gantry" || t === "co2-galvo") return [10000, 11000];
return null;
};
useEffect(() => {
let alive = true;
setLoading(true);
(async () => {
const [rawPath, qs] = path.split("?", 2);
const params = new URLSearchParams(qs || "");
const target = params.get("target") || "";
let url = "";
let normalize: (rows: any[]) => Opt[] = (rows) =>
rows.map((r) => ({
id: String(r.id),
label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id),
}));
if (rawPath === "material") {
url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_color") {
url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_coating") {
url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "material_opacity") {
url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`;
normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) }));
} else if (rawPath === "laser_software") {
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
} else if (rawPath === "laser_source") {
// fetch all and client-filter by nm
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
const range = nmRangeFor(target);
normalize = (rows) => {
const filtered = range
? rows.filter((r: any) => {
const nm = parseNum(r.nm);
return nm != null && nm >= range[0] && nm <= range[1];
})
: rows;
return filtered.map((r: any) => ({
id: String(r.submission_id),
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id),
}));
};
} else if (rawPath === "lens") {
// CO2 gantry uses focus lenses; all others use scan lenses
if (target === "co2-gantry") {
url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000`;
normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.name ?? r.id) }));
} else {
// SCAN LENSES (fiber, uv, co2-galvo): sort numerically by focal_length
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
normalize = (rows) => {
const toNum = (v: any) => {
const m = String(v ?? "").match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY;
};
const sorted = [...rows].sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length));
return sorted.map((r) => {
const fs = r.field_size != null ? `${r.field_size}` : "";
const fl = r.focal_length != null ? `${r.focal_length}` : "";
const composed = [fs && `${fs} mm`, fl && `${fl} mm`].filter(Boolean).join(" — ");
return { id: String(r.id), label: composed || String(r.id) };
});
};
}
} else {
// unknown path → empty
setOpts([]);
setLoading(false);
return;
}
const res = await fetch(url, { cache: "no-store", credentials: "include" });
if (!res.ok) throw new Error(`Directus ${res.status} fetching ${url}`);
const json = await res.json();
const rows = json?.data ?? [];
const mapped = normalize(rows);
// client-side text filter
const needle = (q || "").trim().toLowerCase();
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
if (alive) setOpts(filtered);
})()
.catch(() => alive && setOpts([]))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [path, q]);
return { opts, loading, setQ };
}
/* ────────────────────────────────────────────────────────────
* Small inputs
* ──────────────────────────────────────────────────────────── */
function FilterableSelect({
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
}: {
label: string;
name: string;
register: UseFormRegister<any>;
options: Opt[];
loading?: boolean;
onQuery?: (q: string) => void;
placeholder?: string;
required?: boolean;
}) {
const [filter, setFilter] = useState("");
useEffect(() => {
onQuery?.(filter);
}, [filter, onQuery]);
const filtered = useMemo(() => {
if (!filter) return options;
const f = filter.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(f));
}, [options, filter]);
return (
<div>
<label className="block text-sm mb-1">
{label} {required ? <span className="text-red-600">*</span> : null}
</label>
<input
className="w-full border rounded px-2 py-1 mb-1"
placeholder="Type to filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
);
}
function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister<any> }) {
return (
<label className="flex items-center gap-1 text-sm">
<input type="checkbox" {...register(name)} /> {label}
</label>
);
}
/* ────────────────────────────────────────────────────────────
* Component
* ──────────────────────────────────────────────────────────── */
export default function SettingsSubmit(props: SettingsSubmitProps) {
const { mode = "create" } = props;
const isEdit = mode === "edit";
const router = useRouter();
const sp = useSearchParams();
// initial target (locked in edit)
const initialTarget: Target =
isEdit ? props.initialTarget : ((sp.get("target") as Target) || props.initialTarget || "settings_fiber");
const [target, setTarget] = useState<Target>(initialTarget);
// Map collection -> slug used by options selectors
const typeForOptions = useMemo(() => {
switch (target) {
case "settings_fiber":
return "fiber";
case "settings_uv":
return "uv";
case "settings_co2gan":
return "co2-gantry";
case "settings_co2gal":
return "co2-galvo";
default:
return "fiber";
}
}, [target]);
// Image inputs
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
// UX error for auth/submit
const [submitErr, setSubmitErr] = useState<string | null>(null);
// Current signed-in user (banner only)
const [me, setMe] = useState<Me | null>(null);
const [meErr, setMeErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
// use our bearer-only API
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((j) => {
if (!alive) return;
setMe(j || null);
})
.catch(() => {
if (alive) setMeErr("not-signed-in");
});
return () => {
alive = false;
};
}, []);
const meLabel =
me?.display_name?.trim() ||
[me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
me?.username?.trim() ||
me?.email?.trim() ||
(me?.id ? `User ${me.id.slice(0, 8)}${me.id.slice(-4)}` : "Unknown user");
// Options
const mats = useOptions("material");
const coats = useOptions("material_coating");
const colors = useOptions("material_color");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software"); // required for ALL targets
// these two need ?target=
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
const lens = useOptions(`lens?target=${typeForOptions}`);
// Repeater choice options (LOCAL now, no network)
const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
const rasterDither = { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} };
// Default values
const createDefaults: FormValues = {
setting_title: "",
setting_notes: "",
photo: null,
screen: null,
mat: "",
mat_coat: "",
mat_color: "",
mat_opacity: "",
mat_thickness: null,
source: "",
lens: "",
focus: null,
laser_soft: "",
repeat_all: null,
fill_settings: [],
line_settings: [],
raster_settings: [],
};
const {
register,
handleSubmit,
control,
reset,
formState: { isSubmitting },
} = useForm<FormValues>({
defaultValues: isEdit ? (props as EditProps).initialValues : (props.initialValues as any) || createDefaults,
});
// If edit props change (rare), sync the form
useEffect(() => {
if (isEdit) reset((props as EditProps).initialValues);
}, [isEdit, reset, props]);
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
function num(v: any) {
return v === "" || v == null ? null : Number(v);
}
const bool = (v: any) => !!v;
const detailHref = (coll: Target, submissionId: string | number) => {
switch (coll) {
case "settings_co2gal": return `/settings/co2-galvo/${submissionId}?edit=1`;
case "settings_co2gan": return `/settings/co2-gantry/${submissionId}?edit=1`;
case "settings_fiber": return `/settings/fiber/${submissionId}?edit=1`;
case "settings_uv": return `/settings/uv/${submissionId}?edit=1`;
}
};
async function onSubmit(values: FormValues) {
setSubmitErr(null);
// Build normalized payload/patch from form values
const payload: any = {
target,
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
mat: values.mat || null,
mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null,
mat_thickness: num(values.mat_thickness),
source: values.source || null,
lens: values.lens || null,
focus: num(values.focus),
laser_soft: values.laser_soft || null, // all targets
repeat_all: num(values.repeat_all), // all targets
fill_settings: (values.fill_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
interval: num(r.interval),
pass: num(r.pass),
type: r.type || "",
frequency: num(r.frequency),
pulse: num(r.pulse),
angle: num(r.angle),
auto: bool(r.auto),
increment: num(r.increment),
cross: bool(r.cross),
flood: bool(r.flood),
air: bool(r.air),
})),
line_settings: (values.line_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
perf: bool(r.perf),
cut: r.cut || "",
skip: r.skip || "",
pass: num(r.pass),
air: bool(r.air),
frequency: num(r.frequency),
pulse: num(r.pulse),
wobble: bool(r.wobble),
step: num(r.step),
size: num(r.size),
})),
raster_settings: (values.raster_settings || []).map((r: any) => ({
name: r.name || "",
power: num(r.power),
speed: num(r.speed),
type: r.type || "",
dither: r.dither || "",
halftone_cell: num(r.halftone_cell),
halftone_angle: num(r.halftone_angle),
inversion: bool(r.inversion),
interval: num(r.interval),
dot: num(r.dot),
pass: num(r.pass),
air: bool(r.air),
frequency: num(r.frequency),
pulse: num(r.pulse),
cross: bool(r.cross),
})),
};
try {
let res: Response;
if (isEdit) {
// EDIT FLOW: call /api/my-settings/update
const { initialTarget, submissionId } = props as EditProps;
// When files are picked, send multipart so backend can upload & patch.
if (photoFile || screenFile) {
const form = new FormData();
form.set(
"payload",
JSON.stringify({
collection: initialTarget,
submission_id: submissionId,
patch: payload, // server decides which keys to persist
})
);
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
res = await fetch("/api/my-settings/update", {
method: "POST",
credentials: "include",
body: form,
});
} else {
// JSON only (no file changes)
res = await fetch("/api/my-settings/update", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
collection: initialTarget,
submission_id: submissionId,
patch: payload,
}),
});
}
const txt = await res.text();
const data = txt ? JSON.parse(txt) : null;
if (!res.ok || !data?.ok) {
const msg = data?.error || data?.message || `HTTP ${res.status}`;
throw new Error(msg);
}
const savedId = data?.id ?? (props as EditProps).submissionId;
props.onSaved?.(savedId);
router.push(detailHref(initialTarget, savedId));
return;
}
// CREATE FLOW: photo is required
if (!photoFile) {
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
throw new Error("Please select a Result Photo.");
}
if (photoFile || screenFile) {
const form = new FormData();
form.set("payload", JSON.stringify(payload));
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" });
} else {
res = await fetch("/api/submit/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 || res.status === 403) throw new Error("You must be signed in to submit settings.");
throw new Error(data?.error || "Submission failed");
}
reset(createDefaults);
setPhotoFile(null);
setScreenFile(null);
setPhotoPreview("");
setScreenPreview("");
const id = data?.id ? String(data.id) : "";
props.onSaved?.(id);
router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`);
} catch (e: any) {
setSubmitErr(e?.message || "Submission failed");
}
}
function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) {
setFile(file);
if (!file) {
setPreview("");
return;
}
const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || ""));
reader.readAsDataURL(file);
}
return (
<div className="max-w-3xl mx-auto space-y-4">
{/* Target + Software (Software required for ALL targets) */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="block text-sm mb-1">Target</label>
<select
className="border rounded px-2 py-1"
value={target}
onChange={(e) => setTarget(e.target.value as Target)}
disabled={isEdit} // lock in edit mode
>
<option value="settings_fiber">Fiber</option>
<option value="settings_co2gan">CO Gantry</option>
<option value="settings_co2gal">CO Galvo</option>
<option value="settings_uv">UV</option>
</select>
{isEdit && <p className="text-xs text-muted-foreground mt-1">Target is fixed while editing.</p>}
</div>
<div className="flex-1 min-w-[220px]">
<FilterableSelect
label="Software"
name="laser_soft"
register={register}
options={soft.opts}
loading={soft.loading}
onQuery={soft.setQ}
required={true}
/>
</div>
</div>
{/* Submitting-as banner */}
{me ? (
<div className="text-sm text-muted-foreground">
{isEdit ? "Editing as " : "Submitting as "}
<span className="font-medium">{meLabel}</span>.
</div>
) : meErr ? (
<div className="border border-yellow-600 bg-yellow-50 text-yellow-800 rounded p-2 text-sm">
Youre not signed in. {isEdit ? "Saving changes" : "Submissions"} will fail until you sign in.
</div>
) : null}
{submitErr ? (
<div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Title */}
<div className="grid md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="block text-sm mb-1">
Title <span className="text-red-600">*</span>
</label>
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div>
</div>
{/* Images */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">
Result Photo {isEdit ? null : <span className="text-red-600">*</span>}
</label>
{isEdit && typeof (props as EditProps).initialValues?.photo === "string" && (
<p className="text-xs text-muted-foreground mb-1">
Current: <span className="font-mono">{shortId((props as EditProps).initialValues.photo)}</span>
</p>
)}
<input
type="file"
accept="image/*"
data-role="photo"
required={!isEdit}
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{photoFile ? (
<>
Selected: <span className="font-mono">{photoFile.name}</span>
</>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p>
{photoPreview ? <img src={photoPreview} alt="Result preview" className="mt-2 rounded border" /> : null}
</div>
<div>
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
{isEdit && typeof (props as EditProps).initialValues?.screen === "string" && (
<p className="text-xs text-muted-foreground mb-1">
Current: <span className="font-mono">{shortId((props as EditProps).initialValues.screen)}</span>
</p>
)}
<input
type="file"
accept="image/*"
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{screenFile ? (
<>
Selected: <span className="font-mono">{screenFile.name}</span>
</>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p>
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm mb-1">Notes</label>
<textarea rows={4} className="w-full border rounded px-2 py-1" {...register("setting_notes")} />
</div>
{/* Material / Source / Lens */}
<div className="grid md:grid-cols-2 gap-3">
<FilterableSelect
label="Material"
name="mat"
register={register}
options={mats.opts}
loading={mats.loading}
onQuery={mats.setQ}
required
/>
<FilterableSelect
label="Coating"
name="mat_coat"
register={register}
options={coats.opts}
loading={coats.loading}
onQuery={coats.setQ}
required
/>
<FilterableSelect
label="Color"
name="mat_color"
register={register}
options={colors.opts}
loading={colors.loading}
onQuery={colors.setQ}
required
/>
<FilterableSelect
label="Opacity"
name="mat_opacity"
register={register}
options={opacs.opts}
loading={opacs.loading}
onQuery={opacs.setQ}
required
/>
<FilterableSelect
label="Laser Source"
name="source"
register={register}
options={srcs.opts}
loading={srcs.loading}
onQuery={srcs.setQ}
required
/>
<FilterableSelect
label="Lens"
name="lens"
register={register}
options={lens.opts}
loading={lens.loading}
onQuery={lens.setQ}
required
/>
</div>
{/* Focus, thickness, repeat_all */}
<div className="grid md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1">Material Thickness (mm)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1" {...register("mat_thickness")} />
</div>
<div>
<label className="block text-sm mb-1">
Focus (mm) <span className="text-red-600">*</span>
</label>
<input
type="number"
min={-10}
max={10}
step="1"
className="w-full border rounded px-2 py-1"
{...register("focus", { required: true })}
/>
<p className="text-xs text-muted-foreground mt-1">
0 = in focus. Negative = focus closer. Positive = focus further.
</p>
</div>
<div>
<label className="block text-sm mb-1">
Repeat All <span className="text-red-600">*</span>
</label>
<input type="number" step="1" className="w-full border rounded px-2 py-1" {...register("repeat_all", { required: true })} />
</div>
</div>
{/* FILL */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Fill Settings</legend>
<button
type="button"
className="px-2 py-1 border rounded"
onClick={() => fills.append({ type: "uni" })} // default ensures value populated
>
+ Add
</button>
</div>
{fills.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`fill_settings.${i}.name`)} />
<FilterableSelect
label="Type"
name={`fill_settings.${i}.type`}
register={register}
options={fillType.opts}
loading={false}
onQuery={() => {}}
placeholder="Select type"
/>
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.pulse`)} />
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.speed`)} />
<input placeholder="Interval (mm)" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.interval`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.pass`)} />
<input placeholder="Angle (°)" type="number" step="1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.angle`)} />
<input placeholder="Increment" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.increment`)} />
<div className="flex items-center gap-3">
<BoolBox label="Auto" name={`fill_settings.${i}.auto`} register={register} />
<BoolBox label="Cross" name={`fill_settings.${i}.cross`} register={register} />
</div>
<div className="flex items-center gap-3">
<BoolBox label="Flood" name={`fill_settings.${i}.flood`} register={register} />
<BoolBox label="Air" name={`fill_settings.${i}.air`} register={register} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => fills.remove(i)}>
Remove
</button>
</div>
))}
</fieldset>
{/* LINE */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Line Settings</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => lines.append({})}>
+ Add
</button>
</div>
{lines.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`line_settings.${i}.name`)} />
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.pulse`)} />
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.speed`)} />
<input placeholder="Perf" className="border rounded px-2 py-1" {...register(`line_settings.${i}.perf`)} />
<input placeholder="Cut" className="border rounded px-2 py-1" {...register(`line_settings.${i}.cut`)} />
<input placeholder="Skip" className="border rounded px-2 py-1" {...register(`line_settings.${i}.skip`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`line_settings.${i}.pass`)} />
<input placeholder="Step" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`line_settings.${i}.step`)} />
<input placeholder="Size" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`line_settings.${i}.size`)} />
<BoolBox label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
<BoolBox label="Air" name={`line_settings.${i}.air`} register={register} />
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => lines.remove(i)}>
Remove
</button>
</div>
))}
</fieldset>
{/* RASTER */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Raster Settings</legend>
<button
type="button"
className="px-2 py-1 border rounded"
onClick={() => rasters.append({ type: "uni", dither: "threshold" })} // defaults ensure values are set
>
+ Add
</button>
</div>
{rasters.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<input placeholder="Name" className="border rounded px-2 py-1 md:col-span-2" {...register(`raster_settings.${i}.name`)} />
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.frequency`)} />
<input placeholder="Pulse (ns)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.pulse`)} />
<FilterableSelect
label="Type"
name={`raster_settings.${i}.type`}
register={register}
options={rasterType.opts}
loading={false}
onQuery={() => {}}
placeholder="Select type"
/>
<FilterableSelect
label="Dither"
name={`raster_settings.${i}.dither`}
register={register}
options={rasterDither.opts}
loading={false}
onQuery={() => {}}
placeholder="Select dither"
/>
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.power`)} />
<input placeholder="Speed (mm/s)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.speed`)} />
<input placeholder="Halftone Cell" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.halftone_cell`)} />
<input placeholder="Halftone Angle" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.halftone_angle`)} />
<input placeholder="Interval (mm)" type="number" step="0.001" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.interval`)} />
<input placeholder="Dot" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.dot`)} />
<input placeholder="Pass" type="number" step="1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.pass`)} />
<BoolBox label="Cross" name={`raster_settings.${i}.cross`} register={register} />
<div className="flex items-center gap-3">
<BoolBox label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
<BoolBox label="Air" name={`raster_settings.${i}.air`} register={register} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => rasters.remove(i)}>
Remove
</button>
</div>
))}
</fieldset>
<button
disabled={isSubmitting}
className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50"
>
{isSubmitting ? (isEdit ? "Saving…" : "Submitting…") : (isEdit ? "Save Changes" : "Submit Settings")}
</button>
</form>
</div>
);
}