settings pages componenet restructure

This commit is contained in:
makearmy 2025-10-03 13:57:24 -04:00
parent 7b707e07a7
commit 099f21b130
7 changed files with 617 additions and 556 deletions

View file

@ -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<Rec | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(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 <select>
const toId = (v: any) =>
v == null ? null : (typeof v === "object" ? (v.id ?? v.submission_id ?? null) : v);
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);
// laser_source options use submission_id as value
const sourceId = rec.source && typeof rec.source === "object" ? (rec.source.submission_id ?? null) : rec.source ?? 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]);
if (loading) return <p className="p-6">Loading setting</p>;
if (err) return (
<div className="p-6">
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{err}
</div>
</div>
);
if (!rec) return <p className="p-6">Setting not found.</p>;
// ── EDIT MODE ───────────────────────────────────────────────
if (editMode && initialValues) {
return (
<main className="p-6 max-w-5xl mx-auto space-y-4">
<h1 className="text-2xl font-semibold">Edit CO Galvo Setting</h1>
<SettingsSubmit
mode="edit"
initialTarget="settings_co2gal"
submissionId={rec.submission_id}
initialValues={initialValues}
/>
<div className="text-sm">
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}`}> Back to view</Link>
</div>
</main>
);
}
// ── VIEW MODE (read-only) ───────────────────────────────────
const ownerDisplay = ownerLabel(rec.owner);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="card bg-card p-4 mb-4">
<h1 className="text-3xl font-bold mb-2">{rec.setting_title || "Untitled"}</h1>
{/* Keep the debug block for now */}
<pre className="text-xs bg-muted/30 rounded p-2 mb-2">
{JSON.stringify({ owner: rec?.owner }, null, 2)}
</pre>
<div className="space-y-1 text-sm text-muted-foreground">
<p><strong>Owner:</strong> {ownerDisplay}</p>
<p><strong>Uploader:</strong> {rec.uploader || "—"}</p>
</div>
</div>
<div className="pt-2">
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}?edit=1`}>
Edit this setting
</Link>
</div>
</div>
);
}

View file

@ -1,2 +1,11 @@
// app/settings/co2-galvo/[id]/page.tsx
export { default } from "./co2-galvo";
"use client";
import { useParams, useSearchParams } from "next/navigation";
import CO2GalvoDetail from "@/components/details/CO2GalvoDetail";
export default function CO2GalvoDetailStandalone() {
const params = useParams<{ id: string }>();
const sp = useSearchParams();
const edit = sp.get("edit") === "1";
return <CO2GalvoDetail id={params.id} mode={edit ? "edit" : "view"} />;
}

View file

@ -1,331 +1,14 @@
// app/settings/co2-galvo/page.tsx
"use client";
import CO2GalvoList from "@/components/lists/CO2GalvoList";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
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 default function CO2GalvoSettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState<SettingsRow[]>([]);
const [ownerMap, setOwnerMap] = useState<Record<string, string>>({}); // id -> username
const [loading, setLoading] = useState(true);
const [resolvingOwners, setResolvingOwners] = useState(false);
// current signed-in user id (for "Edit" visibility)
const [meId, setMeId] = useState<string | null>(null);
// Debounce search box
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(t);
}, [query]);
// Safe JSON reader (so HTML 404s don't blow up)
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})`);
}
}
// Load current user id (ignore errors; unauth just means no edit buttons)
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; // unauth or error → no edit display
const j = await readJson(r);
const id = j?.data?.id ?? j?.id ?? null;
if (!dead) setMeId(id ? String(id) : null);
} catch {
/* swallow */
}
})();
return () => {
dead = true;
};
}, []);
useEffect(() => {
// ✅ include parent field `owner` AND subfields; use auth proxy
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));
}, []);
// After settings load, resolve owner usernames directly via /api/dx/users using the user token
useEffect(() => {
if (!settings.length) return;
// Collect IDs needing resolution (owner is raw id OR object without username)
const ids = new Set<string>();
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 {
// object with maybe id/username
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; // Directus handles big _in, but chunk to be safe
setResolvingOwners(true);
(async () => {
const updates: Record<string, string> = {};
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));
// filter[id][_in]=a,b,c
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 403/401, perms don't allow reading other users; stop trying further
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; // prefer resolved username, fall back to id
}
// object
return (
o.username ||
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
o.email ||
(o.id != null ? String(o.id) : "—")
);
};
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(
`(${debouncedQuery.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")})`,
"gi"
);
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => {
const fieldsToSearch = [
entry.setting_title,
ownerLabel(entry.owner),
entry.uploader,
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.field_size as any,
];
return fieldsToSearch
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q));
});
}, [settings, debouncedQuery, ownerMap]);
export default function CO2GalvoSettingsPageStandalone() {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-4">
<input
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder="Search by title, owner, material, model…"
className="w-full max-w-lg border rounded px-3 py-2"
<div className="p-6 max-w-7xl mx-auto space-y-4">
<CO2GalvoList
linkFor={(sid, opts) =>
opts?.edit ? `/settings/co2-galvo/${sid}?edit=1` : `/settings/co2-galvo/${sid}`
}
/>
</div>
{loading ? (
<p>Loading</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-muted">
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">
Owner {resolvingOwners ? "…resolving" : ""}
</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Model</th>
<th className="px-2 py-2 text-left">Field</th>
<th className="px-2 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((s) => {
const mine = isMine(s.owner);
const ownerText =
ownerLabel(s.owner) + (mine ? " (you)" : "");
return (
<tr
key={s.submission_id}
className="border-b hover:bg-muted/30"
>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={`/settings/co2-galvo/${s.submission_id}`}
className="underline"
>
<span
dangerouslySetInnerHTML={{
__html: highlight(s.setting_title || "Untitled"),
}}
/>
</Link>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(ownerText),
}}
/>
<td className="px-2 py-2 whitespace-nowrap">
{s.mat?.name || "—"}
</td>
<td className="px-2 py-2 whitespace-nowrap">
{s.mat_coat?.name || "—"}
</td>
<td className="px-2 py-2 whitespace-nowrap">
{s.source?.model || "—"}
</td>
<td className="px-2 py-2 whitespace-nowrap">
{s.lens?.field_size || "—"}
</td>
<td className="px-2 py-2 whitespace-nowrap">
{mine ? (
<Link
href={`/settings/co2-galvo/${s.submission_id}?edit=1`}
className="underline"
>
Edit
</Link>
) : (
<span className="opacity-50"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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<Rec | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(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 <p className="p-6">Loading setting</p>;
if (err)
return (
<div className="p-6">
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">{err}</div>
</div>
);
if (!rec) return <p className="p-6">Setting not found.</p>;
// EDIT MODE
if (editMode && initialValues) {
return (
<main className="p-4 lg:p-6 max-w-5xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl lg:text-2xl font-semibold">Edit CO Galvo Setting</h1>
<button
className="px-2 py-1 border rounded"
onClick={() => {
if (onBack) onBack();
else clearEditParam();
}}
>
Cancel
</button>
</div>
<SettingsSubmit
mode="edit"
initialTarget="settings_co2gal"
submissionId={rec.submission_id}
initialValues={initialValues}
/>
<div className="text-sm">
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}`}>
Back to view
</Link>
</div>
</main>
);
}
// VIEW MODE
const ownerDisplay = ownerLabel(rec.owner);
return (
<div className="p-4 lg:p-6">
<div className="card bg-card p-4 mb-4 rounded border">
<h1 className="text-2xl font-bold mb-2 break-words">{rec.setting_title || "Untitled"}</h1>
{/* debug block left intact for now */}
<pre className="text-xs bg-muted/30 rounded p-2 mb-2 overflow-auto">
{JSON.stringify({ owner: rec?.owner }, null, 2)}
</pre>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<strong>Owner:</strong> {ownerDisplay}
</p>
<p>
<strong>Uploader:</strong> {rec.uploader || "—"}
</p>
</div>
</div>
{showOwnerEdit && (
<div className="pt-2">
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}?edit=1`}>
Edit this setting
</Link>
</div>
)}
</div>
);
}

View file

@ -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<SettingsRow[]>([]);
const [ownerMap, setOwnerMap] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [resolvingOwners, setResolvingOwners] = useState(false);
const [meId, setMeId] = useState<string | null>(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<string>();
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<string, string> = {};
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 (
<div className="space-y-3">
<div>
<input
value={localQuery}
onChange={(e) => {
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"
/>
</div>
{loading ? (
<p>Loading</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-muted">
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Owner {resolvingOwners ? "…resolving" : ""}</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Model</th>
<th className="px-2 py-2 text-left">Field</th>
<th className="px-2 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((s) => {
const mine = isMine(s.owner);
const ownerText = ownerLabel(s.owner) + (mine ? " (you)" : "");
return (
<tr key={s.submission_id} className="border-b hover:bg-muted/30">
<td className="px-2 py-2 whitespace-nowrap">
<Link href={linkFor(s.submission_id)} className="underline">
{s.setting_title || "Untitled"}
</Link>
</td>
<td className="px-2 py-2 whitespace-nowrap">{ownerText}</td>
<td className="px-2 py-2 whitespace-nowrap">{s.mat?.name || "—"}</td>
<td className="px-2 py-2 whitespace-nowrap">{s.mat_coat?.name || "—"}</td>
<td className="px-2 py-2 whitespace-nowrap">{s.source?.model || "—"}</td>
<td className="px-2 py-2 whitespace-nowrap">{s.lens?.field_size || "—"}</td>
<td className="px-2 py-2 whitespace-nowrap">
{mine ? (
<Link href={linkFor(s.submission_id, { edit: true })} className="underline">
Edit
</Link>
) : (
<span className="opacity-50"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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

View file

@ -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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-md border p-4">
<CO2GalvoList linkFor={linkFor} />
</div>
<div className="rounded-md border p-4 min-h-[240px]">
{id ? (
<CO2GalvoDetail
id={id}
mode={edit ? "edit" : "view"}
onBack={clearIdAndEdit}
onSaved={() => {
// 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 });
}}
/>
) : (
<div className="text-sm text-muted-foreground">Select a setting to view details</div>
)}
</div>
</div>
);
}