diff --git a/components/details/CO2GalvoDetail.tsx b/components/details/CO2GalvoDetail.tsx index 07b2f2e6..fba71549 100644 --- a/components/details/CO2GalvoDetail.tsx +++ b/components/details/CO2GalvoDetail.tsx @@ -27,11 +27,6 @@ type Rec = { laser_soft?: { id?: string | number; name?: string | null } | string | number | null; repeat_all?: number | null; - // NEW lens config fields that may be required by schema - lens_conf?: number | null; - lens_apt?: number | null; - lens_exp?: number | null; - fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; @@ -163,11 +158,6 @@ export default function CO2GalvoDetail({ "laser_soft.name", "repeat_all", - // NEW lens config fields - "lens_conf", - "lens_apt", - "lens_exp", - "fill_settings", "line_settings", "raster_settings", @@ -216,6 +206,7 @@ export default function CO2GalvoDetail({ const sourceId = rec.source && typeof rec.source === "object" ? rec.source.submission_id ?? null : (rec.source as any) ?? null; return { + submission_id: rec.submission_id, // ★ include for type parity setting_title: rec.setting_title ?? "", setting_notes: rec.setting_notes ?? "", @@ -235,11 +226,6 @@ export default function CO2GalvoDetail({ laser_soft: (typeof rec.laser_soft === "object" ? rec.laser_soft?.id : (rec.laser_soft as any)) ?? null, repeat_all: rec.repeat_all ?? null, - // pass through for edit prefill - lens_conf: rec.lens_conf ?? null, - lens_apt: rec.lens_apt ?? null, - lens_exp: rec.lens_exp ?? null, - fill_settings: rec.fill_settings ?? [], line_settings: rec.line_settings ?? [], raster_settings: rec.raster_settings ?? [], diff --git a/components/forms/SettingsSubmit.tsx b/components/forms/SettingsSubmit.tsx index f80898dc..728a9c2b 100644 --- a/components/forms/SettingsSubmit.tsx +++ b/components/forms/SettingsSubmit.tsx @@ -5,20 +5,403 @@ 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 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(/\/$/, ""); // ───────────────────────────────────────────────────────────── -// UI bits +// 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 = { + /** optional, carried separately via props.submissionId */ + submission_id?: string | number; + 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; + + // CO2 extras (stored as relations -> ids) + lens_conf?: any; + lens_apt?: any; + lens_exp?: any; + + 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), + lens_conf: idToString(iv.lens_conf), + lens_apt: idToString(iv.lens_apt), + lens_exp: idToString(iv.lens_exp), + + // 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", + "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 ?? r.submission_id ?? r.value), + 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") { + 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 { + 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) }; + }); + }; + } + } + // NEW: fixed lists for config / aperture / expander + else if (rawPath === "laser_scan_lens_config") { + url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "laser_scan_lens_apt") { + url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "laser_scan_lens_exp") { + url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; + } else { + 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]; + } + + 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, @@ -40,7 +423,9 @@ function FilterableSelect({ required?: boolean; }) { const [filter, setFilter] = useState(""); - useEffect(() => { onQuery?.(filter); }, [filter, onQuery]); + useEffect(() => { + onQuery?.(filter); + }, [filter, onQuery]); const filtered = useMemo(() => { if (!filter) return options; @@ -62,7 +447,9 @@ function FilterableSelect({ @@ -98,154 +485,64 @@ function LabeledInput({ }) { return (
- - + +
); } // ───────────────────────────────────────────────────────────── -// Helpers +// Component // ───────────────────────────────────────────────────────────── -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; -}) { +export default function SettingsSubmit(props: CreateProps | EditProps) { const router = useRouter(); const sp = useSearchParams(); - const target: Target = "settings_co2gal"; // locked + const isEdit = isEditProps(props); + const edit = isEdit ? props : null; - // files + 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]); + + 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(""); - // errors / me + // 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); @@ -253,29 +550,38 @@ export default function SettingsSubmit({ 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); }) + .then((j) => { if (!alive) return; 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]); + const meLabel = me?.username ?? ""; - // 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); + // For edit-mode, compute normalized current values once + const current = useMemo( + () => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null), + [isEdit, edit?.initialValues] + ); - // 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" + // 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); + + // NEW: fixed-value dropdowns for galvo configs + const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf || undefined); + const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt || undefined); + const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp || 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) => {} }; - // form const { register, handleSubmit, @@ -288,14 +594,23 @@ export default function SettingsSubmit({ 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: [], + mat: "", + mat_coat: "", + mat_color: "", + mat_opacity: "", + mat_thickness: "", + source: "", + lens: "", + focus: "", + laser_soft: "", + repeat_all: "", + // dropdown ids + lens_conf: "", + lens_apt: "", + lens_exp: "", + fill_settings: [], + line_settings: [], + raster_settings: [], }, }); @@ -303,120 +618,184 @@ export default function SettingsSubmit({ const lines = useFieldArray({ control, name: "line_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" }); - // prefill for edit + // Prefill the form in edit mode 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]); + 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.lens_conf ?? "", + lens_apt: iv.lens_apt ?? "", + lens_exp: iv.lens_exp ?? "", + fill_settings: iv.fill_settings ?? [], + line_settings: iv.line_settings ?? [], + raster_settings: iv.raster_settings ?? [], + }); + } + }, [isEdit, edit?.initialValues, reset]); - // keep selects stable when options hydrate + // After reset, force RHF values once (covers early case) 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 fieldNames = [ + "laser_soft", "mat", "mat_coat", "mat_color", "mat_opacity", "source", "lens", + "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 }); + 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, mats.opts, coats.opts, colors.opts, opacs.opts, srcs.opts, lens.opts, soft.opts, lensConf.opts, lensApt.opts, lensExp.opts]); + }, [isEdit, current, getValues, setValue]); - // numeric / bool coercers - const num = (v: any) => (v === "" || v == null ? null : Number(v)); + // 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 }); + }; + ["mat","mat_coat","mat_color","mat_opacity","laser_soft","source","lens","lens_conf","lens_apt","lens_exp"] + .forEach((n) => apply(n as any)); + }, [isEdit, current, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, lensConf.opts, lensApt.opts, lensExp.opts]); + + function num(v: any) { return v === "" || v == null ? null : Number(v); } const bool = (v: any) => !!v; - // submit + // ───────────────────────────────────────────────────────────── + // SUBMIT: Match prod envelope → ALWAYS multipart with "payload" + // ───────────────────────────────────────────────────────────── 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) { + // 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; } - // build payload with only required + notes/images + repeaters - const payload: any = { + const fullPayload: 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, + mat_thickness: num(values.mat_thickness), + source: values.source || null, + lens: values.lens || null, + focus: num(values.focus), 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 + // uploader: include if we have it; server will also set from owner ...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}), - // repeaters (optional – pass-through) + // CO2 dropdown ids (relations) + lens_conf: values.lens_conf || null, + lens_apt: values.lens_apt || null, + lens_exp: values.lens_exp || null, + 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), + 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), + 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), + 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; + const directusData = toDirectusData(target, fullPayload); + if (!directusData.setting_title) { + setSubmitErr("Title is required."); + return; } + const base = isEdit && edit?.submissionId + ? { target, mode: "edit" as const, submission_id: edit.submissionId } + : { target }; + + const flatPayload = { + ...base, + ...directusData, + setting_title: directusData.setting_title, + }; + try { const form = new FormData(); - form.set("payload", JSON.stringify(payload)); // server route reads "payload" - - if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); + form.set("payload", JSON.stringify(flatPayload)); + 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 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."); @@ -425,13 +804,16 @@ export default function SettingsSubmit({ if (!isEdit) { reset(); - setPhotoFile(null); setScreenFile(null); - setPhotoPreview(""); setScreenPreview(""); + setPhotoFile(null); + setScreenFile(null); + setPhotoPreview(""); + setScreenPreview(""); } - const id = (data as any)?.id ? String((data as any).id) : String(submissionId ?? ""); + 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"); + 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)}`); @@ -449,16 +831,46 @@ export default function SettingsSubmit({ 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; + 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 */} +
+
+ + +
- {/* Banner */} +
+ +
+
+ + {/* Submitting-as banner */} {me ? (
- Submitting as {me.username || me.email || `User ${shortId(me.id)}`}. + Submitting as {meLabel}.
) : meErr ? (
@@ -466,30 +878,64 @@ export default function SettingsSubmit({
) : null} - {submitErr ?
{submitErr}
: 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."}

+ + + {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."}

+ + {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}
@@ -500,38 +946,169 @@ export default function SettingsSubmit({