"use client"; import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; /** Targets map 1:1 with your Directus collections */ type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; type Opt = { id: string; label: string }; /* ───────────────────────────────────────────────────────────── * Generic hook to fetch options with client-side filter * Expects endpoint to return: { data: Array<{id,label}> } * ──────────────────────────────────────────────────────────── */ function useOptions(path: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); useEffect(() => { let alive = true; setLoading(true); const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`; fetch(url, { cache: "no-store" }) .then((r) => r.json()) .then((j) => { if (alive) setOpts((j?.data as Opt[]) ?? []); }) .catch(() => { if (alive) setOpts([]); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [path, q]); return { opts, loading, setQ }; } /* ───────────────────────────────────────────────────────────── * Filterable select with optional "required" client rule * ──────────────────────────────────────────────────────────── */ function FilterableSelect({ label, name, register, options, loading, onQuery, placeholder = "—", required = false, }: { label: string; name: string; register: UseFormRegister; 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 (
setFilter(e.target.value)} />
); } /* ───────────────────────────────────────────────────────────── * Checkbox * ──────────────────────────────────────────────────────────── */ function BoolBox({ label, name, register }:{ label: string; name: string; register: UseFormRegister; }) { return ( ); } /* ───────────────────────────────────────────────────────────── * Polished File Picker (with preview + filename) * NOT registered into RHF; managed via local state. * Use `onFile` to push the selected File up to the parent. * ──────────────────────────────────────────────────────────── */ function FilePicker({ label, required, onFile, accept, }: { label: string; required?: boolean; onFile: (f: File | null) => void; accept?: string; }) { const [file, setFile] = useState(null); const [url, setUrl] = useState(null); function handleChange(e: React.ChangeEvent) { const f = e.target.files?.[0] || null; setFile(f); onFile(f); if (url) URL.revokeObjectURL(url); setUrl(f ? URL.createObjectURL(f) : null); } function clear() { setFile(null); onFile(null); if (url) URL.revokeObjectURL(url); setUrl(null); } const inputId = `fp-${label.replace(/\s+/g, "-").toLowerCase()}`; return (
{file && ( )}
{/* visually hidden native input; we present a custom button */}
{file ? file.name : "No file chosen"}
{url && (
{/* eslint-disable-next-line @next/next/no-img-element */} {`${label}
)}
); } /* ───────────────────────────────────────────────────────────── * Main form * ──────────────────────────────────────────────────────────── */ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Target }) { const [target, setTarget] = useState(initialTarget ?? "settings_fiber"); // Generic lists (alphabetical) const mats = useOptions("material"); const coats = useOptions("material_coating"); const colors = useOptions("material_color"); const opacs = useOptions("material_opacity"); const soft = useOptions("laser_software"); // only visible for fiber // Target-driven lists const srcs = useOptions(`laser_source?target=${target}`); // wavelength filter server-side const lens = useOptions(`lens?target=${target}`); // scan vs focus lens by target // Repeater select choices from Directus field config 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, errors } } = useForm({ defaultValues: { setting_title: "", uploader: "", setting_notes: "", mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: "", source: "", lens: "", focus: "", laser_soft: "", repeat_all: "", fill_settings: [], line_settings: [], raster_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"; // file state (not part of RHF) const [photoFile, setPhotoFile] = useState(null); const [screenFile, setScreenFile] = useState(null); const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null); // helpers function num(v: any) { return (v === "" || v == null) ? null : Number(v); } const bool = (v: any) => !!v; async function onSubmit(values: any) { setSubmitMsg(null); // client-side requireds (extra guardrails) const missing: string[] = []; if (!photoFile) missing.push("photo"); if (!values.source) missing.push("laser source"); if (!values.lens) missing.push("lens"); if (values.focus === "" || values.focus === undefined || values.focus === null) missing.push("focus"); for (const k of ["mat","mat_coat","mat_color","mat_opacity"]) { if (!values[k]) missing.push(k); } if (!values.setting_title) missing.push("title"); if (!values.uploader) missing.push("uploader"); if (isFiber) { if (!values.laser_soft) missing.push("software"); if (values.repeat_all === "" || values.repeat_all === undefined || values.repeat_all === null) missing.push("repeat_all"); } if (missing.length) { setSubmitMsg({ ok: false, text: `Missing required field(s): ${missing.join(", ")}` }); return; } // Build server payload (numbers coerced on server too) const payload: any = { target, setting_title: values.setting_title, uploader: values.uploader, 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), 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 || "", // present on fiber/uv/co2gal frequency: num(r.frequency), pulse: num(r.pulse), angle: num(r.angle), auto: bool(r.auto), increment: num(r.increment), cross: bool(r.cross), // present on all 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), // extra on fiber/uv/co2gal 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), // extras on fiber/uv/co2gal frequency: num(r.frequency), pulse: num(r.pulse), cross: bool(r.cross), })), }; if (isFiber) { payload.laser_soft = values.laser_soft || null; payload.repeat_all = num(values.repeat_all); } // Use multipart/form-data with JSON payload + files const form = new FormData(); form.set("payload", JSON.stringify(payload)); if (photoFile) form.set("photo", photoFile, photoFile.name); if (screenFile) form.set("screen", screenFile, screenFile.name); const res = await fetch("/api/submit/settings", { method: "POST", body: form, }); const text = await res.text(); let data: any = {}; try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON error page */ } if (!res.ok) { setSubmitMsg({ ok: false, text: `Submission failed: ${data?.error || res.statusText}` }); return; } setSubmitMsg({ ok: true, text: `Submitted! ID: ${data?.id ?? "unknown"}` }); // reset form (keep target) reset({ setting_title: "", uploader: "", setting_notes: "", mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: "", source: "", lens: "", focus: "", laser_soft: "", repeat_all: "", fill_settings: [], line_settings: [], raster_settings: [], }); setPhotoFile(null); setScreenFile(null); } return (
{isFiber && (
)}
{/* identity */}
{errors.setting_title &&

Title is required

}
{errors.uploader &&

Uploader is required

}