diff --git a/components/details/CO2GalvoDetail.tsx b/components/details/CO2GalvoDetail.tsx index 8b0ad178..7b5ea390 100644 --- a/components/details/CO2GalvoDetail.tsx +++ b/components/details/CO2GalvoDetail.tsx @@ -10,9 +10,10 @@ type Rec = { setting_title?: string | null; setting_notes?: string | null; - photo?: { id?: string } | string | number | null; - screen?: { id?: string } | string | number | null; + photo?: { id?: string } | string | null; + screen?: { id?: string } | string | null; + // ids & readable fields mat?: { id?: string | number; name?: string | null } | null; mat_coat?: { id?: string | number; name?: string | null } | null; mat_color?: { id?: string | number; name?: string | null } | null; @@ -23,7 +24,7 @@ type Rec = { lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null; focus?: number | null; - laser_soft?: any; + laser_soft?: { id?: string | number; name?: string | null } | string | number | null; repeat_all?: number | null; fill_settings?: any[] | null; @@ -32,6 +33,7 @@ type Rec = { owner?: { id?: string | number; username?: string | null } | string | number | null; uploader?: string | null; + last_modified_date?: string | null; }; @@ -40,22 +42,35 @@ async function readJson(res: Response) { try { return text ? JSON.parse(text) : null; } catch { throw new Error(`Unexpected response (HTTP ${res.status})`); } } -const ownerLabel = (o: Rec["owner"]) => -!o ? "—" : (typeof o === "string" || typeof o === "number") ? String(o) : (o.username || String(o.id ?? "—")); +function ownerLabel(o: Rec["owner"]) { + if (!o) return "—"; + if (typeof o === "string" || typeof o === "number") return String(o); + return o.username || String(o.id ?? "—"); +} -const isMine = (owner: Rec["owner"], meId: string | null) => -!!meId && !!owner && ((typeof owner === "string" || typeof owner === "number") ? String(owner) === meId : (owner.id != null && String(owner.id) === meId)); - -const resolveFileId = (v: Rec["photo"]): string | null => -v == null ? null : (typeof v === "string" || typeof v === "number") ? String(v) : v.id ? String(v.id) : null; - -// ✅ Use public Directus assets (works on your stack). Fallback to proxy if no base configured. +// Prefer public assets if available (avoids auth cookie issues in ) const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); function fileUrl(id?: string) { if (!id) return ""; return API_BASE ? `${API_BASE}/assets/${id}` : `/api/dx/assets/${id}`; } +function ZoomableSquareImage(props: { src: string; alt: string; onOpen: () => void }) { + const { src, alt, onOpen } = props; + return ( +
+ {alt} { (e.currentTarget as HTMLImageElement).style.display = "none"; }} + /> +
+ ); +} + export default function CO2GalvoDetail({ id, mode, @@ -78,10 +93,8 @@ export default function CO2GalvoDetail({ const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); + // Current user id to gate the Edit button const [meId, setMeId] = useState(null); - const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); - - // me id (for owner-gated Edit button) useEffect(() => { let dead = false; (async () => { @@ -89,14 +102,22 @@ export default function CO2GalvoDetail({ const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" }); if (!r.ok) return; const j = await readJson(r); - const mid = j?.data?.id ?? j?.id ?? null; - if (!dead) setMeId(mid ? String(mid) : null); + const id = j?.data?.id ?? j?.id ?? null; + if (!dead) setMeId(id ? String(id) : null); } catch { /* ignore */ } })(); return () => { dead = true; }; }, []); - // load record (readable fields) + // lightbox + const [viewerSrc, setViewerSrc] = useState(null); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setViewerSrc(null); }; + if (viewerSrc) window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [viewerSrc]); + + // load record (with human-readable fields, +laser_soft.name) useEffect(() => { if (!id) return; let dead = false; @@ -107,19 +128,49 @@ export default function CO2GalvoDetail({ setErr(null); const fields = [ - "submission_id","setting_title","setting_notes", - "photo.id","screen.id", - "mat.id","mat.name","mat_coat.id","mat_coat.name","mat_color.id","mat_color.name","mat_opacity.id","mat_opacity.opacity","mat_thickness", - "source.submission_id","source.make","source.model","source.nm", - "lens.id","lens.field_size","lens.focal_length", - "focus","laser_soft","repeat_all", - "fill_settings","line_settings","raster_settings", - "owner.id","owner.username","uploader","last_modified_date", + "submission_id", + "setting_title", + "setting_notes", + "photo.id", + "screen.id", + + "mat.id", + "mat.name", + "mat_coat.id", + "mat_coat.name", + "mat_color.id", + "mat_color.name", + "mat_opacity.id", + "mat_opacity.opacity", + "mat_thickness", + + "source.submission_id", + "source.make", + "source.model", + "source.nm", + + "lens.id", + "lens.field_size", + "lens.focal_length", + + "focus", + "laser_soft.id", + "laser_soft.name", + "repeat_all", + + "fill_settings", + "line_settings", + "raster_settings", + + "owner.id", + "owner.username", + "uploader", + "last_modified_date", ].join(","); - const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent( - fields - )}&filter[submission_id][_eq]=${encodeURIComponent(String(id))}&limit=1`; + const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent( + String(id) + )}&limit=1`; const r = await fetch(url, { cache: "no-store", credentials: "include" }); if (!r.ok) { @@ -140,13 +191,12 @@ export default function CO2GalvoDetail({ return () => { dead = true; }; }, [id]); - // initial values for edit form const initialValues = useMemo(() => { if (!rec) return null; - const toId = (v: any) => (v == null ? null : typeof v === "object" ? v.id ?? v.submission_id ?? null : v); + const toId = (v: any) => (v == null ? null : (typeof v === "object" ? (v.id ?? v.submission_id ?? null) : v)); - const photoId = resolveFileId(rec.photo); - const screenId = resolveFileId(rec.screen); + const photoId = typeof rec.photo === "string" || typeof rec.photo === "number" ? String(rec.photo) : rec.photo?.id ?? null; + const screenId = typeof rec.screen === "string" || typeof rec.screen === "number" ? String(rec.screen) : rec.screen?.id ?? null; const matId = toId(rec.mat); const coatId = toId(rec.mat_coat); @@ -158,35 +208,29 @@ export default function CO2GalvoDetail({ return { setting_title: rec.setting_title ?? "", setting_notes: rec.setting_notes ?? "", - photo: photoId, + + photo: photoId, screen: screenId, + mat: matId ? String(matId) : null, mat_coat: coatId ? String(coatId) : null, mat_color: colorId ? String(colorId) : null, mat_opacity: opacityId ? String(opacityId) : null, mat_thickness: rec.mat_thickness ?? null, + source: sourceId != null ? String(sourceId) : null, lens: lensId != null ? String(lensId) : null, focus: rec.focus ?? null, - laser_soft: rec.laser_soft ?? null, + + laser_soft: (typeof rec.laser_soft === "object" ? rec.laser_soft?.id : (rec.laser_soft as any)) ?? null, repeat_all: rec.repeat_all ?? null, - fill_settings: rec.fill_settings ?? [], - line_settings: rec.line_settings ?? [], + + fill_settings: rec.fill_settings ?? [], + line_settings: rec.line_settings ?? [], raster_settings: rec.raster_settings ?? [], }; }, [rec]); - // derive image URLs directly (no proxy/blob) - const photoUrl = useMemo(() => { - const pid = resolveFileId(rec?.photo ?? null); - return pid ? fileUrl(pid) : null; - }, [rec?.photo]); - - const screenUrl = useMemo(() => { - const sid = resolveFileId(rec?.screen ?? null); - return sid ? fileUrl(sid) : null; - }, [rec?.screen]); - function clearEditParam() { const params = new URLSearchParams(sp.toString()); params.delete("edit"); @@ -201,15 +245,13 @@ export default function CO2GalvoDetail({ ); if (!rec) return

Setting not found.

; - // EDIT mode + // ── EDIT MODE ─────────────────────────────────────────────── if (editMode && initialValues) { return (

Edit CO₂ Galvo Setting

- +
@@ -233,7 +290,6 @@ export default function CO2GalvoDetail({
- {/* Meta */}
Owner: {ownerDisplay}
@@ -246,7 +302,7 @@ export default function CO2GalvoDetail({
Laser Source: {[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") || "—"}{rec.source?.nm ? ` (${rec.source.nm})` : ""}
Scan Lens: {rec.lens?.field_size || "—"}{rec.lens?.focal_length ? ` / ${rec.lens.focal_length} mm` : ""}
Focus (mm): {rec.focus ?? "—"}
-
Software: {rec.laser_soft ?? "—"}
+
Software: {softName}
Repeat All: {rec.repeat_all ?? "—"}
@@ -257,58 +313,24 @@ export default function CO2GalvoDetail({
) : null} - {canEdit && ( + {showOwnerEdit && isMine && (
- +
)} - {/* Images: 1:1 thumbs + click to enlarge; only render when we have a URL */}
- {photoUrl ? ( -
- -
Result
+ {photoSrc ? ( +
+ setViewerSrc(photoSrc)} /> +
Result
) : null} - - {screenUrl ? ( -
- -
Settings Screenshot
+ {screenSrc ? ( +
+ setViewerSrc(screenSrc)} /> +
Settings Screenshot
) : null}
@@ -420,19 +442,9 @@ export default function CO2GalvoDetail({
)} - {lightbox && ( -
setLightbox(null)} - role="dialog" - aria-modal="true" - > - {lightbox.alt} e.stopPropagation()} - /> + {viewerSrc && ( +
setViewerSrc(null)}> + e.stopPropagation()} />
)}
diff --git a/components/forms/SettingsSubmit.tsx b/components/forms/SettingsSubmit.tsx index 9eaba9eb..cff8a6a5 100644 --- a/components/forms/SettingsSubmit.tsx +++ b/components/forms/SettingsSubmit.tsx @@ -167,10 +167,12 @@ function useOptions(path: string) { })); }; } 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) => { @@ -187,6 +189,7 @@ function useOptions(path: string) { }; } } else { + // unknown path → empty setOpts([]); setLoading(false); return; @@ -198,6 +201,7 @@ function useOptions(path: string) { 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; @@ -281,6 +285,43 @@ function BoolBox({ label, name, register }: { label: string; name: string; regis ); } +function LabeledInput({ + label, + name, + type = "text", + step, + register, + required = false, + min, + max, +}: { + label: string; + name: string; + type?: "text" | "number"; + step?: string | number; + register: UseFormRegister; + required?: boolean; + min?: number; + max?: number; +}) { + return ( +
+ + +
+ ); +} + // ───────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────── @@ -289,11 +330,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { const sp = useSearchParams(); const isEdit = isEditProps(props); - const edit = isEdit ? props : null; + const edit = isEdit ? props : null; // strongly-typed local when edit const initialFromQuery = (sp.get("target") as Target) || props.initialTarget || "settings_fiber"; const [target, setTarget] = useState(initialFromQuery); + // Map collection -> slug used by options selectors const typeForOptions = useMemo(() => { switch (target) { case "settings_fiber": @@ -325,6 +367,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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) => { @@ -347,12 +390,13 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { const coats = useOptions("material_coating"); const colors = useOptions("material_color"); const opacs = useOptions("material_opacity"); - const soft = useOptions("laser_software"); + 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) + // 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) => {} }; @@ -376,7 +420,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { lens: "", focus: "", laser_soft: "", - repeat_all: "", + repeat_all: "", // on all targets fill_settings: [], line_settings: [], raster_settings: [], @@ -387,30 +431,29 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { const lines = useFieldArray({ control, name: "line_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" }); - // ✅ Prefill when editing + // Prefill the form in edit mode useEffect(() => { - if (!isEdit || !edit?.initialValues) return; - - const S = (v: any) => (v == null ? "" : String(v)); - - reset({ - setting_title: edit.initialValues.setting_title ?? "", - setting_notes: edit.initialValues.setting_notes ?? "", - mat: S(edit.initialValues.mat), - mat_coat: S(edit.initialValues.mat_coat), - mat_color: S(edit.initialValues.mat_color), - mat_opacity: S(edit.initialValues.mat_opacity), - mat_thickness: edit.initialValues.mat_thickness ?? "", - source: S(edit.initialValues.source), - lens: S(edit.initialValues.lens), - focus: edit.initialValues.focus ?? "", - laser_soft: S(edit.initialValues.laser_soft), - repeat_all: edit.initialValues.repeat_all ?? "", - fill_settings: edit.initialValues.fill_settings ?? [], - line_settings: edit.initialValues.line_settings ?? [], - raster_settings: edit.initialValues.raster_settings ?? [], - }); - // we keep previews empty; “Current: ” is shown below file inputs + if (isEdit && edit?.initialValues) { + reset({ + setting_title: edit.initialValues.setting_title ?? "", + setting_notes: edit.initialValues.setting_notes ?? "", + photo: edit.initialValues.photo ?? null, + screen: edit.initialValues.screen ?? null, + mat: edit.initialValues.mat ?? "", + mat_coat: edit.initialValues.mat_coat ?? "", + mat_color: edit.initialValues.mat_color ?? "", + mat_opacity: edit.initialValues.mat_opacity ?? "", + mat_thickness: edit.initialValues.mat_thickness ?? "", + source: edit.initialValues.source ?? "", + lens: edit.initialValues.lens ?? "", + focus: edit.initialValues.focus ?? "", + laser_soft: edit.initialValues.laser_soft ?? "", + repeat_all: edit.initialValues.repeat_all ?? "", + fill_settings: edit.initialValues.fill_settings ?? [], + line_settings: edit.initialValues.line_settings ?? [], + raster_settings: edit.initialValues.raster_settings ?? [], + }); + } }, [isEdit, edit?.initialValues, reset]); function num(v: any) { @@ -421,10 +464,10 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { async function onSubmit(values: any) { setSubmitErr(null); + // In edit mode, allow keeping the existing photo (no new file) if one exists. const hasExistingPhotoId = - !!(isEdit && typeof edit?.initialValues?.photo === "string" && edit?.initialValues?.photo); - - if (!photoFile && !hasExistingPhotoId) { + !!(isEdit && typeof edit!.initialValues?.photo === "string" && edit!.initialValues.photo); + if (!photoFile && !hasExistingPhotoId && !isEdit) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } @@ -441,8 +484,8 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { source: values.source || null, lens: values.lens || null, focus: num(values.focus), - laser_soft: values.laser_soft || null, - repeat_all: num(values.repeat_all), + 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), @@ -493,10 +536,10 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { })), }; - // ✅ Tell the API we're editing and which record to patch - if (isEdit) { + // Ensure EDIT gets routed as a PATCH via the API + if (isEdit && edit?.submissionId != null) { payload.mode = "edit"; - payload.submission_id = edit!.submissionId; + payload.submission_id = edit.submissionId; } try { @@ -520,11 +563,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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"); + throw new Error((data as any)?.error || "Submission failed"); } - // Reset only on create; for edit, keep values visible + // Success if (!isEdit) { + // reset only on create reset(); setPhotoFile(null); setScreenFile(null); @@ -532,11 +576,16 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { setScreenPreview(""); } - const id = data?.id ? String(data.id) : String(edit?.submissionId ?? ""); - const next = isEdit - ? `/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}` - : `/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`; - router.push(next); + const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? ""); + // back to success (create) or view (edit) + if (isEdit) { + // remove ?edit=1 + const q = new URLSearchParams(sp.toString()); + q.delete("edit"); + router.replace(`/portal/laser-settings?${q.toString()}`); + } else { + router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); + } } catch (e: any) { setSubmitErr(e?.message || "Submission failed"); } @@ -553,14 +602,15 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { reader.readAsDataURL(file); } + // Convenience strings for “Current:” (edit mode) const currentPhotoId = - isEdit && typeof edit?.initialValues?.photo === "string" ? edit!.initialValues!.photo : null; + isEdit && typeof edit.initialValues?.photo === "string" ? (edit.initialValues.photo as string) : null; const currentScreenId = - isEdit && typeof edit?.initialValues?.screen === "string" ? edit!.initialValues!.screen : null; + isEdit && typeof edit.initialValues?.screen === "string" ? (edit.initialValues.screen as string) : null; return (
- {/* Target + Software */} + {/* Target + Software (Software required for ALL targets) */}
@@ -619,7 +669,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
{currentPhotoId && ( @@ -632,14 +682,12 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { type="file" accept="image/*" data-role="photo" - required={!currentPhotoId} + required={!isEdit && !currentPhotoId} onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} />

{photoFile ? ( - <> - Selected: {photoFile.name} - + <>Selected: {photoFile.name} ) : ( "Max 25 MB. JPG/PNG/WebP recommended." )} @@ -663,9 +711,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { />

{screenFile ? ( - <> - Selected: {screenFile.name} - + <>Selected: {screenFile.name} ) : ( "Max 25 MB. JPG/PNG/WebP recommended." )} @@ -740,49 +786,23 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { {/* Focus, thickness, repeat_all */}

-
- - -
-
- - -

- 0 = in focus. Negative = focus closer. Positive = focus further. -

-
-
- - -
+ + + +

0 = in focus. Negative = focus closer. Positive = focus further.

{/* FILL */}
Fill Settings -
{fills.fields.map((f, i) => (
- + {}} placeholder="Select type" /> - - - - - - - - + + + + + + + +
@@ -826,17 +846,17 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
{lines.fields.map((f, i) => (
- - - - - - - - - - - + + + + + + + + + + + @@ -861,9 +881,9 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
{rasters.fields.map((f, i) => (
- - - + + + {}} placeholder="Select dither" /> - - - - - - - + + + + + + +