diff --git a/components/forms/SettingsSubmit.tsx b/components/forms/SettingsSubmit.tsx index d611d547..f80898dc 100644 --- a/components/forms/SettingsSubmit.tsx +++ b/components/forms/SettingsSubmit.tsx @@ -5,401 +5,20 @@ 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"; +/** 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; - 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; - - // 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 +// UI bits // ───────────────────────────────────────────────────────────── function FilterableSelect({ label, @@ -421,9 +40,7 @@ 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; @@ -445,9 +62,7 @@ function FilterableSelect({ @@ -483,64 +98,154 @@ function LabeledInput({ }) { return (
- - + +
); } // ───────────────────────────────────────────────────────────── -// Component +// Helpers // ───────────────────────────────────────────────────────────── -export default function SettingsSubmit(props: CreateProps | EditProps) { +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 isEdit = isEditProps(props); - const edit = isEdit ? props : null; + const target: Target = "settings_co2gal"; // locked - 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 + // files const [photoFile, setPhotoFile] = useState(null); const [screenFile, setScreenFile] = useState(null); const [photoPreview, setPhotoPreview] = useState(""); const [screenPreview, setScreenPreview] = useState(""); - // UX error for auth/submit + // errors / me const [submitErr, setSubmitErr] = useState(null); - - // Current signed-in user (banner + uploader) const [me, setMe] = useState(null); const [meErr, setMeErr] = useState(null); @@ -548,38 +253,29 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { let alive = true; 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); }) + .then((j) => { if (alive) setMe(j || null); }) .catch(() => { if (alive) setMeErr("not-signed-in"); }); return () => { alive = false; }; }, []); - const meLabel = me?.username ?? ""; + const isEdit = mode === "edit" && submissionId != null; + const current = useMemo(() => (isEdit && initialValues ? normalizeForReset(initialValues) : null), [isEdit, initialValues]); - // For edit-mode, compute normalized current values once - const current = useMemo( - () => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null), - [isEdit, edit?.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); - // 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) => {} }; + // 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, @@ -592,23 +288,14 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { defaultValues: { setting_title: "", setting_notes: "", - 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: [], + // 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: [], }, }); @@ -616,184 +303,120 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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.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]); - - // After reset, force RHF values once (covers early case) + // prefill for edit useEffect(() => { if (!isEdit || !current) return; - const fieldNames = [ - "laser_soft", "mat", "mat_coat", "mat_color", "mat_opacity", "source", "lens", - "lens_conf", "lens_apt", "lens_exp", - ] 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 }); - } + 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, getValues, setValue]); + }, [isEdit, current, reset]); - // When options hydrate/change, re-apply the current ids so the select shows them + // keep selects stable when options hydrate 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]); + 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]); - function num(v: any) { return v === "" || v == null ? null : Number(v); } + // numeric / bool coercers + const num = (v: any) => (v === "" || v == null ? null : Number(v)); const bool = (v: any) => !!v; - // ───────────────────────────────────────────────────────────── - // SUBMIT: Match prod envelope → ALWAYS multipart with "payload" - // ───────────────────────────────────────────────────────────── + // submit 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) { + // 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; } - const fullPayload: any = { + // 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, - 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), - // uploader: include if we have it; server will also set from owner + // server auto-sets uploader from owner, but include a mirror if available ...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}), - // CO2 dropdown ids (relations) - lens_conf: values.lens_conf || null, - lens_apt: values.lens_apt || null, - lens_exp: values.lens_exp || null, - + // 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), + 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), })), }; - const directusData = toDirectusData(target, fullPayload); - if (!directusData.setting_title) { - setSubmitErr("Title is required."); - return; + // edit meta + if (isEdit && submissionId != null) { + payload.mode = "edit"; + payload.submission_id = submissionId; } - 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(flatPayload)); - if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); + 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 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."); @@ -802,16 +425,13 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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(edit?.submissionId ?? ""); + const id = (data as any)?.id ? String((data as any).id) : String(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)}`); @@ -829,46 +449,16 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { reader.readAsDataURL(file); } - 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; + 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 (
- {/* Target + Software */} -
-
- - -
-
- -
-
- - {/* Submitting-as banner */} + {/* Banner */} {me ? (
- Submitting as {meLabel}. + Submitting as {me.username || me.email || `User ${shortId(me.id)}`}.
) : meErr ? (
@@ -876,64 +466,30 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
) : 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}
@@ -944,169 +500,38 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {