settings overhaul and reset

This commit is contained in:
makearmy 2025-10-05 20:35:20 -04:00
parent 8b3aa65e2e
commit 7ef13e56ff
3 changed files with 486 additions and 300 deletions

View file

@ -2,12 +2,13 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import SettingsSubmit from "@/components/forms/SettingsSubmit";
type Rec = { type Rec = {
submission_id: string | number; submission_id: string | number;
setting_title?: string | null; setting_title?: string | null;
setting_notes?: string | null; setting_notes?: string | null;
photo?: { id?: string } | string | null; photo?: { id?: string } | string | null;
screen?: { id?: string } | string | null; screen?: { id?: string } | string | null;
@ -17,15 +18,15 @@ type Rec = {
mat_opacity?: { id?: string | number; opacity?: string | number | null } | null; mat_opacity?: { id?: string | number; opacity?: string | number | null } | null;
mat_thickness?: number | null; mat_thickness?: number | null;
laser_soft?: { id?: string | number; name?: string | null } | string | number | null;
source?: { submission_id?: string | number; make?: string | null; model?: string | null; nm?: string | null } | null; source?: { submission_id?: string | number; make?: string | null; model?: string | null; nm?: string | null } | null;
lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null; lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null;
focus?: number | null;
lens_conf?: { id?: string | number; name?: string | null } | null; lens_conf?: { id?: string | number; name?: string | null } | null;
lens_apt?: { id?: string | number; name?: string | null } | null; lens_apt?: { id?: string | number; name?: string | null } | null;
lens_exp?: { id?: string | number; name?: string | null } | null; lens_exp?: { id?: string | number; name?: string | null } | null;
focus?: number | null;
laser_soft?: { id?: string | number; name?: string | null } | string | number | null;
repeat_all?: number | null; repeat_all?: number | null;
fill_settings?: any[] | null; fill_settings?: any[] | null;
@ -34,243 +35,358 @@ type Rec = {
owner?: { id?: string | number; username?: string | null } | string | number | null; owner?: { id?: string | number; username?: string | null } | string | number | null;
uploader?: string | null; uploader?: string | null;
last_modified_date?: string | null; last_modified_date?: string | null;
}; };
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); type Me = { id: string | number; username?: string; email?: string } | null;
const asset = (id?: string | number) => (id ? `${API}/assets/${id}` : "");
async function readJson(r: Response) { const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const t = await r.text(); const fileUrl = (id?: string) => (id ? (API_BASE ? `${API_BASE}/assets/${id}` : `/api/dx/assets/${id}`) : "");
try {
return t ? JSON.parse(t) : null; async function readJson(res: Response) {
} catch { const t = await res.text();
return null; try { return t ? JSON.parse(t) : null; } catch { return null; }
}
} }
export default function CO2GalvoDetail({ id, editable = true }: { id: string | number; editable?: boolean }) { export default function CO2GalvoDetail({ id, editable }: { id: string | number; editable?: boolean }) {
const sp = useSearchParams();
const router = useRouter();
const editParam = sp.get("edit") === "1";
const [rec, setRec] = useState<Rec | null>(null); const [rec, setRec] = useState<Rec | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [me, setMe] = useState<Me>(null);
// robust me
useEffect(() => { useEffect(() => {
let live = true; let live = true;
(async () => { (async () => {
setLoading(true); try {
setErr(null); const r1 = await fetch(`/api/me`, { credentials: "include", cache: "no-store" });
const fields = [ if (r1.ok) {
"submission_id", const j = await readJson(r1);
"setting_title", const id = j?.id ?? j?.data?.id ?? null;
"setting_notes", const username = j?.username ?? j?.data?.username ?? null;
"photo.id", const email = j?.email ?? j?.data?.email ?? null;
"screen.id", if (live && id) { setMe({ id, username: username ?? undefined, email: email ?? undefined }); return; }
"mat.id", }
"mat.name", const r2 = await fetch(`/api/dx/users/me?fields=id,username,email`, { credentials: "include", cache: "no-store" });
"mat_coat.id", if (live && r2.ok) {
"mat_coat.name", const j2 = await readJson(r2);
"mat_color.id", const d = j2?.data ?? j2 ?? null;
"mat_color.name", setMe(d?.id ? { id: d.id, username: d.username ?? undefined, email: d.email ?? undefined } : null);
"mat_opacity.id", }
"mat_opacity.opacity", } catch { if (live) setMe(null); }
"mat_thickness", })();
"laser_soft.id", return () => { live = false; };
"laser_soft.name", }, []);
"source.submission_id",
"source.make",
"source.model",
"source.nm",
"lens.id",
"lens.field_size",
"lens.focal_length",
"focus",
"lens_conf.id",
"lens_conf.name",
"lens_apt.id",
"lens_apt.name",
"lens_exp.id",
"lens_exp.name",
"repeat_all",
"fill_settings",
"line_settings",
"raster_settings",
"owner.id",
"owner.username",
"uploader",
"last_modified_date",
].join(",");
const url = `${API}/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent( // load record (with readable fields)
String(id) useEffect(() => {
)}&limit=1`; if (!id) return;
let dead = false;
const res = await fetch(url, { credentials: "include", cache: "no-store" }); (async () => {
if (!res.ok) { try {
const j = await readJson(res); setLoading(true);
throw new Error(j?.errors?.[0]?.message || `HTTP ${res.status}`); 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",
"laser_soft.id","laser_soft.name",
"lens_conf.id","lens_conf.name",
"lens_apt.id","lens_apt.name",
"lens_exp.id","lens_exp.name",
"lens.id","lens.field_size","lens.focal_length",
"focus","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);
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);
} }
const j = await res.json(); })();
const row = Array.isArray(j?.data) ? j.data[0] : null;
if (!row) throw new Error("Not found"); return () => { dead = true; };
if (live) setRec(row);
})()
.catch((e: any) => live && setErr(e?.message || "Failed"))
.finally(() => live && setLoading(false));
return () => {
live = false;
};
}, [id]); }, [id]);
const photoId = typeof rec?.photo === "object" ? rec?.photo?.id : (rec?.photo as any); const ownerId = useMemo(() => {
const screenId = typeof rec?.screen === "object" ? rec?.screen?.id : (rec?.screen as any); if (!rec?.owner) return null;
return typeof rec.owner === "object" ? (rec.owner.id != null ? String(rec.owner.id) : null) : String(rec.owner);
}, [rec]);
const isMine = me?.id != null && ownerId != null && String(me.id) === String(ownerId);
if (loading) return <p className="p-4">Loading</p>; // Edit-mode initialValues shape for SettingsSubmit
if (err) return <div className="p-4 border border-red-300 bg-red-50 text-red-700 rounded">{err}</div>; const initialValues = useMemo(() => {
if (!rec) return <p className="p-4">Not found.</p>; if (!rec) return null;
const toId = (v: any) => (v == null ? "" : typeof v === "object" ? (v.id ?? v.submission_id ?? "") : String(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 softName = typeof rec.laser_soft === "object" ? rec.laser_soft?.name ?? "—" : "—"; return {
submission_id: rec.submission_id,
setting_title: rec.setting_title ?? "",
setting_notes: rec.setting_notes ?? "",
photo: photoId,
screen: screenId,
// Material
mat: toId(rec.mat) || null,
mat_coat: toId(rec.mat_coat) || null,
mat_color: toId(rec.mat_color) || null,
mat_opacity: toId(rec.mat_opacity) || null,
mat_thickness: rec.mat_thickness ?? null,
// Rig & Optics
laser_soft: typeof rec.laser_soft === "object" ? String(rec.laser_soft?.id ?? "") : (rec.laser_soft != null ? String(rec.laser_soft) : null),
source: rec.source && typeof rec.source === "object" ? (rec.source.submission_id != null ? String(rec.source.submission_id) : null) : (rec.source as any) ?? null,
lens_conf: toId(rec.lens_conf) || null,
lens_apt: toId(rec.lens_apt) || null,
lens_exp: toId(rec.lens_exp) || null,
lens: toId(rec.lens) || null,
focus: rec.focus ?? null,
repeat_all: rec.repeat_all ?? null,
// Repeaters
fill_settings: rec.fill_settings ?? [],
line_settings: rec.line_settings ?? [],
raster_settings: rec.raster_settings ?? [],
};
}, [rec]);
function setEdit(on: boolean) {
const q = new URLSearchParams(sp.toString());
if (on) q.set("edit", "1"); else q.delete("edit");
router.replace(`?${q.toString()}`, { scroll: false });
}
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 (editable && editParam && initialValues) {
return (
<main className="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={() => setEdit(false)}>Cancel</button>
</div>
<SettingsSubmit mode="edit" submissionId={rec.submission_id} initialValues={initialValues} />
</main>
);
}
// VIEW MODE (sections + order mirrors the form)
const photoId = typeof rec.photo === "object" ? rec.photo?.id : (rec.photo as any);
const screenId = typeof rec.screen === "object" ? rec.screen?.id : (rec.screen as any);
const photoSrc = photoId ? fileUrl(String(photoId)) : "";
const screenSrc = screenId ? fileUrl(String(screenId)) : "";
const softName = typeof rec.laser_soft === "object" ? (rec.laser_soft?.name ?? "—") : "—";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-bold">{rec.setting_title || "Untitled"}</h1> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold break-words">{rec.setting_title || "Untitled"}</h1>
{editable && isMine ? (
<button className="px-2 py-1 border rounded text-sm" onClick={() => setEdit(true)}>Edit</button>
) : null}
</div>
<div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div> <div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div>
</header> </header>
<section className="grid md:grid-cols-2 gap-6"> {/* Info */}
<div className="space-y-2 text-sm"> <section className="space-y-2">
<div> <h2 className="text-lg font-semibold">Info</h2>
<span className="font-medium">Owner:</span> {typeof rec.owner === "object" ? rec.owner?.username || rec.owner?.id : rec.owner || "—"} {rec.setting_notes ? <p className="text-sm whitespace-pre-wrap">{rec.setting_notes}</p> : <p className="text-sm text-muted-foreground">No notes.</p>}
</div> </section>
<div>
<span className="font-medium">Uploader:</span> {rec.uploader || "—"} {/* Images */}
</div> {(photoSrc || screenSrc) && (
<div> <section className="space-y-2">
<span className="font-medium">Material:</span> {rec.mat?.name || "—"} <h2 className="text-lg font-semibold">Images</h2>
</div> <div className="grid grid-cols-2 gap-4">
<div> {photoSrc ? (
<span className="font-medium">Coating:</span> {rec.mat_coat?.name || "—"} <figure className="space-y-1">
</div> <img src={photoSrc} alt="Result" className="rounded border object-cover w-full" />
<div> <figcaption className="text-xs text-muted-foreground">Result</figcaption>
<span className="font-medium">Color:</span> {rec.mat_color?.name || "—"} </figure>
</div> ) : null}
<div> {screenSrc ? (
<span className="font-medium">Opacity:</span> {rec.mat_opacity?.opacity ?? "—"} <figure className="space-y-1">
</div> <img src={screenSrc} alt="Settings Screenshot" className="rounded border object-cover w-full" />
<div> <figcaption className="text-xs text-muted-foreground">Settings Screenshot</figcaption>
<span className="font-medium">Thickness (mm):</span> {rec.mat_thickness ?? "—"} </figure>
) : null}
</div>
</section>
)}
{/* Material */}
<section className="space-y-2">
<h2 className="text-lg font-semibold">Material</h2>
<div className="grid md:grid-cols-2 gap-2 text-sm">
<div><span className="font-medium">Material:</span> {rec.mat?.name || "—"}</div>
<div><span className="font-medium">Coating:</span> {rec.mat_coat?.name || "—"}</div>
<div><span className="font-medium">Color:</span> {rec.mat_color?.name || "—"}</div>
<div><span className="font-medium">Opacity:</span> {rec.mat_opacity?.opacity ?? "—"}</div>
<div><span className="font-medium">Material Thickness (mm):</span> {rec.mat_thickness ?? "—"}</div>
</div> </div>
</section>
{/* Rig & Optics (ordered) */}
<section className="space-y-2">
<h2 className="text-lg font-semibold">Rig & Optics</h2>
<div className="grid md:grid-cols-3 gap-2 text-sm">
<div><span className="font-medium">Laser Software:</span> {softName}</div>
<div> <div>
<span className="font-medium">Laser Source:</span>{" "} <span className="font-medium">Laser Source:</span>{" "}
{[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") || "—"} {[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") || "—"}
{rec.source?.nm ? ` (${rec.source.nm})` : ""} {rec.source?.nm ? ` (${rec.source.nm})` : ""}
</div> </div>
<div><span className="font-medium">Lens Configuration:</span> {rec.lens_conf?.name ?? "—"}</div>
<div><span className="font-medium">Scan Head Aperture:</span> {rec.lens_apt?.name ?? "—"}</div>
<div><span className="font-medium">Beam Expander:</span> {rec.lens_exp?.name ?? "—"}</div>
<div> <div>
<span className="font-medium">Scan Lens:</span> {rec.lens?.field_size || "—"} <span className="font-medium">Scan Lens:</span>{" "}
{rec.lens?.focal_length ? ` / ${rec.lens.focal_length} mm` : ""} {rec.lens?.field_size || "—"}{rec.lens?.focal_length ? ` / ${rec.lens.focal_length} mm` : ""}
</div> </div>
<div> <div><span className="font-medium">Focus (mm):</span> {rec.focus ?? "—"}</div>
<span className="font-medium">Focus (mm):</span> {rec.focus ?? "—"} <div><span className="font-medium">Repeat All:</span> {rec.repeat_all ?? "—"}</div>
</div>
<div>
<span className="font-medium">Software:</span> {softName}
</div>
<div>
<span className="font-medium">Lens Config:</span> {rec.lens_conf?.name || "—"}
</div>
<div>
<span className="font-medium">Scan Head Aperture:</span> {rec.lens_apt?.name || "—"}
</div>
<div>
<span className="font-medium">Beam Expander:</span> {rec.lens_exp?.name || "—"}
</div>
<div>
<span className="font-medium">Repeat All:</span> {rec.repeat_all ?? "—"}
</div> </div>
</section>
{rec.setting_notes ? ( {/* Process Settings */}
<div className="pt-2"> {(rec.fill_settings?.length ?? 0) > 0 && (
<div className="text-sm font-medium mb-1">Notes</div> <section className="space-y-2">
<p className="whitespace-pre-wrap">{rec.setting_notes}</p> <h2 className="text-lg font-semibold">Fill Settings</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr>
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1 text-left">Type</th>
<th className="px-2 py-1 text-left">Power (%)</th>
<th className="px-2 py-1 text-left">Speed (mm/s)</th>
<th className="px-2 py-1 text-left">Interval</th>
<th className="px-2 py-1 text-left">Angle</th>
<th className="px-2 py-1 text-left">Pass</th>
<th className="px-2 py-1 text-left">Freq (kHz)</th>
<th className="px-2 py-1 text-left">Pulse (ns)</th>
</tr>
</thead>
<tbody className="divide-y">
{rec.fill_settings!.map((r: any, i: number) => (
<tr key={i}>
<td className="px-2 py-1">{r.name || "—"}</td>
<td className="px-2 py-1">{r.type || "—"}</td>
<td className="px-2 py-1">{r.power ?? "—"}</td>
<td className="px-2 py-1">{r.speed ?? "—"}</td>
<td className="px-2 py-1">{r.interval ?? "—"}</td>
<td className="px-2 py-1">{r.angle ?? "—"}</td>
<td className="px-2 py-1">{r.pass ?? "—"}</td>
<td className="px-2 py-1">{r.frequency ?? "—"}</td>
<td className="px-2 py-1">{r.pulse ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) : null} </section>
</div>
<div className="grid grid-cols-2 gap-3">
{photoId ? (
<figure>
<img className="w-full rounded border" src={asset(photoId)} alt="Result" />
<figcaption className="text-xs text-muted-foreground mt-1">Result</figcaption>
</figure>
) : null}
{screenId ? (
<figure>
<img className="w-full rounded border" src={asset(screenId)} alt="Settings Screenshot" />
<figcaption className="text-xs text-muted-foreground mt-1">Settings</figcaption>
</figure>
) : null}
</div>
</section>
{/* Tables */}
{Array.isArray(rec.fill_settings) && rec.fill_settings.length > 0 && (
<Table title="Fill Settings" cols={["Name", "Type", "Power", "Speed", "Interval", "Angle", "Pass", "Freq", "Pulse"]} rows={rec.fill_settings.map((r) => [
r.name ?? "—",
r.type ?? "—",
r.power ?? "—",
r.speed ?? "—",
r.interval ?? "—",
r.angle ?? "—",
r.pass ?? "—",
r.frequency ?? "—",
r.pulse ?? "—",
])} />
)} )}
{Array.isArray(rec.line_settings) && rec.line_settings.length > 0 && ( {(rec.line_settings?.length ?? 0) > 0 && (
<Table title="Line Settings" cols={["Name", "Power", "Speed", "Freq", "Pulse", "Pass", "Air"]} rows={rec.line_settings.map((r) => [ <section className="space-y-2">
r.name ?? "—", <h2 className="text-lg font-semibold">Line Settings</h2>
r.power ?? "—", <div className="overflow-x-auto">
r.speed ?? "—", <table className="w-full text-sm">
r.frequency ?? "—", <thead className="border-b">
r.pulse ?? "—", <tr>
r.pass ?? "—", <th className="px-2 py-1 text-left">Name</th>
r.air ? "Yes" : "No", <th className="px-2 py-1 text-left">Power</th>
])} /> <th className="px-2 py-1 text-left">Speed</th>
<th className="px-2 py-1 text-left">Freq</th>
<th className="px-2 py-1 text-left">Pulse</th>
<th className="px-2 py-1 text-left">Pass</th>
<th className="px-2 py-1 text-left">Air</th>
</tr>
</thead>
<tbody className="divide-y">
{rec.line_settings!.map((r: any, i: number) => (
<tr key={i}>
<td className="px-2 py-1">{r.name || "—"}</td>
<td className="px-2 py-1">{r.power ?? "—"}</td>
<td className="px-2 py-1">{r.speed ?? "—"}</td>
<td className="px-2 py-1">{r.frequency ?? "—"}</td>
<td className="px-2 py-1">{r.pulse ?? "—"}</td>
<td className="px-2 py-1">{r.pass ?? "—"}</td>
<td className="px-2 py-1">{r.air ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)} )}
{Array.isArray(rec.raster_settings) && rec.raster_settings.length > 0 && ( {(rec.raster_settings?.length ?? 0) > 0 && (
<Table title="Raster Settings" cols={["Name", "Type", "Dither", "Power", "Speed", "Interval", "Pass"]} rows={rec.raster_settings.map((r) => [ <section className="space-y-2">
r.name ?? "—", <h2 className="text-lg font-semibold">Raster Settings</h2>
r.type ?? "—", <div className="overflow-x-auto">
r.dither ?? "—", <table className="w-full text-sm">
r.power ?? "—", <thead className="border-b">
r.speed ?? "—", <tr>
r.interval ?? "—", <th className="px-2 py-1 text-left">Name</th>
r.pass ?? "—", <th className="px-2 py-1 text-left">Type</th>
])} /> <th className="px-2 py-1 text-left">Dither</th>
<th className="px-2 py-1 text-left">Power</th>
<th className="px-2 py-1 text-left">Speed</th>
<th className="px-2 py-1 text-left">Interval</th>
<th className="px-2 py-1 text-left">Pass</th>
</tr>
</thead>
<tbody className="divide-y">
{rec.raster_settings!.map((r: any, i: number) => (
<tr key={i}>
<td className="px-2 py-1">{r.name || "—"}</td>
<td className="px-2 py-1">{r.type || "—"}</td>
<td className="px-2 py-1">{r.dither || "—"}</td>
<td className="px-2 py-1">{r.power ?? "—"}</td>
<td className="px-2 py-1">{r.speed ?? "—"}</td>
<td className="px-2 py-1">{r.interval ?? "—"}</td>
<td className="px-2 py-1">{r.pass ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)} )}
</div> </div>
); );
} }
function Table({ title, cols, rows }: { title: string; cols: string[]; rows: any[][] }) {
return (
<section className="space-y-2">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr>{cols.map((c) => <th key={c} className="px-2 py-1 text-left">{c}</th>)}</tr>
</thead>
<tbody className="divide-y">
{rows.map((r, i) => (
<tr key={i}>{r.map((v, j) => <td key={j} className="px-2 py-1">{String(v)}</td>)}</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View file

@ -2,20 +2,13 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; import { useForm, useFieldArray, useWatch, type UseFormRegister } from "react-hook-form";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
/**
* From-scratch CO Galvo form that follows the data sheet.
* - Prefill works via reset() with raw IDs.
* - Submits via /app/api/settings (this file does not assume any old helpers).
* - Lens options belong to Rig & Optics (not a separate "Lens Options" section).
*/
type Target = "settings_co2gal"; type Target = "settings_co2gal";
type Opt = { id: string; label: string }; type Opt = { id: string; label: string };
type Me = { id: string; username?: string; email?: string }; type Me = { id: string; username?: string; email?: string } | null;
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
@ -40,7 +33,8 @@ type EditInitialValues = {
source?: string | null; // submission_id source?: string | null; // submission_id
lens?: string | null; lens?: string | null;
focus?: number | null; focus?: number | null;
// CO2 Galvo triplet (Rig & Optics)
// CO2 Galvo triplet
lens_conf?: string | null; lens_conf?: string | null;
lens_apt?: string | null; lens_apt?: string | null;
lens_exp?: string | null; lens_exp?: string | null;
@ -58,21 +52,39 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
const router = useRouter(); const router = useRouter();
const isEdit = mode === "edit"; const isEdit = mode === "edit";
const [me, setMe] = useState<Me | null>(null); const [me, setMe] = useState<Me>(null);
const [submitErr, setSubmitErr] = useState<string | null>(null); const [submitErr, setSubmitErr] = useState<string | null>(null);
// Robust current-user fetch
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
fetch(`/api/me`, { cache: "no-store", credentials: "include" }) (async () => {
.then((r) => (r.ok ? r.json() : null)) try {
.then((j) => alive && setMe(j || null)) const r1 = await fetch(`/api/me`, { cache: "no-store", credentials: "include" });
.catch(() => alive && setMe(null)); if (r1.ok) {
return () => { const j = await r1.json().catch(() => null);
alive = false; if (alive && j) {
}; const id = j?.id ?? j?.data?.id ?? null;
const username = j?.username ?? j?.data?.username ?? j?.name ?? null;
const email = j?.email ?? j?.data?.email ?? null;
setMe(id ? { id, username: username ?? undefined, email: email ?? undefined } : null);
return;
}
}
const r2 = await fetch(`/api/dx/users/me?fields=id,username,email`, { cache: "no-store", credentials: "include" });
if (alive && r2.ok) {
const j2 = await r2.json().catch(() => null);
const d = j2?.data ?? j2 ?? null;
setMe(d?.id ? { id: d.id, username: d.username ?? undefined, email: d.email ?? undefined } : null);
}
} catch {
if (alive) setMe(null);
}
})();
return () => { alive = false; };
}, []); }, []);
// Options loaders (raw Directus reads) // Options loaders (Directus reads)
function useOptions(path: string, includeId?: string | null) { function useOptions(path: string, includeId?: string | null) {
const [opts, setOpts] = useState<Opt[]>([]); const [opts, setOpts] = useState<Opt[]>([]);
useEffect(() => { useEffect(() => {
@ -92,7 +104,6 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
} else if (path === "laser_source_co2_galvo") { } else if (path === "laser_source_co2_galvo") {
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
// Explicitly reference the global Number to avoid the local <Number/> component shadowing it.
type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null }; type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null };
const toNum = (v: unknown): number | null => { const toNum = (v: unknown): number | null => {
if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null; if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null;
@ -147,9 +158,7 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
if (live) setOpts(list); if (live) setOpts(list);
})().catch(() => live && setOpts([])); })().catch(() => live && setOpts([]));
return () => { return () => { live = false; };
live = false;
};
}, [path, includeId]); }, [path, includeId]);
return { opts }; return { opts };
@ -163,16 +172,7 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
]; ];
const RASTER_TYPES = FILL_TYPES; const RASTER_TYPES = FILL_TYPES;
const RASTER_DITHER: Opt[] = [ const RASTER_DITHER: Opt[] = [
"threshold", "threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale",
"ordered",
"atkinson",
"dither",
"stucki",
"jarvis",
"newsprint",
"halftone",
"sketch",
"grayscale",
].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) })); ].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) }));
// react-hook-form // react-hook-form
@ -195,11 +195,12 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
// Rig & Optics // Rig & Optics
laser_soft: "", laser_soft: "",
source: "", source: "",
lens: "", // keep these blank so Select shows "—"
focus: "",
lens_conf: "", lens_conf: "",
lens_apt: "", lens_apt: "",
lens_exp: "", lens_exp: "",
lens: "",
focus: "",
repeat_all: "", repeat_all: "",
// Repeaters // Repeaters
fill_settings: [], fill_settings: [],
@ -228,11 +229,11 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
// Rig & Optics // Rig & Optics
laser_soft: initialValues.laser_soft ?? "", laser_soft: initialValues.laser_soft ?? "",
source: initialValues.source ?? "", source: initialValues.source ?? "",
lens: initialValues.lens ?? "",
focus: initialValues.focus ?? "",
lens_conf: initialValues.lens_conf ?? "", lens_conf: initialValues.lens_conf ?? "",
lens_apt: initialValues.lens_apt ?? "", lens_apt: initialValues.lens_apt ?? "",
lens_exp: initialValues.lens_exp ?? "", lens_exp: initialValues.lens_exp ?? "",
lens: initialValues.lens ?? "",
focus: initialValues.focus ?? "",
repeat_all: initialValues.repeat_all ?? "", repeat_all: initialValues.repeat_all ?? "",
// Repeaters // Repeaters
fill_settings: initialValues.fill_settings ?? [], fill_settings: initialValues.fill_settings ?? [],
@ -270,16 +271,16 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
mat_coat: values.mat_coat || null, mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null, mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null, mat_opacity: values.mat_opacity || null,
mat_thickness: values.mat_thickness === "" ? null : Number(values.mat_thickness), mat_thickness: values.mat_thickness === "" ? null : globalThis.Number(values.mat_thickness),
// Rig & Optics // Rig & Optics
laser_soft: values.laser_soft || null, laser_soft: values.laser_soft || null,
source: values.source || null, source: values.source || null,
lens: values.lens || null,
focus: values.focus === "" ? null : Number(values.focus),
lens_conf: values.lens_conf || null, lens_conf: values.lens_conf || null,
lens_apt: values.lens_apt || null, lens_apt: values.lens_apt || null,
lens_exp: values.lens_exp || null, lens_exp: values.lens_exp || null,
repeat_all: values.repeat_all === "" ? null : Number(values.repeat_all), lens: values.lens || null,
focus: values.focus === "" ? null : globalThis.Number(values.focus),
repeat_all: values.repeat_all === "" ? null : globalThis.Number(values.repeat_all),
// Repeaters (raw pass-through; api will normalize nums/bools) // Repeaters (raw pass-through; api will normalize nums/bools)
fill_settings: values.fill_settings || [], fill_settings: values.fill_settings || [],
line_settings: values.line_settings || [], line_settings: values.line_settings || [],
@ -311,11 +312,13 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
} }
}; };
const meLabel = me?.username || me?.email || "";
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-xl font-semibold">{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}</h1> <h1 className="text-xl font-semibold">{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}</h1>
{me ? <p className="text-sm text-muted-foreground">Submitting as {me.username || me.email}</p> : null} {meLabel ? <p className="text-sm text-muted-foreground">Submitting as {meLabel}</p> : null}
{submitErr ? <div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div> : null} {submitErr ? <div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div> : null}
</header> </header>
@ -325,7 +328,7 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
<h2 className="text-lg font-semibold">Info</h2> <h2 className="text-lg font-semibold">Info</h2>
<div className="grid gap-3"> <div className="grid gap-3">
<div> <div>
<label className="block text-sm mb-1">Title *</label> <label className="block text-sm mb-1">Title</label>
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} /> <input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div> </div>
<div> <div>
@ -340,7 +343,7 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
<h2 className="text-lg font-semibold">Images</h2> <h2 className="text-lg font-semibold">Images</h2>
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm mb-1">Result Photo {isEdit || initialValues?.photo ? null : <span className="text-red-600">*</span>}</label> <label className="block text-sm mb-1">Result Photo</label>
<input type="file" accept="image/*" onChange={onPick(setPhotoFile)} /> <input type="file" accept="image/*" onChange={onPick(setPhotoFile)} />
</div> </div>
<div> <div>
@ -354,28 +357,33 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-lg font-semibold">Material</h2> <h2 className="text-lg font-semibold">Material</h2>
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
<Select label="Material *" {...{ name: "mat", register, options: mats.opts, required: true }} /> <Select label="Material" {...{ name: "mat", register, options: mats.opts, required: true }} />
<Select label="Coating *" {...{ name: "mat_coat", register, options: coats.opts, required: true }} /> <Select label="Coating" {...{ name: "mat_coat", register, options: coats.opts, required: true }} />
<Select label="Color *" {...{ name: "mat_color", register, options: colors.opts, required: true }} /> <Select label="Color" {...{ name: "mat_color", register, options: colors.opts, required: true }} />
<Select label="Opacity *" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} /> <Select label="Opacity" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} />
<Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" /> <Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" />
</div> </div>
</section> </section>
{/* Rig & Optics (includes lens_conf/apt/exp) */} {/* Rig & Optics (order per spec) */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-lg font-semibold">Rig & Optics</h2> <h2 className="text-lg font-semibold">Rig & Optics</h2>
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
<Select label="Software *" {...{ name: "laser_soft", register, options: soft.opts, required: true }} /> <Select label="Laser Software" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
<Select label="Laser Source *" {...{ name: "source", register, options: srcs.opts, required: true }} /> <Select label="Laser Source" {...{ name: "source", register, options: srcs.opts, required: true }} />
<Select label="Scan Lens *" {...{ name: "lens", register, options: lens.opts, required: true }} />
<Number label="Focus (mm) *" name="focus" register={register} step="1" />
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
</div> </div>
<div className="grid md:grid-cols-3 gap-3"> <div className="grid md:grid-cols-3 gap-3">
<Select label="Lens Configuration *" {...{ name: "lens_conf", register, options: conf.opts, required: true }} /> <Select label="Lens Configuration" {...{ name: "lens_conf", register, options: conf.opts, required: true }} />
<Select label="Scan Head Aperture *" {...{ name: "lens_apt", register, options: apt.opts, required: true }} /> <Select label="Scan Head Aperture" {...{ name: "lens_apt", register, options: apt.opts, required: true }} />
<Select label="Beam Expander *" {...{ name: "lens_exp", register, options: exp.opts, required: true }} /> <Select label="Beam Expander" {...{ name: "lens_exp", register, options: exp.opts, required: true }} />
</div>
<div className="grid md:grid-cols-3 gap-3">
<Select label="Scan Lens" {...{ name: "lens", register, options: lens.opts, required: true }} />
<Number label="Focus (mm)" name="focus" register={register} step="1" />
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
</div> </div>
</section> </section>
@ -387,7 +395,7 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
<Repeater <Repeater
title="Fill" title="Fill"
fields={fills.fields} fields={fills.fields}
onAdd={() => fills.append({ type: "uni" })} onAdd={() => fills.append({ type: "" })} // default to "—"
onRemove={(i) => fills.remove(i)} onRemove={(i) => fills.remove(i)}
render={(i) => ( render={(i) => (
<div className="grid md:grid-cols-4 gap-2"> <div className="grid md:grid-cols-4 gap-2">
@ -438,27 +446,35 @@ export default function SettingsSubmit({ mode = "create", submissionId, initialV
<Repeater <Repeater
title="Raster" title="Raster"
fields={rasters.fields} fields={rasters.fields}
onAdd={() => rasters.append({ type: "uni", dither: "threshold" })} onAdd={() => rasters.append({ type: "", dither: "" })} // default to "—"
onRemove={(i) => rasters.remove(i)} onRemove={(i) => rasters.remove(i)}
render={(i) => ( render={(i) => {
<div className="grid md:grid-cols-4 gap-2"> const ditherVal = useWatch({ control, name: `raster_settings.${i}.dither` }) || "";
<Text label="Name" name={`raster_settings.${i}.name`} register={register} /> const isHalftone = ditherVal === "halftone";
<Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" /> return (
<Number label="Pulse (ns)" name={`raster_settings.${i}.pulse`} register={register} step="0.1" /> <div className="grid md:grid-cols-4 gap-2">
<Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} /> <Text label="Name" name={`raster_settings.${i}.name`} register={register} />
<Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} /> <Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" />
<Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" /> <Number label="Pulse (ns)" name={`raster_settings.${i}.pulse`} register={register} step="0.1" />
<Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" /> <Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} />
<Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" /> <Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} />
<Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" /> <Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" />
<Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" /> <Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" />
<Number label="Dot" name={`raster_settings.${i}.dot`} register={register} step="0.1" /> {isHalftone && (
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" /> <>
<Check label="Cross" name={`raster_settings.${i}.cross`} register={register} /> <Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" />
<Check label="Inversion" name={`raster_settings.${i}.inversion`} register={register} /> <Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" />
<Check label="Air" name={`raster_settings.${i}.air`} register={register} /> </>
</div> )}
)} <Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" />
<Number label="Dot" name={`raster_settings.${i}.dot`} register={register} step="0.1" />
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" />
<Check label="Cross" name={`raster_settings.${i}.cross`} register={register} />
<Check label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
<Check label="Air" name={`raster_settings.${i}.air`} register={register} />
</div>
);
}}
/> />
</section> </section>
@ -486,9 +502,7 @@ function Select({
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}> <select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value=""></option> <option value=""></option>
{options.map((o) => ( {options.map((o) => (
<option key={o.id} value={o.id}> <option key={o.id} value={o.id}>{o.label}</option>
{o.label}
</option>
))} ))}
</select> </select>
</div> </div>
@ -522,16 +536,12 @@ function Repeater({ title, fields, onAdd, onRemove, render }: any) {
<fieldset className="border rounded p-3 space-y-2"> <fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<legend className="font-semibold">{title}</legend> <legend className="font-semibold">{title}</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={onAdd}> <button type="button" className="px-2 py-1 border rounded" onClick={onAdd}>+ Add</button>
+ Add
</button>
</div> </div>
{fields.map((_: any, i: number) => ( {fields.map((_: any, i: number) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
{render(i)} {render(i)}
<button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}> <button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}>Remove</button>
Remove
</button>
</div> </div>
))} ))}
</fieldset> </fieldset>

View file

@ -51,11 +51,43 @@ export default function CO2GalvoList({
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [localQuery, setLocalQuery] = useState(queryText ?? ""); const [localQuery, setLocalQuery] = useState(queryText ?? "");
const [meId, setMeId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (queryText !== undefined) setLocalQuery(queryText); if (queryText !== undefined) setLocalQuery(queryText);
}, [queryText]); }, [queryText]);
// Robust current-user id fetch so we can show "Edit" for owners
useEffect(() => {
let live = true;
(async () => {
try {
// 1) Try app-level /api/me
const r1 = await fetch(`/api/me`, { cache: "no-store", credentials: "include" });
if (r1.ok) {
const j1 = await readJson(r1);
const id1 = j1?.id ?? j1?.data?.id ?? null;
if (live && id1 != null) {
setMeId(String(id1));
return;
}
}
// 2) Fallback to Directus /users/me
const r2 = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
if (!r2.ok) return;
const j2 = await readJson(r2);
const id2 = j2?.data?.id ?? j2?.id ?? null;
if (live && id2 != null) setMeId(String(id2));
} catch {
/* ignore */
}
})();
return () => {
live = false;
};
}, []);
// Load rows
useEffect(() => { useEffect(() => {
let live = true; let live = true;
(async () => { (async () => {
@ -92,9 +124,23 @@ export default function CO2GalvoList({
const ownerLabel = (o: Owner) => { const ownerLabel = (o: Owner) => {
if (!o) return "—"; if (!o) return "—";
if (typeof o === "string" || typeof o === "number") return String(o); if (typeof o === "string" || typeof o === "number") return String(o);
return o.username || [o.first_name, o.last_name].filter(Boolean).join(" ").trim() || o.email || (o.id != null ? String(o.id) : "—"); return (
o.username ||
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
o.email ||
(o.id != null ? String(o.id) : "—")
);
}; };
const isMine = (o: Owner): boolean => {
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 withEdit = (href: string) => (href.includes("?") ? `${href}&edit=1` : `${href}?edit=1`);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = (localQuery || "").toLowerCase(); const q = (localQuery || "").toLowerCase();
if (!q) return rows; if (!q) return rows;
@ -124,29 +170,43 @@ export default function CO2GalvoList({
<table className="w-full table-fixed text-sm"> <table className="w-full table-fixed text-sm">
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th className="px-2 py-2 text-left w-[30%]">Title</th> <th className="px-2 py-2 text-left w-[28%]">Title</th>
<th className="px-2 py-2 text-left w-[16%]">Owner</th> <th className="px-2 py-2 text-left w-[16%]">Owner</th>
<th className="px-2 py-2 text-left w-[14%]">Material</th> <th className="px-2 py-2 text-left w-[14%]">Material</th>
<th className="px-2 py-2 text-left w-[14%]">Coating</th> <th className="px-2 py-2 text-left w-[14%]">Coating</th>
<th className="px-2 py-2 text-left w-[14%]">Model</th> <th className="px-2 py-2 text-left w-[14%]">Model</th>
<th className="px-2 py-2 text-left w-[10%]">Field</th> <th className="px-2 py-2 text-left w-[10%]">Field</th>
<th className="px-2 py-2 text-left w-[4%]">Edit</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{filtered.map((r) => ( {filtered.map((r) => {
<tr key={r.submission_id} className="hover:bg-muted/40"> const href = linkFor(r.submission_id);
<td className="px-2 py-2 truncate"> const mine = isMine(r.owner);
<Link href={linkFor(r.submission_id)} className="underline"> return (
{r.setting_title || "Untitled"} <tr key={r.submission_id} className="hover:bg-muted/40">
</Link> <td className="px-2 py-2 truncate">
</td> <Link href={href} className="underline">
<td className="px-2 py-2 truncate">{ownerLabel(r.owner)}</td> {r.setting_title || "Untitled"}
<td className="px-2 py-2 truncate">{r.mat?.name || "—"}</td> </Link>
<td className="px-2 py-2 truncate">{r.mat_coat?.name || "—"}</td> </td>
<td className="px-2 py-2 truncate">{r.source?.model || "—"}</td> <td className="px-2 py-2 truncate">{ownerLabel(r.owner)}{mine ? " (you)" : ""}</td>
<td className="px-2 py-2 truncate">{r.lens?.field_size || "—"}</td> <td className="px-2 py-2 truncate">{r.mat?.name || "—"}</td>
</tr> <td className="px-2 py-2 truncate">{r.mat_coat?.name || "—"}</td>
))} <td className="px-2 py-2 truncate">{r.source?.model || "—"}</td>
<td className="px-2 py-2 truncate">{r.lens?.field_size || "—"}</td>
<td className="px-2 py-2">
{mine ? (
<Link href={withEdit(href)} className="underline">
Edit
</Link>
) : (
<span className="opacity-50"></span>
)}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>