"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)}`; } 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 until/if a numeric mirror field exists 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 }; } 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 ( ); } export default function SettingsSubmit({ initialTarget }: { initialTarget?: Target }) { const router = useRouter(); const sp = useSearchParams(); const initialFromQuery = (sp.get("target") as Target) || initialTarget || "settings_fiber"; const [target, setTarget] = useState(initialFromQuery); // 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) => {} }; const { register, handleSubmit, control, reset, 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 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" }); function num(v: any) { return v === "" || v == null ? null : Number(v); } const bool = (v: any) => !!v; async function onSubmit(values: any) { setSubmitErr(null); if (!photoFile) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } 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 || "", // now driven by local enum 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 || "", // now driven by local enum dither: r.dither || "", // now driven by local enum 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 (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(); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); const id = data?.id ? String(data.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) */}
{/* 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 */}
onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} />

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

{photoPreview ? Result preview : null}
onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} />

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

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