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) {
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 ma_at = readCookie("ma_at", cookieHeader);
const headers: Record<string, string> = { "cache-control": "no-store" };
if (cookieHeader) headers.cookie = cookieHeader;
if (ma_at) headers.authorization = `Bearer ${ma_at}`;
if (cookieHeader) headers.cookie = cookieHeader; // session cookie
if (ma_at) headers.authorization = `Bearer ${ma_at}`; // direct token (if present)
const res = await fetch(url, { headers, cache: "no-store" });
const body = await res.json().catch(() => ({}));
const res = await fetch(url, { headers, cache: "no-store" });
const raw = await res.json().catch(() => ({}));
const data = raw?.data ?? raw ?? null; // normalize
return new NextResponse(JSON.stringify(body), {
status: res.status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
return NextResponse.json(
{ data },
{ status: res.status, headers: { "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";
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) {
// If your schema supports target filtering, add it here. Otherwise we return all.
const url = new URL(`${BASE}/items/laser_source`);
url.searchParams.set("fields", "id,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);
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), {
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
cache: "no-store",
});
const text = await res.text().catch(() => "");
@ -28,7 +32,8 @@ async function dFetch(bearer: string, target?: string | null) {
export async function GET(req: NextRequest) {
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 });
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 data = rows.map(({ id, name }) => ({ id, name }));
const rows: Array<{ id: number | string; name?: string; label?: string; title?: string }> =
r.json?.data ?? [];
const data = rows.map(({ id, name, label, title }) => ({
id,
name: name || label || title || "",
}));
return NextResponse.json({ data });
} catch (e: any) {
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";
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) {
// Adjust the collection name if yours differs (e.g., laser_scan_lens)
const url = new URL(`${BASE}/items/laser_scan_lens`);
url.searchParams.set("fields", "id,name");
url.searchParams.set("sort", "name");
// Example if you model per-target lenses:
// if (target) url.searchParams.set("filter[target][_eq]", target);
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), {
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
cache: "no-store",
});
const text = await res.text().catch(() => "");
@ -28,7 +32,8 @@ async function dFetch(bearer: string, target?: string | null) {
export async function GET(req: NextRequest) {
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 });
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 data = rows.map(({ id, name }) => ({ id, name }));
const rows: Array<{ id: number | string; name?: string; label?: string; title?: string }> =
r.json?.data ?? [];
const data = rows.map(({ id, name, label, title }) => ({
id,
name: name || label || title || "",
}));
return NextResponse.json({ data });
} catch (e: any) {
return NextResponse.json({ error: e?.message || "Failed to load lenses" }, { status: 500 });

View file

@ -49,7 +49,14 @@ function useOptions(path: string) {
}
function FilterableSelect({
label, name, register, options, loading, onQuery, placeholder = "—", required = false,
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
}: {
label: string;
name: string;
@ -61,7 +68,9 @@ function FilterableSelect({
required?: boolean;
}) {
const [filter, setFilter] = useState("");
useEffect(() => { onQuery?.(filter); }, [filter, onQuery]);
useEffect(() => {
onQuery?.(filter);
}, [filter, onQuery]);
const filtered = useMemo(() => {
if (!filter) return options;
@ -81,18 +90,21 @@ function FilterableSelect({
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>
<option value="">
{placeholder}
{loading ? " (loading…)" : ""}
</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
);
}
function BoolBox({ label, name, register }:{
label: string; name: string; register: UseFormRegister<any>;
}) {
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}
@ -109,11 +121,16 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
// Map collection -> slug used by options endpoints
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";
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]);
@ -134,54 +151,76 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
let alive = true;
fetch("/api/me", { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((j) => { if (alive) setMe(j?.data || j || null); })
.catch(() => { if (alive) setMeErr("not-signed-in"); });
return () => { alive = false; };
.then((j) => {
if (alive) setMe(j?.data || j || null);
})
.catch(() => {
if (alive) setMeErr("not-signed-in");
});
return () => {
alive = false;
};
}, []);
// Prefer username; avoid falling back to ID
const meLabel =
(me?.username && me.username.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()) ||
"Unknown user";
// Options
const mats = useOptions("material");
const coats = useOptions("material_coating");
const mats = useOptions("material");
const coats = useOptions("material_coating");
const colors = useOptions("material_color");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software");
const opacs = useOptions("material_opacity");
const soft = useOptions("laser_software");
// IMPORTANT: your API expects ?target=, not ?type=
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
const lens = useOptions(`lens?target=${typeForOptions}`);
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
const lens = useOptions(`lens?target=${typeForOptions}`);
// Repeater choice options
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 fillType = useOptions(`repeater-choices?target=${target}&group=fill_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 { register, handleSubmit, control, reset, formState: { isSubmitting } } = useForm<any>({
const {
register,
handleSubmit,
control,
reset,
formState: { isSubmitting },
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
mat: "", mat_coat: "", mat_color: "", mat_opacity: "",
mat_thickness: "", source: "", lens: "", focus: "",
laser_soft: "", repeat_all: "",
fill_settings: [], line_settings: [], raster_settings: [],
mat: "",
mat_coat: "",
mat_color: "",
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 lines = useFieldArray({ control, name: "line_settings" });
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
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;
async function onSubmit(values: any) {
@ -204,7 +243,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
source: values.source || null,
lens: values.lens || null,
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) => ({
name: r.name || "",
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 {
let res: Response;
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) {
setFile(file);
if (!file) { setPreview(""); return; }
if (!file) {
setPreview("");
return;
}
const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || ""));
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>
) : 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">
{/* 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>
<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>
@ -365,20 +402,42 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
{/* Images */}
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">Result Photo <span className="text-red-600">*</span></label>
<input type="file" accept="image/*" data-role="photo" required
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} />
<label className="block text-sm mb-1">
Result Photo <span className="text-red-600">*</span>
</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">
{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>
{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>
<input type="file" accept="image/*"
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} />
<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."}
{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>
@ -392,39 +451,399 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
{/* 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="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
/>
{/* 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 label="Lens" name="lens" register={register} options={lens.opts} loading={lens.loading} onQuery={lens.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 */}
{/* Focus, thickness, repeat_all (repeat_all required for 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>
<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>
{target === "settings_fiber" && (
<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>
<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({})}>
+ 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>
)}
<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`)}
/>
{/* FILL / LINE / RASTER (unchanged from your current) */}
{/* ... keep your existing repeaters here ... */}
<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`)}
/>
<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"}
</button>
</form>