makearmy-app/components/details/CO2GalvoDetail.tsx
2025-10-06 21:36:55 -04:00

509 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 &amp; 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>
);
}