fix for lens/source submission bugs

This commit is contained in:
makearmy 2025-09-28 08:47:34 -04:00
parent aa56de71c0
commit d181e4dc27
4 changed files with 533 additions and 99 deletions

View file

@ -8,24 +8,21 @@ function readCookie(name: string, cookieHeader: string) {
export async function GET(req: Request) { export async function GET(req: Request) {
const base = process.env.NEXT_PUBLIC_API_BASE_URL!; const base = process.env.NEXT_PUBLIC_API_BASE_URL!;
// include username so the form can show it const url = `${base}/users/me?fields=id,username,display_name,first_name,last_name,email`;
const url = `${base}/users/me?fields=id,username,display_name,first_name,last_name,email`;
const cookieHeader = req.headers.get("cookie") ?? ""; const cookieHeader = req.headers.get("cookie") ?? "";
const ma_at = readCookie("ma_at", cookieHeader); const ma_at = readCookie("ma_at", cookieHeader);
const headers: Record<string, string> = { "cache-control": "no-store" }; const headers: Record<string, string> = { "cache-control": "no-store" };
if (cookieHeader) headers.cookie = cookieHeader; if (cookieHeader) headers.cookie = cookieHeader; // session cookie
if (ma_at) headers.authorization = `Bearer ${ma_at}`; if (ma_at) headers.authorization = `Bearer ${ma_at}`; // direct token (if present)
const res = await fetch(url, { headers, cache: "no-store" }); const res = await fetch(url, { headers, cache: "no-store" });
const body = await res.json().catch(() => ({})); const raw = await res.json().catch(() => ({}));
const data = raw?.data ?? raw ?? null; // normalize
return new NextResponse(JSON.stringify(body), { return NextResponse.json(
status: res.status, { data },
headers: { { status: res.status, headers: { "content-type": "application/json", "cache-control": "no-store" } }
"content-type": "application/json", );
"cache-control": "no-store",
},
});
} }

View file

@ -3,21 +3,25 @@ export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const BASE =
(process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
function buildPath(target?: string | null) { function buildPath(target?: string | null) {
// If your schema supports target filtering, add it here. Otherwise we return all.
const url = new URL(`${BASE}/items/laser_source`); const url = new URL(`${BASE}/items/laser_source`);
url.searchParams.set("fields", "id,name"); url.searchParams.set("fields", "id,name");
url.searchParams.set("sort", "name"); url.searchParams.set("sort", "name");
// Example (uncomment/adjust if you actually have a `target` field or relation):
// if (target) url.searchParams.set("filter[target][_eq]", target); // if (target) url.searchParams.set("filter[target][_eq]", target);
return String(url); return String(url);
} }
async function dFetch(bearer: string, target?: string | null) { function readCookieFromHeader(name: string, cookieHeader: string) {
const m = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return m?.[1] ?? null;
}
async function dFetch(token: string, target?: string | null) {
const res = await fetch(buildPath(target), { const res = await fetch(buildPath(target), {
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
cache: "no-store", cache: "no-store",
}); });
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@ -28,7 +32,8 @@ async function dFetch(bearer: string, target?: string | null) {
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const userAt = req.cookies.get("ma_at")?.value; const cookieHeader = req.headers.get("cookie") ?? "";
const userAt = req.cookies.get("ma_at")?.value || readCookieFromHeader("ma_at", cookieHeader);
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const target = req.nextUrl.searchParams.get("target"); const target = req.nextUrl.searchParams.get("target");
@ -40,8 +45,12 @@ export async function GET(req: NextRequest) {
); );
} }
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? []; const rows: Array<{ id: number | string; name?: string; label?: string; title?: string }> =
const data = rows.map(({ id, name }) => ({ id, name })); r.json?.data ?? [];
const data = rows.map(({ id, name, label, title }) => ({
id,
name: name || label || title || "",
}));
return NextResponse.json({ data }); return NextResponse.json({ data });
} catch (e: any) { } catch (e: any) {
return NextResponse.json({ error: e?.message || "Failed to load laser sources" }, { status: 500 }); return NextResponse.json({ error: e?.message || "Failed to load laser sources" }, { status: 500 });

View file

@ -3,21 +3,25 @@ export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const BASE =
(process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
function buildPath(target?: string | null) { function buildPath(target?: string | null) {
// Adjust the collection name if yours differs (e.g., laser_scan_lens)
const url = new URL(`${BASE}/items/laser_scan_lens`); const url = new URL(`${BASE}/items/laser_scan_lens`);
url.searchParams.set("fields", "id,name"); url.searchParams.set("fields", "id,name");
url.searchParams.set("sort", "name"); url.searchParams.set("sort", "name");
// Example if you model per-target lenses:
// if (target) url.searchParams.set("filter[target][_eq]", target); // if (target) url.searchParams.set("filter[target][_eq]", target);
return String(url); return String(url);
} }
async function dFetch(bearer: string, target?: string | null) { function readCookieFromHeader(name: string, cookieHeader: string) {
const m = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return m?.[1] ?? null;
}
async function dFetch(token: string, target?: string | null) {
const res = await fetch(buildPath(target), { const res = await fetch(buildPath(target), {
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
cache: "no-store", cache: "no-store",
}); });
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@ -28,7 +32,8 @@ async function dFetch(bearer: string, target?: string | null) {
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const userAt = req.cookies.get("ma_at")?.value; const cookieHeader = req.headers.get("cookie") ?? "";
const userAt = req.cookies.get("ma_at")?.value || readCookieFromHeader("ma_at", cookieHeader);
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const target = req.nextUrl.searchParams.get("target"); const target = req.nextUrl.searchParams.get("target");
@ -40,8 +45,12 @@ export async function GET(req: NextRequest) {
); );
} }
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? []; const rows: Array<{ id: number | string; name?: string; label?: string; title?: string }> =
const data = rows.map(({ id, name }) => ({ id, name })); r.json?.data ?? [];
const data = rows.map(({ id, name, label, title }) => ({
id,
name: name || label || title || "",
}));
return NextResponse.json({ data }); return NextResponse.json({ data });
} catch (e: any) { } catch (e: any) {
return NextResponse.json({ error: e?.message || "Failed to load lenses" }, { status: 500 }); return NextResponse.json({ error: e?.message || "Failed to load lenses" }, { status: 500 });

View file

@ -49,7 +49,14 @@ function useOptions(path: string) {
} }
function FilterableSelect({ function FilterableSelect({
label, name, register, options, loading, onQuery, placeholder = "—", required = false, label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
}: { }: {
label: string; label: string;
name: string; name: string;
@ -61,7 +68,9 @@ function FilterableSelect({
required?: boolean; required?: boolean;
}) { }) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
useEffect(() => { onQuery?.(filter); }, [filter, onQuery]); useEffect(() => {
onQuery?.(filter);
}, [filter, onQuery]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!filter) return options; if (!filter) return options;
@ -81,18 +90,21 @@ function FilterableSelect({
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
/> />
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}> <select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option> <option value="">
{placeholder}
{loading ? " (loading…)" : ""}
</option>
{filtered.map((o) => ( {filtered.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option> <option key={o.id} value={o.id}>
{o.label}
</option>
))} ))}
</select> </select>
</div> </div>
); );
} }
function BoolBox({ label, name, register }:{ function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister<any> }) {
label: string; name: string; register: UseFormRegister<any>;
}) {
return ( return (
<label className="flex items-center gap-1 text-sm"> <label className="flex items-center gap-1 text-sm">
<input type="checkbox" {...register(name)} /> {label} <input type="checkbox" {...register(name)} /> {label}
@ -109,11 +121,16 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
// Map collection -> slug used by options endpoints // Map collection -> slug used by options endpoints
const typeForOptions = useMemo(() => { const typeForOptions = useMemo(() => {
switch (target) { switch (target) {
case "settings_fiber": return "fiber"; case "settings_fiber":
case "settings_uv": return "uv"; return "fiber";
case "settings_co2gan": return "co2-gantry"; case "settings_uv":
case "settings_co2gal": return "co2-galvo"; return "uv";
default: return "fiber"; case "settings_co2gan":
return "co2-gantry";
case "settings_co2gal":
return "co2-galvo";
default:
return "fiber";
} }
}, [target]); }, [target]);
@ -134,54 +151,76 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
let alive = true; let alive = true;
fetch("/api/me", { cache: "no-store", credentials: "include" }) fetch("/api/me", { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((j) => { if (alive) setMe(j?.data || j || null); }) .then((j) => {
.catch(() => { if (alive) setMeErr("not-signed-in"); }); if (alive) setMe(j?.data || j || null);
return () => { alive = false; }; })
.catch(() => {
if (alive) setMeErr("not-signed-in");
});
return () => {
alive = false;
};
}, []); }, []);
// Prefer username; avoid falling back to ID // Prefer username; avoid falling back to ID
const meLabel = const meLabel =
(me?.username && me.username.trim()) || (me?.username && me.username.trim()) ||
(me?.email && me.email.trim()) || (me?.email && me.email.trim()) ||
([me?.first_name, me?.last_name].filter(Boolean).join(" ").trim()) || [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
(me?.display_name && me.display_name.trim()) || (me?.display_name && me.display_name.trim()) ||
"Unknown user"; "Unknown user";
// Options // Options
const mats = useOptions("material"); const mats = useOptions("material");
const coats = useOptions("material_coating"); const coats = useOptions("material_coating");
const colors = useOptions("material_color"); const colors = useOptions("material_color");
const opacs = useOptions("material_opacity"); const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software"); const soft = useOptions("laser_software");
// IMPORTANT: your API expects ?target=, not ?type= // IMPORTANT: your API expects ?target=, not ?type=
const srcs = useOptions(`laser_source?target=${typeForOptions}`); const srcs = useOptions(`laser_source?target=${typeForOptions}`);
const lens = useOptions(`lens?target=${typeForOptions}`); const lens = useOptions(`lens?target=${typeForOptions}`);
// Repeater choice options // Repeater choice options
const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`);
const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`);
const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`);
const { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm<any>({ const {
register,
handleSubmit,
control,
reset,
formState: { isSubmitting },
} = useForm<any>({
defaultValues: { defaultValues: {
setting_title: "", setting_title: "",
setting_notes: "", setting_notes: "",
mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat: "",
mat_thickness: "", source: "", lens: "", focus: "", mat_coat: "",
laser_soft: "", repeat_all: "", mat_color: "",
fill_settings: [], line_settings: [], raster_settings: [], mat_opacity: "",
mat_thickness: "",
source: "",
lens: "",
focus: "",
laser_soft: "",
repeat_all: "", // required for ALL targets now
fill_settings: [],
line_settings: [],
raster_settings: [],
}, },
}); });
const fills = useFieldArray({ control, name: "fill_settings" }); const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" }); const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" });
const isGantry = target === "settings_co2gan"; const isGantry = target === "settings_co2gan";
const isFiber = target === "settings_fiber";
function num(v: any) { return (v === "" || v == null) ? null : Number(v); } function num(v: any) {
return v === "" || v == null ? null : Number(v);
}
const bool = (v: any) => !!v; const bool = (v: any) => !!v;
async function onSubmit(values: any) { async function onSubmit(values: any) {
@ -204,7 +243,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
source: values.source || null, source: values.source || null,
lens: values.lens || null, lens: values.lens || null,
focus: num(values.focus), focus: num(values.focus),
laser_soft: values.laser_soft || null, // <-- always include for ALL targets laser_soft: values.laser_soft || null, // required for all targets
repeat_all: num(values.repeat_all), // <-- now always included
fill_settings: (values.fill_settings || []).map((r: any) => ({ fill_settings: (values.fill_settings || []).map((r: any) => ({
name: r.name || "", name: r.name || "",
power: num(r.power), power: num(r.power),
@ -255,10 +295,6 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
})), })),
}; };
if (isFiber) {
payload.repeat_all = num(values.repeat_all); // still Fiber-only
}
try { try {
let res: Response; let res: Response;
if (photoFile || screenFile) { if (photoFile || screenFile) {
@ -297,7 +333,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) { function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) {
setFile(file); setFile(file);
if (!file) { setPreview(""); return; } if (!file) {
setPreview("");
return;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || "")); reader.onload = () => setPreview(String(reader.result || ""));
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -349,15 +388,13 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
<div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div> <div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div>
) : null} ) : null}
{/* …(form continues unchanged from your current version)… */}
{/* I left the rest of your form intact aside from the changes above. */}
{/* -- Title, Images, Notes, Material/Source/Lens, Focus, Fill/Line/Raster, Submit -- */}
{/* Title */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Title */}
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm mb-1">Title <span className="text-red-600">*</span></label> <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 })} /> <input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div> </div>
</div> </div>
@ -365,20 +402,42 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
{/* Images */} {/* Images */}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm mb-1">Result Photo <span className="text-red-600">*</span></label> <label className="block text-sm mb-1">
<input type="file" accept="image/*" data-role="photo" required Result Photo <span className="text-red-600">*</span>
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} /> </label>
<input
type="file"
accept="image/*"
data-role="photo"
required
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)}
/>
<p className="text-xs text-muted-foreground mt-1"> <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."} {photoFile ? (
<>
Selected: <span className="font-mono">{photoFile.name}</span>
</>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p> </p>
{photoPreview ? <img src={photoPreview} alt="Result preview" className="mt-2 rounded border" /> : null} {photoPreview ? <img src={photoPreview} alt="Result preview" className="mt-2 rounded border" /> : null}
</div> </div>
<div> <div>
<label className="block text-sm mb-1">Settings Screenshot (optional)</label> <label className="block text-sm mb-1">Settings Screenshot (optional)</label>
<input type="file" accept="image/*" <input
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} /> type="file"
accept="image/*"
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)}
/>
<p className="text-xs text-muted-foreground mt-1"> <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."} {screenFile ? (
<>
Selected: <span className="font-mono">{screenFile.name}</span>
</>
) : (
"Max 25 MB. JPG/PNG/WebP recommended."
)}
</p> </p>
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null} {screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div> </div>
@ -392,39 +451,399 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
{/* Material / Source / Lens */} {/* Material / Source / Lens */}
<div className="grid md:grid-cols-2 gap-3"> <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
<FilterableSelect label="Coating" name="mat_coat" register={register} options={coats.opts} loading={coats.loading} onQuery={coats.setQ} required /> label="Material"
<FilterableSelect label="Color" name="mat_color" register={register} options={colors.opts} loading={colors.loading} onQuery={colors.setQ} required /> name="mat"
<FilterableSelect label="Opacity" name="mat_opacity" register={register} options={opacs.opts} loading={opacs.loading} onQuery={opacs.setQ} required /> 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
/>
{/* Fixed: pass ?target= to these two */} {/* Fixed: pass ?target= to these two */}
<FilterableSelect label="Laser Source" name="source" register={register} options={srcs.opts} loading={srcs.loading} onQuery={srcs.setQ} required /> <FilterableSelect
<FilterableSelect label="Lens" name="lens" register={register} options={lens.opts} loading={lens.loading} onQuery={lens.setQ} required /> 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> </div>
{/* Focus, thickness, repeat_all */} {/* Focus, thickness, repeat_all (repeat_all required for ALL) */}
<div className="grid md:grid-cols-3 gap-3"> <div className="grid md:grid-cols-3 gap-3">
<div> <div>
<label className="block text-sm mb-1">Material Thickness (mm)</label> <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")} /> <input type="number" step="0.01" className="w-full border rounded px-2 py-1" {...register("mat_thickness")} />
</div> </div>
<div> <div>
<label className="block text-sm mb-1">Focus (mm) <span className="text-red-600">*</span></label> <label className="block text-sm mb-1">
<input type="number" min={-10} max={10} step="1" className="w-full border rounded px-2 py-1" {...register("focus", { required: true })} /> Focus (mm) <span className="text-red-600">*</span>
<p className="text-xs text-muted-foreground mt-1">0 = in focus. Negative = focus closer. Positive = focus further.</p> </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>
{target === "settings_fiber" && ( <div>
<div> <label className="block text-sm mb-1">
<label className="block text-sm mb-1">Repeat All <span className="text-red-600">*</span></label> Repeat All <span className="text-red-600">*</span>
<input type="number" step="1" className="w-full border rounded px-2 py-1" {...register("repeat_all", { required: true })} /> </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({})}>
+ 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={fillType.loading}
onQuery={fillType.setQ}
placeholder="Select type"
/>
{!isGantry && (
<>
<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`)}
/>
{!isGantry && (
<>
<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> </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> </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`)}
/>
{/* FILL / LINE / RASTER (unchanged from your current) */} <input
{/* ... keep your existing repeaters here ... */} 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`)}
/>
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50"> {/* Extras (non-gantry) */}
<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="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({})}>
+ 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`)}
/>
<FilterableSelect
label="Type"
name={`raster_settings.${i}.type`}
register={register}
options={rasterType.opts}
loading={rasterType.loading}
onQuery={rasterType.setQ}
placeholder="Select type"
/>
<FilterableSelect
label="Dither"
name={`raster_settings.${i}.dither`}
register={register}
options={rasterDither.opts}
loading={rasterDither.loading}
onQuery={rasterDither.setQ}
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 ? "Submitting…" : "Submit Settings"} {isSubmitting ? "Submitting…" : "Submit Settings"}
</button> </button>
</form> </form>