// components/forms/SettingsSubmit.tsx "use client"; import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; import { useRouter, useSearchParams } from "next/navigation"; /** CO₂ Galvo–only version (fresh submit path) */ type Target = "settings_co2gal"; // ← limited on purpose type Opt = { id: string; label: string }; type Me = { id: string; username?: string; email?: string; }; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); // ───────────────────────────────────────────────────────────── // UI bits // ───────────────────────────────────────────────────────────── 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)} />
); } function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister }) { return ( ); } function LabeledInput({ label, name, type = "text", step, register, required = false, min, max, }: { label: string; name: string; type?: "text" | "number"; step?: string | number; register: UseFormRegister; required?: boolean; min?: number; max?: number; }) { return (
); } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── function shortId(s?: string) { if (!s) return ""; return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`; } function idToString(v: any): string { if (v == null || v === "") return ""; if (typeof v === "object") { if ((v as any).id != null) return String((v as any).id); if ((v as any).submission_id != null) return String((v as any).submission_id); } return String(v); } function useOptions(path: string, forceIncludeId?: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); useEffect(() => { let alive = true; setLoading(true); (async () => { let url = ""; let normalize: (rows: any[]) => Opt[] = (rows) => rows.map((r) => ({ id: String(r.id ?? r.submission_id ?? r.value), label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id) })); if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; else if (path === "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 (path === "laser_software") url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; else if (path === "laser_source") url = `${API}/items/laser_source?fields=submission_id,make,model&limit=2000&sort=make,model`, normalize = (rows) => rows.map((r) => ({ id: String(r.submission_id), label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id) })); else if (path === "laser_scan_lens") url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`; // the three fixed lists you showed (labels per your request) else if (path === "laser_scan_lens_config") url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`; else if (path === "laser_scan_lens_apt") url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; else if (path === "laser_scan_lens_exp") url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; else { setOpts([]); setLoading(false); return; } // special label for scan lenses if (path === "laser_scan_lens") { 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) }; }); }; } 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 ?? []; let mapped = normalize(rows); // include currently selected ID if not in page/filter if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) { mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped]; } const needle = (q || "").trim().toLowerCase(); const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; if (alive) setOpts(filtered); })() .catch(() => { if (alive) setOpts([]); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [path, q, forceIncludeId]); return { opts, loading, setQ }; } function normalizeForReset(iv: any) { return { ...iv, mat: idToString(iv.mat), mat_coat: idToString(iv.mat_coat), mat_color: idToString(iv.mat_color), mat_opacity:idToString(iv.mat_opacity), source: idToString(iv.source), lens: idToString(iv.lens), laser_soft: idToString(iv.laser_soft), lens_conf: idToString(iv.lens_conf), lens_apt: idToString(iv.lens_apt), lens_exp: idToString(iv.lens_exp), }; } type EditInitialValues = { submission_id: string | number; setting_title?: string; setting_notes?: string; photo?: string | { id?: string } | null; screen?: string | { id?: string } | null; mat?: any; mat_coat?: any; mat_color?: any; mat_opacity?: any; mat_thickness?: number | null; source?: any; lens?: any; focus?: number | null; laser_soft?: any; repeat_all?: number | null; lens_conf?: any; lens_apt?: any; lens_exp?: any; fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; }; // ───────────────────────────────────────────────────────────── // Component (CO₂ Galvo only) // ───────────────────────────────────────────────────────────── export default function SettingsSubmit({ mode, submissionId, initialValues, }: { mode?: "edit"; submissionId?: string | number; initialValues?: EditInitialValues; }) { const router = useRouter(); const sp = useSearchParams(); const target: Target = "settings_co2gal"; // locked // files const [photoFile, setPhotoFile] = useState(null); const [screenFile, setScreenFile] = useState(null); const [photoPreview, setPhotoPreview] = useState(""); const [screenPreview, setScreenPreview] = useState(""); // errors / me const [submitErr, setSubmitErr] = useState(null); const [me, setMe] = useState(null); const [meErr, setMeErr] = useState(null); useEffect(() => { 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 || null); }) .catch(() => { if (alive) setMeErr("not-signed-in"); }); return () => { alive = false; }; }, []); const isEdit = mode === "edit" && submissionId != null; const current = useMemo(() => (isEdit && initialValues ? normalizeForReset(initialValues) : null), [isEdit, initialValues]); // options (galvo) const mats = useOptions("material", current?.mat); const coats = useOptions("material_coating", current?.mat_coat); const colors= useOptions("material_color", current?.mat_color); const opacs = useOptions("material_opacity", current?.mat_opacity); const soft = useOptions("laser_software", current?.laser_soft); const srcs = useOptions("laser_source", current?.source); const lens = useOptions("laser_scan_lens", current?.lens); // three fixed lists (labels per your request) const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf); // "Lens Configuration" const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt); // "Scan Head Aperture" const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp); // "Beam Expander" // form const { register, handleSubmit, control, reset, setValue, getValues, formState: { isSubmitting }, } = useForm({ defaultValues: { setting_title: "", setting_notes: "", // required relations mat: "", mat_coat: "", mat_color: "", mat_opacity: "", source: "", lens: "", laser_soft: "", lens_conf: "", lens_apt: "", lens_exp: "", // numerics focus: "", repeat_all: "", mat_thickness: "", // repeaters (kept, but not required) 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" }); // prefill for edit useEffect(() => { if (!isEdit || !current) return; reset({ setting_title: current.setting_title ?? "", setting_notes: current.setting_notes ?? "", photo: current.photo ?? null, screen: current.screen ?? null, mat: current.mat ?? "", mat_coat: current.mat_coat ?? "", mat_color: current.mat_color ?? "", mat_opacity: current.mat_opacity ?? "", mat_thickness: current.mat_thickness ?? "", source: current.source ?? "", lens: current.lens ?? "", focus: current.focus ?? "", laser_soft: current.laser_soft ?? "", repeat_all: current.repeat_all ?? "", lens_conf: current.lens_conf ?? "", lens_apt: current.lens_apt ?? "", lens_exp: current.lens_exp ?? "", fill_settings: current.fill_settings ?? [], line_settings: current.line_settings ?? [], raster_settings: current.raster_settings ?? [], }); }, [isEdit, current, reset]); // keep selects stable when options hydrate useEffect(() => { if (!isEdit || !current) return; const names = ["mat","mat_coat","mat_color","mat_opacity","source","lens","laser_soft","lens_conf","lens_apt","lens_exp"] as const; const values = getValues(); names.forEach((n) => { const cur = (current as any)[n]; const now = (values as any)[n]; if (cur && (now == null || now === "")) setValue(n as any, cur, { shouldDirty: false, shouldValidate: false }); }); }, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, srcs.opts, lens.opts, soft.opts, lensConf.opts, lensApt.opts, lensExp.opts]); // numeric / bool coercers const num = (v: any) => (v === "" || v == null ? null : Number(v)); const bool = (v: any) => !!v; // submit async function onSubmit(values: any) { setSubmitErr(null); // required-photo logic: need a file unless edit already has a photo id const currentPhotoId = isEdit && typeof current?.photo === "string" ? current!.photo as string : null; if (!currentPhotoId && !photoFile) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } // build payload with only required + notes/images + repeaters const payload: any = { target, setting_title: values.setting_title, setting_notes: values.setting_notes || "", // required fields per your list source: values.source || null, lens: values.lens || null, lens_conf: values.lens_conf || null, lens_apt: values.lens_apt || null, lens_exp: values.lens_exp || null, focus: num(values.focus), mat: values.mat || null, mat_coat: values.mat_coat || null, mat_color: values.mat_color || null, mat_opacity: values.mat_opacity || null, laser_soft: values.laser_soft || null, repeat_all: num(values.repeat_all), // nice-to-have mat_thickness: num(values.mat_thickness), // server auto-sets uploader from owner, but include a mirror if available ...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}), // repeaters (optional – pass-through) 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: bool(r.cut), skip: bool(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), })), }; // edit meta if (isEdit && submissionId != null) { payload.mode = "edit"; payload.submission_id = submissionId; } try { const form = new FormData(); form.set("payload", JSON.stringify(payload)); // server route reads "payload" if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); const res = await fetch("/api/submit/settings", { method: "POST", body: form, 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 as any)?.error || "Submission failed"); } if (!isEdit) { reset(); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); } const id = (data as any)?.id ? String((data as any).id) : String(submissionId ?? ""); if (isEdit) { const q = new URLSearchParams(sp.toString()); q.delete("edit"); router.replace(`/portal/laser-settings?${q.toString()}`); } else { 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); } const currentPhotoId = isEdit && typeof current?.photo === "string" ? (current!.photo as string) : null; const currentScreenId = isEdit && typeof current?.screen === "string" ? (current!.screen as string) : null; return (
{/* Banner */} {me ? (
Submitting as {me.username || me.email || `User ${shortId(me.id)}`}.
) : meErr ? (
You’re not signed in. Submissions will fail until you sign in.
) : null} {submitErr ?
{submitErr}
: null}
{/* Title */}
{/* Images */}
{currentPhotoId &&

Current: {shortId(currentPhotoId)}

} onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} />

{photoFile ? <>Selected: {photoFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."}

{photoPreview ? Result preview : null}
{currentScreenId &&

Current: {shortId(currentScreenId)}

} onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} />

{screenFile ? <>Selected: {screenFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."}

{screenPreview ? Settings preview : null}
{/* Notes */}