509 lines
22 KiB
TypeScript
509 lines
22 KiB
TypeScript
// components/details/CO2GalvoDetail.tsx
|
||
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
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;
|
||
|
||
// Material
|
||
mat?: { id?: string | number; name?: string | null } | null;
|
||
mat_coat?: { id?: string | number; name?: string | null } | null;
|
||
mat_color?: { id?: string | number; name?: string | null } | null;
|
||
mat_opacity?: { id?: string | number; opacity?: string | number | null } | null;
|
||
mat_thickness?: number | null;
|
||
|
||
// Rig & Optics
|
||
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;
|
||
lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null;
|
||
focus?: number | null;
|
||
|
||
// CO₂ Galvo fixed lists
|
||
lens_conf?: { id?: string | number; name?: string | null } | null;
|
||
lens_apt?: { id?: string | number; name?: string | null } | string | number | null;
|
||
lens_exp?: { id?: string | number; name?: string | null } | string | number | null;
|
||
|
||
repeat_all?: number | null;
|
||
|
||
// Repeaters
|
||
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;
|
||
};
|
||
|
||
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);
|
||
|
||
// me id for owner-only edit
|
||
const [meId, setMeId] = useState<string | null>(null);
|
||
|
||
// Lightbox
|
||
const [viewerSrc, setViewerSrc] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewerSrc(null);
|
||
if (viewerSrc) window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [viewerSrc]);
|
||
|
||
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||
const fileUrl = (assetId?: string) => (assetId ? (API_BASE ? `${API_BASE}/assets/${assetId}` : `/api/dx/assets/${assetId}`) : "");
|
||
|
||
function ownerLabel(o: Rec["owner"]) {
|
||
if (!o) return "—";
|
||
if (typeof o === "string" || typeof o === "number") return String(o);
|
||
return o.username || String(o.id ?? "—");
|
||
}
|
||
const yesNo = (v: any) => (v ? "Yes" : "No");
|
||
|
||
// stringify possibly-object options for display
|
||
const optLabel = (v: any): string => {
|
||
if (v == null) return "—";
|
||
if (typeof v === "string" || typeof v === "number") return String(v);
|
||
if (typeof v === "object") return v.name ?? (v.id != null ? String(v.id) : "—");
|
||
return "—";
|
||
};
|
||
|
||
// fetch me id
|
||
useEffect(() => {
|
||
let alive = true;
|
||
(async () => {
|
||
try {
|
||
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
|
||
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 */ }
|
||
})();
|
||
return () => { alive = false; };
|
||
}, []);
|
||
|
||
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",
|
||
|
||
// Material
|
||
"mat.id",
|
||
"mat.name",
|
||
"mat_coat.id",
|
||
"mat_coat.name",
|
||
"mat_color.id",
|
||
"mat_color.name",
|
||
"mat_opacity.id",
|
||
"mat_opacity.opacity",
|
||
"mat_thickness",
|
||
|
||
// Rig & Optics
|
||
"laser_soft.id",
|
||
"laser_soft.name",
|
||
"source.submission_id",
|
||
"source.make",
|
||
"source.model",
|
||
"source.nm",
|
||
"lens.id",
|
||
"lens.field_size",
|
||
"lens.focal_length",
|
||
"lens_conf.id",
|
||
"lens_conf.name",
|
||
"lens_apt.id",
|
||
"lens_apt.name",
|
||
"lens_exp.id",
|
||
"lens_exp.name",
|
||
"focus",
|
||
"repeat_all",
|
||
|
||
// Repeaters
|
||
"fill_settings",
|
||
"line_settings",
|
||
"raster_settings",
|
||
|
||
// Meta
|
||
"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" });
|
||
const text = await r.text();
|
||
const j = text ? JSON.parse(text) : null;
|
||
if (!r.ok) throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
||
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]);
|
||
|
||
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>;
|
||
|
||
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 = fileUrl(photoId ? String(photoId) : "");
|
||
const screenSrc = fileUrl(screenId ? String(screenId) : "");
|
||
|
||
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})` : "");
|
||
|
||
const ownerId =
|
||
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)
|
||
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}
|
||
</>
|
||
) : (
|
||
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> = {
|
||
uni: "UniDirectional",
|
||
bi: "BiDirectional",
|
||
offset: "Offset Fill",
|
||
};
|
||
const DITHER_LABEL = (v: string | undefined) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : "—");
|
||
|
||
// ----- EDIT MODE -----
|
||
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 */}
|
||
<header className="space-y-1">
|
||
<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={openEdit}>
|
||
Edit
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div>
|
||
</header>
|
||
|
||
{/* 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}
|
||
</div>
|
||
|
||
{/* Images (side-by-side thumbnails) */}
|
||
{(photoSrc || screenSrc) && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{photoSrc ? (
|
||
<figure className="space-y-1 justify-self-start">
|
||
<div
|
||
className="border rounded overflow-hidden cursor-zoom-in mx-auto w-40 md:w-48"
|
||
style={{ aspectRatio: "1 / 1" }}
|
||
onClick={() => setViewerSrc(photoSrc)}
|
||
>
|
||
<img src={photoSrc} alt="Result" className="w-full h-full object-cover" loading="lazy" />
|
||
</div>
|
||
<figcaption className="text-xs text-muted-foreground text-center">Result</figcaption>
|
||
</figure>
|
||
) : null}
|
||
{screenSrc ? (
|
||
<figure className="space-y-1 justify-self-start">
|
||
<div
|
||
className="border rounded overflow-hidden cursor-zoom-in mx-auto w-40 md:w-48"
|
||
style={{ aspectRatio: "1 / 1" }}
|
||
onClick={() => setViewerSrc(screenSrc)}
|
||
>
|
||
<img src={screenSrc} alt="Settings Screenshot" className="w-full h-full object-cover" loading="lazy" />
|
||
</div>
|
||
<figcaption className="text-xs text-muted-foreground text-center">Settings Screenshot</figcaption>
|
||
</figure>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Two columns below: left Rig & Optics, right Material */}
|
||
<section className="grid md:grid-cols-2 gap-6">
|
||
{/* Rig & Optics */}
|
||
<div className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Rig & Optics</h2>
|
||
<div className="grid gap-3">
|
||
<Field label="Software" value={softName} />
|
||
<Field label="Laser Source" value={sourceText || "—"} />
|
||
<Field label="Lens Configuration" value={rec.lens_conf?.name || "—"} />
|
||
<Field label="Scan Head Aperture" value={optLabel(rec.lens_apt)} suffix="mm" />
|
||
<Field label="Beam Expander" value={optLabel(rec.lens_exp)} suffix="x" />
|
||
<Field
|
||
label="Scan Lens"
|
||
value={
|
||
rec.lens
|
||
? `${rec.lens.field_size ?? "—"}${rec.lens.focal_length ? ` / ${rec.lens.focal_length}` : ""}`
|
||
: "—"
|
||
}
|
||
/>
|
||
<Field label="Focus" value={rec.focus ?? "—"} suffix="mm" />
|
||
<Field label="Repeat All" value={rec.repeat_all ?? "—"} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Material */}
|
||
<div className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Material</h2>
|
||
<div className="grid gap-3">
|
||
<Field label="Material" value={rec.mat?.name || "—"} />
|
||
<Field label="Coating" value={rec.mat_coat?.name || "—"} />
|
||
<Field label="Color" value={rec.mat_color?.name || "—"} />
|
||
<Field label="Opacity" value={rec.mat_opacity?.opacity ?? "—"} />
|
||
<Field label="Thickness" value={rec.mat_thickness ?? "—"} suffix="mm" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Repeaters (cards, full width) */}
|
||
{(rec.fill_settings?.length ?? 0) > 0 && (
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Fill Settings</h2>
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
{rec.fill_settings!.map((r: any, i: number) => {
|
||
const showIncrement = !!r.auto;
|
||
return (
|
||
<div key={i} className="border rounded p-3 space-y-2">
|
||
<div className="font-medium">{r.name || `Fill ${i + 1}`}</div>
|
||
<div className="grid sm:grid-cols-2 gap-2">
|
||
<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 ?? "—"} suffix="mm" />
|
||
<Field label="Angle" value={r.angle ?? "—"} suffix="°" />
|
||
<Field label="Pass" value={r.pass ?? "—"} />
|
||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||
<Field label="Pulse" value={r.pulse ?? "—"} suffix="ns" />
|
||
</div>
|
||
|
||
<div className="grid sm:grid-cols-2 gap-2 items-center">
|
||
<Field label="Auto Rotate" value={yesNo(r.auto)} />
|
||
{showIncrement && <Field label="Auto Rotate Increment" value={r.increment ?? "—"} suffix="°" />}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-6">
|
||
<Field label="Crosshatch" value={yesNo(r.cross)} />
|
||
<Field label="Flood Fill" value={yesNo(r.flood)} />
|
||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{(rec.line_settings?.length ?? 0) > 0 && (
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Line Settings</h2>
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
{rec.line_settings!.map((r: any, i: number) => {
|
||
const perfEnabled = !!r.perf;
|
||
const wobbleEnabled = !!r.wobble;
|
||
return (
|
||
<div key={i} className="border rounded p-3 space-y-2">
|
||
<div className="font-medium">{r.name || `Line ${i + 1}`}</div>
|
||
|
||
{/* Base fields – match form order */}
|
||
<div className="grid sm:grid-cols-2 gap-2">
|
||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||
<Field label="Pulse" value={r.pulse ?? "—"} suffix="ns" />
|
||
<Field label="Power" value={r.power ?? "—"} suffix="%" />
|
||
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
|
||
<Field label="Pass" value={r.pass ?? "—"} />
|
||
</div>
|
||
|
||
{/* Perforation row */}
|
||
<div className="grid sm:grid-cols-3 gap-2 items-center">
|
||
<Field label="Perforation Mode" value={yesNo(perfEnabled)} />
|
||
{perfEnabled && <Field label="Cut" value={r.cut ?? "—"} suffix="mm" />}
|
||
{perfEnabled && <Field label="Skip" value={r.skip ?? "—"} suffix="mm" />}
|
||
</div>
|
||
|
||
{/* Wobble row */}
|
||
<div className="grid sm:grid-cols-3 gap-2 items-center">
|
||
<Field label="Wobble" value={yesNo(wobbleEnabled)} />
|
||
{wobbleEnabled && <Field label="Step" value={r.step ?? "—"} suffix="mm" />}
|
||
{wobbleEnabled && <Field label="Size" value={r.size ?? "—"} suffix="mm" />}
|
||
</div>
|
||
|
||
{/* Simple toggle */}
|
||
<div className="flex flex-wrap gap-6">
|
||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{(rec.raster_settings?.length ?? 0) > 0 && (
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Raster Settings</h2>
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
{rec.raster_settings!.map((r: any, i: number) => {
|
||
const isHalftone = r?.dither === "halftone";
|
||
return (
|
||
<div key={i} className="border rounded p-3 space-y-2">
|
||
<div className="font-medium">{r.name || `Raster ${i + 1}`}</div>
|
||
<div className="grid sm:grid-cols-2 gap-2">
|
||
<Field label="Type" value={TYPE_LABEL[r.type] || "—"} />
|
||
<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 ?? "—"} suffix="mm" />
|
||
<Field label="Pass" value={r.pass ?? "—"} />
|
||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||
<Field label="Pulse" value={r.pulse ?? "—"} suffix="ns" />
|
||
{isHalftone && <Field label="Halftone Cell" value={r.halftone_cell ?? "—"} />}
|
||
{isHalftone && <Field label="Halftone Angle" value={r.halftone_angle ?? "—"} />}
|
||
<Field label="Dot Width Adjustment" value={r.dot ?? "—"} suffix="mm" />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-6">
|
||
<Field label="Crosshatch" value={yesNo(r.cross)} />
|
||
<Field label="Inverted" value={yesNo(r.inversion)} />
|
||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|