list and details cleanup

This commit is contained in:
makearmy 2025-10-05 22:32:27 -04:00
parent 6829f2840c
commit 2e8297d426
2 changed files with 125 additions and 135 deletions

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import SettingsSubmit from "@/components/forms/SettingsSubmit";
type Rec = {
submission_id: string | number;
@ -46,12 +47,13 @@ type Rec = {
export default function CO2GalvoDetail({ id, editable }: { id: string | number; editable?: boolean }) {
const router = useRouter();
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);
// current user id to show "Edit" for owners
// me id for owner-only edit
const [meId, setMeId] = useState<string | null>(null);
// Lightbox
@ -78,18 +80,13 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
(async () => {
try {
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
if (!r.ok) return;
const t = await r.text();
const j = t ? JSON.parse(t) : null;
const idVal = j?.data?.id ?? j?.id ?? null;
if (alive) setMeId(idVal ? String(idVal) : null);
} catch {
/* ignore */
}
} catch { /* ignore */ }
})();
return () => {
alive = false;
};
return () => { alive = false; };
}, []);
useEffect(() => {
@ -148,9 +145,9 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
"last_modified_date",
].join(",");
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(
fields
)}&filter[submission_id][_eq]=${encodeURIComponent(String(id))}&limit=1`;
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent(
String(id)
)}&limit=1`;
const r = await fetch(url, { cache: "no-store", credentials: "include" });
const text = await r.text();
@ -165,9 +162,7 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
if (!dead) setLoading(false);
}
})();
return () => {
dead = true;
};
return () => { dead = true; };
}, [id]);
if (loading) return <p className="p-6">Loading setting</p>;
@ -186,42 +181,49 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
const softName = typeof rec.laser_soft === "object" ? rec.laser_soft?.name ?? "—" : "—";
const sourceText =
[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") +
(rec.source?.nm ? ` (${rec.source.nm})` : "");
[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") + (rec.source?.nm ? ` (${rec.source.nm})` : "");
const ownerId =
typeof rec.owner === "object"
? rec.owner?.id != null
? String(rec.owner.id)
: null
: rec.owner != null
? String(rec.owner)
: null;
typeof rec.owner === "object" ? (rec.owner?.id != null ? String(rec.owner.id) : null) : rec.owner != null ? String(rec.owner) : null;
const isMine = meId && ownerId ? meId === ownerId : false;
// Small field renderer: label on top, value below (+ optional suffix)
const Field = ({ label, value, suffix }: { label: string; value: any; suffix?: string }) => (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="text-sm break-words">
{value != null && value !== "" ? (
<>
{String(value)}
{suffix ? <span className="opacity-70"> {suffix}</span> : null}
</>
) : (
"—"
)}
</div>
</div>
);
// Small field renderer (label on top, value below). Accepts React nodes.
const Field = ({ label, value, suffix }: { label: string; value: React.ReactNode | string | number | null | undefined; suffix?: string }) => {
const primitive =
typeof value === "string" || typeof value === "number" || typeof value === "boolean";
const isEmpty = value == null || value === "" || (typeof value === "number" && isNaN(value as number));
return (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="text-sm break-words">
{isEmpty ? (
"—"
) : primitive ? (
<>
{String(value)}
{suffix ? <span className="opacity-70"> {suffix}</span> : null}
</>
) : (
// render React node directly (e.g., Notes paragraph)
value
)}
</div>
</div>
);
};
const openEdit = () => {
const q = new URLSearchParams(sp.toString());
q.set("edit", "1");
router.replace(`?${q.toString()}`, { scroll: false });
};
const closeEdit = () => {
const q = new URLSearchParams(sp.toString());
q.delete("edit");
router.replace(`?${q.toString()}`, { scroll: false });
};
// Pretty labels
const TYPE_LABEL: Record<string, string> = {
@ -231,6 +233,52 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
};
const DITHER_LABEL = (v: string | undefined) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : "—");
// ----- EDIT MODE (restore working behavior) -----
if (editMode && rec) {
const toId = (v: any) =>
v == null ? "" : typeof v === "object" ? (v.id ?? v.submission_id ?? "") : String(v);
const initialValues = {
submission_id: rec.submission_id,
setting_title: rec.setting_title ?? "",
setting_notes: rec.setting_notes ?? "",
photo: photoId ? String(photoId) : null,
screen: screenId ? String(screenId) : null,
// Material
mat: toId(rec.mat) || "",
mat_coat: toId(rec.mat_coat) || "",
mat_color: toId(rec.mat_color) || "",
mat_opacity: toId(rec.mat_opacity) || "",
mat_thickness: rec.mat_thickness ?? null,
// Rig & Optics
laser_soft: typeof rec.laser_soft === "object" ? String(rec.laser_soft?.id ?? "") : String(rec.laser_soft ?? "") || "",
source: rec.source && typeof rec.source === "object" ? String(rec.source.submission_id ?? "") : String(rec.source ?? "") || "",
lens: toId(rec.lens) || "",
focus: rec.focus ?? null,
// CO2 triplet
lens_conf: toId(rec.lens_conf) || "",
lens_apt: toId(rec.lens_apt) || "",
lens_exp: toId(rec.lens_exp) || "",
repeat_all: rec.repeat_all ?? null,
// Repeaters
fill_settings: rec.fill_settings ?? [],
line_settings: rec.line_settings ?? [],
raster_settings: rec.raster_settings ?? [],
};
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={closeEdit}>
Cancel
</button>
</div>
<SettingsSubmit mode="edit" submissionId={rec.submission_id} initialValues={initialValues} />
</main>
);
}
return (
<div className="space-y-6">
{/* Header */}
@ -246,16 +294,20 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
<div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div>
</header>
{/* Info (full width) */}
<section className="grid gap-3">
{/* Top row: Info (left) + Images (right) */}
<section className="grid md:grid-cols-2 gap-6 items-start">
{/* Info */}
<div className="grid gap-3">
<Field label="Owner" value={ownerLabel(rec.owner)} />
<Field label="Uploader" value={rec.uploader || "—"} />
{rec.setting_notes ? <Field label="Notes" value={<p className="whitespace-pre-wrap">{rec.setting_notes}</p>} /> : null}
</section>
{rec.setting_notes ? (
<Field label="Notes" value={<p className="whitespace-pre-wrap">{rec.setting_notes}</p>} />
) : null}
</div>
{/* Images (full width, click to expand, ~75% smaller) */}
{/* Images (side-by-side thumbnails) */}
{(photoSrc || screenSrc) && (
<section className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4">
{photoSrc ? (
<figure className="space-y-1 justify-self-start">
<div
@ -280,10 +332,11 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
<figcaption className="text-xs text-muted-foreground text-center">Settings Screenshot</figcaption>
</figure>
) : null}
</section>
</div>
)}
</section>
{/* Two columns: left Rig & Optics, right Material */}
{/* Two columns below: left Rig & Optics, right Material */}
<section className="grid md:grid-cols-2 gap-6">
{/* Rig & Optics */}
<div className="space-y-3">
@ -334,7 +387,7 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
<Field label="Type" value={TYPE_LABEL[r.type] || "—"} />
<Field label="Power" value={r.power ?? "—"} suffix="%" />
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
<Field label="Interval" value={r.interval ?? "—"} />
<Field label="Interval" value={r.interval ?? "—"} suffix="mm" />
<Field label="Angle" value={r.angle ?? "—"} suffix="°" />
<Field label="Pass" value={r.pass ?? "—"} />
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
@ -391,7 +444,7 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
<Field label="Dither" value={DITHER_LABEL(r.dither)} />
<Field label="Power" value={r.power ?? "—"} suffix="%" />
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
<Field label="Interval" value={r.interval ?? "—"} />
<Field label="Interval" value={r.interval ?? "—"} suffix="mm" />
<Field label="Pass" value={r.pass ?? "—"} />
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
<Field label="Pulse" value={r.pulse ?? "—"} suffix="ns" />
@ -410,16 +463,8 @@ export default function CO2GalvoDetail({ id, editable }: { id: string | number;
{/* Lightbox */}
{viewerSrc && (
<div
className="fixed inset-0 z-50 bg-black/80 p-4 flex items-center justify-center"
onClick={() => setViewerSrc(null)}
>
<img
src={viewerSrc}
alt=""
className="max-w-full max-h-full cursor-zoom-out"
onClick={(e) => e.stopPropagation()}
/>
<div className="fixed inset-0 z-50 bg-black/80 p-4 flex items-center justify-center" onClick={() => setViewerSrc(null)}>
<img src={viewerSrc} alt="" className="max-w-full max-h-full cursor-zoom-out" onClick={(e) => e.stopPropagation()} />
</div>
)}
</div>

View file

@ -51,32 +51,27 @@ export default function CO2GalvoList({
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [localQuery, setLocalQuery] = useState(queryText ?? "");
// id -> username map (fix showing UUIDs)
const [ownerMap, setOwnerMap] = useState<Record<string, string>>({});
// current user id for "Edit" visibility
const [meId, setMeId] = useState<string | null>(null);
useEffect(() => {
if (queryText !== undefined) setLocalQuery(queryText);
}, [queryText]);
// Load current user id
// who am I?
useEffect(() => {
let alive = true;
let live = true;
(async () => {
try {
const r = await fetch(`${API}/users/me?fields=id`, { credentials: "include", cache: "no-store" });
if (!r.ok) return;
const j = await r.json().catch(() => null);
const id = j?.data?.id ?? j?.id ?? null;
if (alive) setMeId(id ? String(id) : null);
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
const j = await readJson(r);
const idVal = j?.data?.id ?? j?.id ?? null;
if (live) setMeId(idVal ? String(idVal) : null);
} catch {
/* ignore */
if (live) setMeId(null);
}
})();
return () => {
alive = false;
live = false;
};
}, []);
@ -113,62 +108,9 @@ export default function CO2GalvoList({
};
}, []);
// Resolve owner usernames when only id/UUID is present
useEffect(() => {
const ids = new Set<string>();
for (const r of rows) {
const o = r.owner;
if (!o) continue;
if (typeof o === "string" || typeof o === "number") {
const id = String(o);
if (!ownerMap[id]) ids.add(id);
} 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;
let cancelled = false;
(async () => {
const all = Array.from(ids);
const updates: Record<string, string> = {};
const chunkSize = 100;
for (let i = 0; i < all.length; i += chunkSize) {
const slice = all.slice(i, i + chunkSize);
const qs = new URLSearchParams();
qs.set("fields", "id,username");
qs.set("limit", String(slice.length));
qs.set("filter[id][_in]", slice.join(","));
const url = `${API}/users?${qs.toString()}`;
try {
const r = await fetch(url, { credentials: "include", cache: "no-store" });
if (!r.ok) continue;
const j = await r.json().catch(() => null);
const arr: Array<{ id: string | number; username?: string | null }> = j?.data || [];
for (const u of arr) {
updates[String(u.id)] = u.username || String(u.id);
}
} catch {
/* ignore */
}
}
if (!cancelled && Object.keys(updates).length) {
setOwnerMap((prev) => ({ ...prev, ...updates }));
}
})();
return () => {
cancelled = true;
};
}, [rows, ownerMap]);
const ownerLabel = (o: Owner) => {
if (!o) return "—";
if (typeof o === "string" || typeof o === "number") {
const id = String(o);
return ownerMap[id] || id;
}
if (typeof o === "string" || typeof o === "number") return String(o);
return (
o.username ||
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
@ -177,15 +119,13 @@ export default function CO2GalvoList({
);
};
const isMine = (o: Owner) => {
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 withEditParam = (href: string) => (href.includes("?") ? `${href}&edit=1` : `${href}?edit=1`);
const filtered = useMemo(() => {
const q = (localQuery || "").toLowerCase();
if (!q) return rows;
@ -194,7 +134,9 @@ export default function CO2GalvoList({
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}, [rows, localQuery, ownerMap]);
}, [rows, localQuery]);
const addEditParam = (href: string) => (href.includes("?") ? `${href}&edit=1` : `${href}?edit=1`);
return (
<div className="space-y-3">
@ -226,22 +168,25 @@ export default function CO2GalvoList({
</thead>
<tbody className="divide-y">
{filtered.map((r) => {
const href = linkFor(r.submission_id);
const mine = isMine(r.owner);
const ownerText = ownerLabel(r.owner) + (mine ? " (you)" : "");
const viewHref = linkFor(r.submission_id);
const editHref = addEditParam(viewHref);
return (
<tr key={r.submission_id} className="hover:bg-muted/40">
<td className="px-2 py-2 truncate">
<Link href={href} className="underline">
<Link href={viewHref} className="underline">
{r.setting_title || "Untitled"}
</Link>
</td>
<td className="px-2 py-2 truncate">{ownerLabel(r.owner)}</td>
<td className="px-2 py-2 truncate">{ownerText}</td>
<td className="px-2 py-2 truncate">{r.mat?.name || "—"}</td>
<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">
{isMine(r.owner) ? (
<Link href={withEditParam(href)} className="underline">
{mine ? (
<Link href={editHref} className="underline">
Edit
</Link>
) : (