// 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"; 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; }; 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)}`; } // ───────────────────────────────────────────────────────────── // Normalizers for edit-mode prefill (IDs + enums) // ───────────────────────────────────────────────────────────── 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 normalizeEnums(value: any, allowed: string[], fallback: string) { const v = value == null ? "" : String(value).toLowerCase(); return allowed.includes(v) ? v : fallback; } type BaseProps = { initialTarget?: Target }; type EditInitialValues = { setting_title?: string; setting_notes?: string; photo?: string | File | { id?: string } | null; screen?: string | File | { 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; // may exist on CO2 targets lens_conf?: number | null; lens_apt?: number | null; lens_exp?: number | null; fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; }; function normalizeForReset(iv: EditInitialValues) { return { ...iv, // Single-selects → string IDs 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), // Arrays: coerce dropdown-ish fields to internal enum keys fill_settings: (iv.fill_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"), })), raster_settings: (iv.raster_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"), dither: normalizeEnums( r?.dither, [ "threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale", ], "threshold" ), })), line_settings: (iv.line_settings ?? []).map((r: any) => ({ ...r })), }; } // ───────────────────────────────────────────────────────────── /** Directus field whitelists + mapper (prevents drift) */ // ───────────────────────────────────────────────────────────── const DIRECTUS_FIELDS: Record = { settings_co2gal: [ "setting_title", "setting_notes", "photo", "screen", "mat", "mat_coat", "mat_color", "mat_opacity", "mat_thickness", "source", "lens", "focus", "laser_soft", "repeat_all", "fill_settings", "line_settings", "raster_settings", // extras "uploader", "lens_conf", "lens_apt", "lens_exp", ], settings_co2gan: [ "setting_title", "setting_notes", "photo", "screen", "mat", "mat_coat", "mat_color", "mat_opacity", "mat_thickness", "source", "lens", "focus", "laser_soft", "repeat_all", "fill_settings", "line_settings", "raster_settings", "uploader", "lens_conf", ], settings_fiber: [ "setting_title", "setting_notes", "photo", "screen", "mat", "mat_coat", "mat_color", "mat_opacity", "mat_thickness", "source", "lens", "focus", "laser_soft", "repeat_all", "fill_settings", "line_settings", "raster_settings", "uploader", ], settings_uv: [ "setting_title", "setting_notes", "photo", "screen", "mat", "mat_coat", "mat_color", "mat_opacity", "mat_thickness", "source", "lens", "focus", "laser_soft", "repeat_all", "fill_settings", "line_settings", "raster_settings", "uploader", ], } as const; function toDirectusData(target: Target, full: any) { const allow = new Set(DIRECTUS_FIELDS[target]); const out: any = {}; for (const k of Object.keys(full)) { if (!allow.has(k)) continue; const v = full[k]; if (v === "") continue; // avoid empty strings confusing required validation out[k] = v; } return out; } // ───────────────────────────────────────────────────────────── // Props (Create vs Edit) + type guard // ───────────────────────────────────────────────────────────── type CreateProps = BaseProps & { mode?: "create"; submissionId?: never; initialValues?: never; }; type EditProps = BaseProps & { mode: "edit"; submissionId: string | number; initialValues: EditInitialValues; }; function isEditProps(p: CreateProps | EditProps): p is EditProps { return (p as any)?.mode === "edit"; } // ───────────────────────────────────────────────────────────── // Options loader (materials, lenses, etc.) // ───────────────────────────────────────────────────────────── function useOptions(path: string, forceIncludeId?: string, opts?: { disableNmFilter?: boolean }) { const [optsState, setOptsState] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); const disableNmFilter = opts?.disableNmFilter === true; // 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") { url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; const range = disableNmFilter ? null : 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 setOptsState([]); 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); // Ensure the currently selected value is present even if filters/pagination miss it let ensured = mapped; if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) { ensured = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped]; } // client-side text filter const needle = (q || "").trim().toLowerCase(); const filtered = needle ? ensured.filter((o) => o.label.toLowerCase().includes(needle)) : ensured; if (alive) setOptsState(filtered); })() .catch(() => alive && setOptsState([])) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [path, q, forceIncludeId, disableNmFilter]); return { opts: optsState, loading, setQ }; } // ───────────────────────────────────────────────────────────── // Small 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 (
); } // ───────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────── export default function SettingsSubmit(props: CreateProps | EditProps) { const router = useRouter(); const sp = useSearchParams(); const isEdit = isEditProps(props); const edit = isEdit ? props : null; // strongly-typed local when edit // Initialize as CO2-galvo in edit mode (unless explicitly overridden) const initialFromQuery = (sp.get("target") as Target) || props.initialTarget || (isEdit ? "settings_co2gal" : "settings_fiber"); const [target, setTarget] = useState(initialFromQuery); useEffect(() => { if (isEdit && props.initialTarget && props.initialTarget !== target) { setTarget(props.initialTarget); } }, [isEdit, props.initialTarget, target]); // 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(null); const [screenFile, setScreenFile] = useState(null); const [photoPreview, setPhotoPreview] = useState(""); const [screenPreview, setScreenPreview] = useState(""); // UX error for auth/submit const [submitErr, setSubmitErr] = useState(null); // Current signed-in user (banner + uploader) const [me, setMe] = useState(null); const [meErr, setMeErr] = useState(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?.username ?? ""; // For edit-mode, compute normalized current values once to seed option lists const current = useMemo( () => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null), [isEdit, edit?.initialValues] ); // Options const mats = useOptions("material", current?.mat || undefined); const coats = useOptions("material_coating", current?.mat_coat || undefined); const colors = useOptions("material_color", current?.mat_color || undefined); const opacs = useOptions("material_opacity", current?.mat_opacity || undefined); const soft = useOptions("laser_software", current?.laser_soft || undefined); // required for ALL targets const srcs = useOptions(`laser_source?target=${typeForOptions}`, current?.source || undefined, { disableNmFilter: isEdit, }); const lens = useOptions(`lens?target=${typeForOptions}`, current?.lens || undefined); // 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) => {} }; const { register, handleSubmit, control, reset, setValue, getValues, formState: { isSubmitting }, } = useForm({ defaultValues: { setting_title: "", setting_notes: "", mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: "", source: "", lens: "", focus: "", laser_soft: "", repeat_all: "", // on all targets // lens config (may be required on CO2 targets) lens_conf: "", lens_apt: "", lens_exp: "", 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 the form in edit mode useEffect(() => { if (isEdit && edit?.initialValues) { const iv = normalizeForReset(edit.initialValues); reset({ setting_title: iv.setting_title ?? "", setting_notes: iv.setting_notes ?? "", photo: iv.photo ?? null, screen: iv.screen ?? null, mat: iv.mat ?? "", mat_coat: iv.mat_coat ?? "", mat_color: iv.mat_color ?? "", mat_opacity: iv.mat_opacity ?? "", mat_thickness: iv.mat_thickness ?? "", source: iv.source ?? "", lens: iv.lens ?? "", focus: iv.focus ?? "", laser_soft: iv.laser_soft ?? "", repeat_all: iv.repeat_all ?? "", lens_conf: (iv as any).lens_conf ?? "", lens_apt: (iv as any).lens_apt ?? "", lens_exp: (iv as any).lens_exp ?? "", fill_settings: iv.fill_settings ?? [], line_settings: iv.line_settings ?? [], raster_settings: iv.raster_settings ?? [], }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEdit, edit?.initialValues, reset]); // After reset, force RHF values once (covers early case) useEffect(() => { if (!isEdit || !current) return; const fieldNames = [ "laser_soft", "mat", "mat_coat", "mat_color", "mat_opacity", "source", "lens", ] as const; const values = getValues(); fieldNames.forEach((name) => { const cur = (current as any)[name]; const now = (values as any)[name]; if (cur && (now == null || now === "")) { setValue(name as any, cur, { shouldDirty: false, shouldValidate: false }); } }); }, [isEdit, current, getValues, setValue]); // When options hydrate/change, re-apply the current ids so the select shows them useEffect(() => { if (!isEdit || !current) return; const apply = (name: keyof typeof current) => { const cur = (current as any)[name]; if (cur) setValue(name as any, cur, { shouldDirty: false, shouldValidate: false }); }; apply("mat"); apply("mat_coat"); apply("mat_color"); apply("mat_opacity"); apply("laser_soft"); apply("source"); apply("lens"); }, [ isEdit, current, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, ]); function num(v: any) { return v === "" || v == null ? null : Number(v); } const bool = (v: any) => !!v; // ───────────────────────────────────────────────────────────── // SUBMIT: Match prod envelope → ALWAYS multipart with "payload" // ───────────────────────────────────────────────────────────── async function onSubmit(values: any) { setSubmitErr(null); // Create vs Edit: photo is required unless an existing photo id is present or a new file is picked const currentPhotoId = isEdit && typeof edit!.initialValues?.photo === "string" && edit!.initialValues.photo ? (edit!.initialValues.photo as string) : null; const requirePhoto = !currentPhotoId && !photoFile; if (requirePhoto) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } // full UI payload (same shape the form uses) const fullPayload: any = { target, // kept top-level for route parity 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 // uploader: set automatically from owner; include only if present on client ...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}), // lens config (required for CO2 targets per your list) lens_conf: num(values.lens_conf), lens_apt: num(values.lens_apt), lens_exp: num(values.lens_exp), 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), })), }; // Whitelist to match collection fields and drop empty strings const directusData = toDirectusData(target, fullPayload); // Early guard for the common required field if (!directusData.setting_title) { setSubmitErr("Title is required."); return; } // Build prod-compatible flat payload and ALWAYS send multipart const base = isEdit && edit?.submissionId ? { target, mode: "edit" as const, submission_id: edit.submissionId } : { target }; const flatPayload = { ...base, ...directusData, // Keep top-level setting_title for route validators setting_title: directusData.setting_title, }; try { const form = new FormData(); form.set("payload", JSON.stringify(flatPayload)); // prod-compatible key 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"); } // Success if (!isEdit) { reset(); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); } const id = (data as any)?.id ? String((data as any).id) : String(edit?.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); } // Convenience strings for “Current:” (edit mode) const currentPhotoId = isEdit && typeof edit?.initialValues?.photo === "string" ? (edit!.initialValues.photo as string) : null; const currentScreenId = isEdit && typeof edit?.initialValues?.screen === "string" ? (edit!.initialValues.screen as string) : null; return (
{/* Target + Software (Software required for ALL targets) */}
{/* Submitting-as banner */} {me ? (
Submitting as {meLabel}.
) : 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 */}