"use client"; import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; import { useRouter, useSearchParams } from "next/navigation"; /* ──────────────────────────────────────────────────────────── * Types * ──────────────────────────────────────────────────────────── */ export 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; }; export type FormValues = { setting_title: string; setting_notes?: string; // files (id string or File at the UI level) photo?: string | File | null; screen?: string | File | null; // relations / numerics mat?: any; mat_coat?: any; mat_color?: any; mat_opacity?: any; mat_thickness?: number | null; source?: any; lens?: any; focus?: number | null; // flags laser_soft?: string | null; repeat_all?: number | null; // repeaters fill_settings: any[]; line_settings: any[]; raster_settings: any[]; }; type CreateProps = { mode?: "create"; // default initialTarget?: Target; initialValues?: Partial; onSaved?: (id: string | number) => void; }; type EditProps = { mode: "edit"; initialTarget: Target; // locked target submissionId: string | number; // external submission_id initialValues: FormValues; // full snapshot to edit onSaved?: (id: string | number) => void; }; export type SettingsSubmitProps = CreateProps | EditProps; /* ──────────────────────────────────────────────────────────── */ 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)}`; } /* ──────────────────────────────────────────────────────────── * Options helpers * ──────────────────────────────────────────────────────────── */ function useOptions(path: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); // 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") { // fetch all and client-filter by nm url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; const range = 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 setOpts([]); 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); // client-side text filter const needle = (q || "").trim().toLowerCase(); const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; if (alive) setOpts(filtered); })() .catch(() => alive && setOpts([])) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [path, q]); return { opts, loading, setQ }; } /* ──────────────────────────────────────────────────────────── * Small inputs * ──────────────────────────────────────────────────────────── */ 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 ( ); } /* ──────────────────────────────────────────────────────────── * Component * ──────────────────────────────────────────────────────────── */ export default function SettingsSubmit(props: SettingsSubmitProps) { const { mode = "create" } = props; const isEdit = mode === "edit"; const router = useRouter(); const sp = useSearchParams(); // initial target (locked in edit) const initialTarget: Target = isEdit ? props.initialTarget : ((sp.get("target") as Target) || props.initialTarget || "settings_fiber"); const [target, setTarget] = useState(initialTarget); // 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 only) 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?.display_name?.trim() || [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() || me?.username?.trim() || me?.email?.trim() || (me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "Unknown user"); // Options const mats = useOptions("material"); const coats = useOptions("material_coating"); const colors = useOptions("material_color"); const opacs = useOptions("material_opacity"); const soft = useOptions("laser_software"); // required for ALL targets // these two need ?target= const srcs = useOptions(`laser_source?target=${typeForOptions}`); const lens = useOptions(`lens?target=${typeForOptions}`); // 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) => {} }; // Default values const createDefaults: FormValues = { setting_title: "", setting_notes: "", photo: null, screen: null, mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: null, source: "", lens: "", focus: null, laser_soft: "", repeat_all: null, fill_settings: [], line_settings: [], raster_settings: [], }; const { register, handleSubmit, control, reset, formState: { isSubmitting }, } = useForm({ defaultValues: isEdit ? (props as EditProps).initialValues : (props.initialValues as any) || createDefaults, }); // If edit props change (rare), sync the form useEffect(() => { if (isEdit) reset((props as EditProps).initialValues); }, [isEdit, reset, props]); const fills = useFieldArray({ control, name: "fill_settings" }); const lines = useFieldArray({ control, name: "line_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" }); function num(v: any) { return v === "" || v == null ? null : Number(v); } const bool = (v: any) => !!v; const detailHref = (coll: Target, submissionId: string | number) => { switch (coll) { case "settings_co2gal": return `/settings/co2-galvo/${submissionId}?edit=1`; case "settings_co2gan": return `/settings/co2-gantry/${submissionId}?edit=1`; case "settings_fiber": return `/settings/fiber/${submissionId}?edit=1`; case "settings_uv": return `/settings/uv/${submissionId}?edit=1`; } }; async function onSubmit(values: FormValues) { setSubmitErr(null); // Build normalized payload/patch from form values const payload: any = { target, 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 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: r.cut || "", skip: 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), })), }; try { let res: Response; if (isEdit) { // EDIT FLOW: call /api/my-settings/update const { initialTarget, submissionId } = props as EditProps; // When files are picked, send multipart so backend can upload & patch. if (photoFile || screenFile) { const form = new FormData(); form.set( "payload", JSON.stringify({ collection: initialTarget, submission_id: submissionId, patch: payload, // server decides which keys to persist }) ); if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); res = await fetch("/api/my-settings/update", { method: "POST", credentials: "include", body: form, }); } else { // JSON only (no file changes) res = await fetch("/api/my-settings/update", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ collection: initialTarget, submission_id: submissionId, patch: payload, }), }); } const txt = await res.text(); const data = txt ? JSON.parse(txt) : null; if (!res.ok || !data?.ok) { const msg = data?.error || data?.message || `HTTP ${res.status}`; throw new Error(msg); } const savedId = data?.id ?? (props as EditProps).submissionId; props.onSaved?.(savedId); router.push(detailHref(initialTarget, savedId)); return; } // CREATE FLOW: photo is required if (!photoFile) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); throw new Error("Please select a Result Photo."); } if (photoFile || screenFile) { const form = new FormData(); form.set("payload", JSON.stringify(payload)); if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); } else { res = await fetch("/api/submit/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), 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?.error || "Submission failed"); } reset(createDefaults); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); const id = data?.id ? String(data.id) : ""; props.onSaved?.(id); 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); } return (
{/* Target + Software (Software required for ALL targets) */}
{isEdit &&

Target is fixed while editing.

}
{/* Submitting-as banner */} {me ? (
{isEdit ? "Editing as " : "Submitting as "} {meLabel}.
) : meErr ? (
You’re not signed in. {isEdit ? "Saving changes" : "Submissions"} will fail until you sign in.
) : null} {submitErr ? (
{submitErr}
) : null}
{/* Title */}
{/* Images */}
{isEdit && typeof (props as EditProps).initialValues?.photo === "string" && (

Current: {shortId((props as EditProps).initialValues.photo)}

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

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

{photoPreview ? Result preview : null}
{isEdit && typeof (props as EditProps).initialValues?.screen === "string" && (

Current: {shortId((props as EditProps).initialValues.screen)}

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

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

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