From 099f21b1307622e403bec55170955ec44ad0e029 Mon Sep 17 00:00:00 2001 From: makearmy Date: Fri, 3 Oct 2025 13:57:24 -0400 Subject: [PATCH] settings pages componenet restructure --- app/settings/co2-galvo/[id]/co2-galvo.tsx | 229 --------------- app/settings/co2-galvo/[id]/page.tsx | 13 +- app/settings/co2-galvo/page.tsx | 331 +--------------------- components/details/CO2GalvoDetail.tsx | 256 +++++++++++++++++ components/lists/CO2GalvoList.tsx | 286 +++++++++++++++++++ components/portal/SettingsSwitcher.tsx | 2 +- components/portal/panels/CO2GalvoPanel | 56 ++++ 7 files changed, 617 insertions(+), 556 deletions(-) delete mode 100644 app/settings/co2-galvo/[id]/co2-galvo.tsx create mode 100644 components/details/CO2GalvoDetail.tsx create mode 100644 components/lists/CO2GalvoList.tsx create mode 100644 components/portal/panels/CO2GalvoPanel diff --git a/app/settings/co2-galvo/[id]/co2-galvo.tsx b/app/settings/co2-galvo/[id]/co2-galvo.tsx deleted file mode 100644 index e0c83d0a..00000000 --- a/app/settings/co2-galvo/[id]/co2-galvo.tsx +++ /dev/null @@ -1,229 +0,0 @@ -// app/settings/co2-galvo/[id]/co2-galvo.tsx -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { useParams, useSearchParams } from "next/navigation"; -import Link from "next/link"; -import SettingsSubmit from "@/components/forms/SettingsSubmit"; - -type Rec = { - submission_id: string | number; - setting_title?: string | null; - setting_notes?: string | null; - - // files can be id or object with id - photo?: { id?: string } | string | null; - screen?: { id?: string } | string | null; - - // relations (may be id or object) - mat?: { id?: string | number } | string | number | null; - mat_coat?: { id?: string | number } | string | number | null; - mat_color?: { id?: string | number } | string | number | null; - mat_opacity?: { id?: string | number } | string | number | null; - mat_thickness?: number | null; - - source?: { submission_id?: string | number } | string | number | null; - lens?: { id?: string | number } | string | number | null; - focus?: number | null; - - laser_soft?: any; - repeat_all?: number | null; - - fill_settings?: any[] | null; - line_settings?: any[] | null; - raster_settings?: any[] | null; - - owner?: { id?: string | number; username?: string | null } | string | number | null; - uploader?: string | null; - - last_modified_date?: string | null; -}; - -function ownerLabel(o: Rec["owner"]) { - if (!o) return "—"; - if (typeof o === "string" || typeof o === "number") return String(o); - return o.username || String(o.id ?? "—"); -} - -async function readJson(res: Response) { - const text = await res.text(); - try { return text ? JSON.parse(text) : null; } catch { throw new Error(`Unexpected response (HTTP ${res.status})`); } -} - -export default function CO2GalvoSettingDetailPage() { - const { id } = useParams<{ id: string }>(); - const sp = useSearchParams(); - const editMode = sp.get("edit") === "1"; - - const [rec, setRec] = useState(null); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(null); - - // Load record by submission_id (that's what the list links use) - useEffect(() => { - if (!id) return; - let dead = false; - - (async () => { - try { - setLoading(true); - setErr(null); - - // Request the specific IDs needed to prefill selects - const fields = [ - "submission_id", - "setting_title", - "setting_notes", - "photo.id", - "screen.id", - - // relations: request their ids explicitly - "mat.id", - "mat_coat.id", - "mat_color.id", - "mat_opacity.id", - "mat_thickness", - - // source is keyed by submission_id in the selector - "source.submission_id", - - // lens select expects numeric id - "lens.id", - "focus", - - "laser_soft", - "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 r = await fetch(url, { cache: "no-store", credentials: "include" }); - if (!r.ok) { - const j = await readJson(r).catch(() => null); - throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`); - } - const j = await readJson(r); - const row: Rec | null = Array.isArray(j?.data) ? j.data[0] || null : null; - if (!row) throw new Error("Setting not found."); - if (!dead) setRec(row); - } catch (e: any) { - if (!dead) setErr(e?.message || String(e)); - } finally { - if (!dead) setLoading(false); - } - })(); - - return () => { dead = true; }; - }, [id]); - - const initialValues = useMemo(() => { - if (!rec) return null; - - // normalize existing file refs to ids for the form - 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; - - // normalize relations to id strings expected by setQuery(e.currentTarget.value)} - placeholder="Search by title, owner, material, model…" - className="w-full max-w-lg border rounded px-3 py-2" +
+ + opts?.edit ? `/settings/co2-galvo/${sid}?edit=1` : `/settings/co2-galvo/${sid}` + } />
- - {loading ? ( -

Loading…

- ) : ( -
- - - - - - - - - - - - - - {filtered.map((s) => { - const mine = isMine(s.owner); - const ownerText = - ownerLabel(s.owner) + (mine ? " (you)" : ""); - return ( - - - - - - - - - ); - })} - -
Title - Owner {resolvingOwners ? "…resolving" : ""} - MaterialCoatingModelFieldActions
- - - - - - {s.mat?.name || "—"} - - {s.mat_coat?.name || "—"} - - {s.source?.model || "—"} - - {s.lens?.field_size || "—"} - - {mine ? ( - - Edit - - ) : ( - - )} -
-
- )} - ); } diff --git a/components/details/CO2GalvoDetail.tsx b/components/details/CO2GalvoDetail.tsx new file mode 100644 index 00000000..ce244aeb --- /dev/null +++ b/components/details/CO2GalvoDetail.tsx @@ -0,0 +1,256 @@ +// components/details/CO2GalvoDetail.tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import SettingsSubmit from "@/components/forms/SettingsSubmit"; + +type Rec = { + submission_id: string | number; + setting_title?: string | null; + setting_notes?: string | null; + + photo?: { id?: string } | string | null; + screen?: { id?: string } | string | null; + + mat?: { id?: string | number } | string | number | null; + mat_coat?: { id?: string | number } | string | number | null; + mat_color?: { id?: string | number } | string | number | null; + mat_opacity?: { id?: string | number } | string | number | null; + mat_thickness?: number | null; + + source?: { submission_id?: string | number } | string | number | null; + lens?: { id?: string | number } | string | number | null; + focus?: number | null; + + laser_soft?: any; + repeat_all?: number | null; + + fill_settings?: any[] | null; + line_settings?: any[] | null; + raster_settings?: any[] | null; + + owner?: { id?: string | number; username?: string | null } | string | number | null; + uploader?: string | null; + + last_modified_date?: string | null; +}; + +async function readJson(res: Response) { + const text = await res.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + throw new Error(`Unexpected response (HTTP ${res.status})`); + } +} + +function ownerLabel(o: Rec["owner"]) { + if (!o) return "—"; + if (typeof o === "string" || typeof o === "number") return String(o); + return o.username || String(o.id ?? "—"); +} + +export default function CO2GalvoDetail({ + id, + mode, + onSaved, + onBack, + showOwnerEdit = true, +}: { + id: string | number; + mode?: "view" | "edit"; + onSaved?: (submission_id: string | number) => void; + onBack?: () => void; + showOwnerEdit?: boolean; +}) { + const sp = useSearchParams(); + const router = useRouter(); + const editParam = sp.get("edit") === "1"; + const editMode = mode ? mode === "edit" : editParam; + + const [rec, setRec] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + // load record (by submission_id) + useEffect(() => { + if (!id) return; + let dead = false; + + (async () => { + try { + setLoading(true); + setErr(null); + + const fields = [ + "submission_id", + "setting_title", + "setting_notes", + "photo.id", + "screen.id", + "mat.id", + "mat_coat.id", + "mat_color.id", + "mat_opacity.id", + "mat_thickness", + "source.submission_id", + "lens.id", + "focus", + "laser_soft", + "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 r = await fetch(url, { cache: "no-store", credentials: "include" }); + if (!r.ok) { + const j = await readJson(r).catch(() => null); + throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`); + } + const j = await readJson(r); + const row: Rec | null = Array.isArray(j?.data) ? j.data[0] || null : null; + if (!row) throw new Error("Setting not found."); + if (!dead) setRec(row); + } catch (e: any) { + if (!dead) setErr(e?.message || String(e)); + } finally { + if (!dead) setLoading(false); + } + })(); + + return () => { + dead = true; + }; + }, [id]); + + 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 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); + const colorId = toId(rec.mat_color); + const opacityId = toId(rec.mat_opacity); + const lensId = toId(rec.lens); + const sourceId = + rec.source && typeof rec.source === "object" ? rec.source.submission_id ?? null : (rec.source as any) ?? null; + + return { + setting_title: rec.setting_title ?? "", + setting_notes: rec.setting_notes ?? "", + 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, + repeat_all: rec.repeat_all ?? null, + fill_settings: rec.fill_settings ?? [], + line_settings: rec.line_settings ?? [], + raster_settings: rec.raster_settings ?? [], + }; + }, [rec]); + + function clearEditParam() { + const params = new URLSearchParams(sp.toString()); + params.delete("edit"); + router.replace(`?${params.toString()}`); + } + + if (loading) return

Loading setting…

; + if (err) + return ( +
+
{err}
+
+ ); + if (!rec) return

Setting not found.

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

Edit CO₂ Galvo Setting

+ +
+ + + +
+ + ← Back to view + +
+
+ ); + } + + // VIEW MODE + const ownerDisplay = ownerLabel(rec.owner); + return ( +
+
+

{rec.setting_title || "Untitled"}

+ + {/* debug block left intact for now */} +
+        {JSON.stringify({ owner: rec?.owner }, null, 2)}
+        
+ +
+

+ Owner: {ownerDisplay} +

+

+ Uploader: {rec.uploader || "—"} +

+
+
+ + {showOwnerEdit && ( +
+ + Edit this setting + +
+ )} +
+ ); +} diff --git a/components/lists/CO2GalvoList.tsx b/components/lists/CO2GalvoList.tsx new file mode 100644 index 00000000..7710685a --- /dev/null +++ b/components/lists/CO2GalvoList.tsx @@ -0,0 +1,286 @@ +// components/lists/CO2GalvoList.tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; + +type Owner = +| string +| number +| { + id?: string | number; + username?: string | null; + first_name?: string | null; + last_name?: string | null; + email?: string | null; +} +| null +| undefined; + +type SettingsRow = { + submission_id: string | number; + setting_title?: string | null; + uploader?: string | null; + owner?: Owner; + mat?: { name?: string | null } | null; + mat_coat?: { name?: string | null } | null; + source?: { model?: string | null } | null; + lens?: { field_size?: string | number | null } | null; +}; + +export type CO2GalvoListProps = { + /** Build the href for a record (portal vs standalone) */ + linkFor: (submission_id: string | number, opts?: { edit?: boolean }) => string; + /** Optional controlled search text (else internal input) */ + queryText?: string; + onQueryChange?: (q: string) => void; +}; + +async function readJson(res: Response) { + const text = await res.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + throw new Error(`Unexpected response (status ${res.status})`); + } +} + +export default function CO2GalvoList({ linkFor, queryText, onQueryChange }: CO2GalvoListProps) { + const [settings, setSettings] = useState([]); + const [ownerMap, setOwnerMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [resolvingOwners, setResolvingOwners] = useState(false); + const [meId, setMeId] = useState(null); + const [localQuery, setLocalQuery] = useState(queryText ?? ""); + + // keep local and external search text in sync + useEffect(() => { + if (queryText !== undefined) setLocalQuery(queryText); + }, [queryText]); + + const q = localQuery; + + // load current user id (for edit visibility) + useEffect(() => { + let dead = false; + (async () => { + try { + 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 id = j?.data?.id ?? j?.id ?? null; + if (!dead) setMeId(id ? String(id) : null); + } catch { + /* ignore */ + } + })(); + return () => { + dead = true; + }; + }, []); + + // load list + useEffect(() => { + const fields = [ + "submission_id", + "setting_title", + "uploader", + "owner", + "owner.id", + "owner.username", + "photo.id", + "photo.title", + "mat.name", + "mat_coat.name", + "source.model", + "lens.field_size", + ].join(","); + + const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`; + + fetch(url, { cache: "no-store", credentials: "include" }) + .then(async (res) => { + if (!res.ok) { + const j = await readJson(res).catch(() => null); + const msg = (j as any)?.errors?.[0]?.message || `HTTP ${res.status}`; + throw new Error(msg); + } + return readJson(res); + }) + .then((json: any) => setSettings(Array.isArray(json?.data) ? json.data : [])) + .catch((e) => { + console.error("CO2 Galvo list fetch failed:", e); + setSettings([]); + }) + .finally(() => setLoading(false)); + }, []); + + // resolve owner usernames if needed + useEffect(() => { + if (!settings.length) return; + + const ids = new Set(); + for (const s of settings) { + const o = s.owner; + if (!o) continue; + + if (typeof o === "string" || typeof o === "number") { + const k = String(o); + if (!ownerMap[k]) ids.add(k); + } else { + const id = o.id != null ? String(o.id) : ""; + const hasUsername = !!o.username; + if (id && !hasUsername && !ownerMap[id]) ids.add(id); + } + } + if (!ids.size) return; + + const all = Array.from(ids); + const chunk = 100; + setResolvingOwners(true); + + (async () => { + const updates: Record = {}; + for (let i = 0; i < all.length; i += chunk) { + const slice = all.slice(i, i + chunk); + const qs = new URLSearchParams(); + qs.set("fields", "id,username"); + qs.set("limit", String(slice.length)); + qs.set("filter[id][_in]", slice.join(",")); + + try { + const r = await fetch(`/api/dx/users?${qs.toString()}`, { + credentials: "include", + cache: "no-store", + }); + if (!r.ok) { + const j = await readJson(r).catch(() => null); + const msg = (j as any)?.errors?.[0]?.message || `HTTP ${r.status}`; + console.warn("Owner lookup failed:", msg); + if (r.status === 401 || r.status === 403) break; + continue; + } + const j = await readJson(r); + const rows: Array<{ id: string; username?: string | null }> = j?.data || []; + for (const row of rows) { + updates[String(row.id)] = row.username || String(row.id); + } + } catch (e) { + console.warn("Owner lookup error:", e); + break; + } + } + + if (Object.keys(updates).length) { + setOwnerMap((prev) => ({ ...prev, ...updates })); + } + setResolvingOwners(false); + })(); + }, [settings, ownerMap]); + + const isMine = (o: Owner) => { + if (!meId || !o) return false; + if (typeof o === "string" || typeof o === "number") return String(o) === meId; + if (o.id != null) return String(o.id) === meId; + return false; + }; + + const ownerLabel = (o: Owner) => { + if (!o) return "—"; + if (typeof o === "string" || typeof o === "number") { + const id = String(o); + return ownerMap[id] || id; + } + return ( + o.username || + [o.first_name, o.last_name].filter(Boolean).join(" ").trim() || + o.email || + (o.id != null ? String(o.id) : "—") + ); + }; + + const filtered = useMemo(() => { + const nq = (q || "").toLowerCase(); + if (!nq) return settings; + return settings.filter((entry) => { + const fields = [ + entry.setting_title, + ownerLabel(entry.owner), + entry.uploader, + entry.mat?.name, + entry.mat_coat?.name, + entry.source?.model, + entry.lens?.field_size as any, + ].filter(Boolean); + return fields.some((v: any) => String(v).toLowerCase().includes(nq)); + }); + }, [settings, q, ownerMap]); + + return ( +
+
+ { + setLocalQuery(e.currentTarget.value); + onQueryChange?.(e.currentTarget.value); + }} + placeholder="Search by title, owner, material, model…" + className="w-full max-w-lg border rounded px-3 py-2" + /> +
+ + {loading ? ( +

Loading…

+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((s) => { + const mine = isMine(s.owner); + const ownerText = ownerLabel(s.owner) + (mine ? " (you)" : ""); + return ( + + + + + + + + + + ); + })} + +
TitleOwner {resolvingOwners ? "…resolving" : ""}MaterialCoatingModelFieldActions
+ + {s.setting_title || "Untitled"} + + {ownerText}{s.mat?.name || "—"}{s.mat_coat?.name || "—"}{s.source?.model || "—"}{s.lens?.field_size || "—"} + {mine ? ( + + Edit + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/components/portal/SettingsSwitcher.tsx b/components/portal/SettingsSwitcher.tsx index 62217c86..96a8758e 100644 --- a/components/portal/SettingsSwitcher.tsx +++ b/components/portal/SettingsSwitcher.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"; // Existing canonical pages const FiberPanel = dynamic(() => import("@/app/settings/fiber/page"), { ssr: false }); const UVPanel = dynamic(() => import("@/app/settings/uv/page"), { ssr: false }); -const CO2GalvoPanel = dynamic(() => import("@/app/settings/co2-galvo/page"), { ssr: false }); +const CO2GalvoPanel = dynamic(() => import("@/components/portal/panels/CO2GalvoPanel"), { ssr: false }); const CO2GantryPanel = dynamic(() => import("@/app/settings/co2-gantry/page"), { ssr: false }); // NEW: embed the submission form in the "Add" tab diff --git a/components/portal/panels/CO2GalvoPanel b/components/portal/panels/CO2GalvoPanel new file mode 100644 index 00000000..9989db24 --- /dev/null +++ b/components/portal/panels/CO2GalvoPanel @@ -0,0 +1,56 @@ +// components/portal/panels/CO2GalvoPanel.tsx +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import CO2GalvoList from "@/components/lists/CO2GalvoList"; +import CO2GalvoDetail from "@/components/details/CO2GalvoDetail"; + +export default function CO2GalvoPanel() { + const sp = useSearchParams(); + const router = useRouter(); + + const id = sp.get("id"); + const edit = sp.get("edit") === "1"; + + function linkFor(submission_id: string | number, opts?: { edit?: boolean }) { + const q = new URLSearchParams(sp.toString()); + q.set("t", "co2-galvo"); + q.set("id", String(submission_id)); + if (opts?.edit) q.set("edit", "1"); + else q.delete("edit"); + return `/portal/laser-settings?${q.toString()}`; + } + + function clearIdAndEdit() { + const q = new URLSearchParams(sp.toString()); + q.delete("id"); + q.delete("edit"); + router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false }); + } + + return ( +
+
+ +
+ +
+ {id ? ( + { + // Post-save: return to view mode and refetch by dropping edit=1 in place. + const q = new URLSearchParams(sp.toString()); + q.delete("edit"); + router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false }); + }} + /> + ) : ( +
Select a setting to view details
+ )} +
+
+ ); +}