418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useForm, type UseFormRegister } from "react-hook-form";
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Types
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
type RigKind = "fiber" | "uv" | "co2_galvo" | "co2_gantry";
|
||
|
||
type Rig = {
|
||
id: string;
|
||
name: string;
|
||
kind: RigKind;
|
||
|
||
// core components
|
||
source?: { id: string; label: string } | null;
|
||
scan_lens?: { id: string; label: string } | null; // fiber/uv/co2_galvo
|
||
focus_lens?: { id: string; label: string } | null; // co2_gantry
|
||
scan_aperture?: { id: string; label: string } | null; // laser_scan_lens_apt
|
||
beam_expander?: { id: string; label: string } | null; // laser_scan_lens_exp
|
||
software?: { id: string; label: string } | null;
|
||
|
||
created_at: number;
|
||
updated_at: number;
|
||
};
|
||
|
||
type Opt = { id: string; label: string };
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
const STORAGE_KEY = "makearmy_rigs_v1";
|
||
|
||
const TARGET_FOR_KIND: Record<RigKind, "settings_fiber" | "settings_uv" | "settings_co2gal" | "settings_co2gan"> = {
|
||
fiber: "settings_fiber",
|
||
uv: "settings_uv",
|
||
co2_galvo: "settings_co2gal",
|
||
co2_gantry: "settings_co2gan",
|
||
};
|
||
|
||
function uuid() {
|
||
// fine for local client IDs
|
||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||
}
|
||
|
||
function loadRigs(): Rig[] {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
return raw ? (JSON.parse(raw) as Rig[]) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveRigs(rigs: Rig[]) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(rigs));
|
||
}
|
||
|
||
function useOptions(path: string) {
|
||
const [opts, setOpts] = useState<Opt[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [q, setQ] = useState("");
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
async function run() {
|
||
setLoading(true);
|
||
try {
|
||
const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`;
|
||
const res = await fetch(url, { cache: "no-store" });
|
||
const json = await res.json();
|
||
if (!alive) return;
|
||
setOpts((json?.data as Opt[]) ?? []);
|
||
} catch {
|
||
if (!alive) return;
|
||
setOpts([]);
|
||
} finally {
|
||
if (!alive) return;
|
||
setLoading(false);
|
||
}
|
||
}
|
||
run();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [path, q]);
|
||
|
||
return { opts, loading, setQ };
|
||
}
|
||
|
||
// Reusable UI bits
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<section className="border rounded-lg p-4 space-y-3 bg-background">
|
||
<h2 className="text-lg font-semibold">{title}</h2>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function FilterableSelect({
|
||
label,
|
||
name,
|
||
register,
|
||
options,
|
||
loading,
|
||
onQuery,
|
||
placeholder = "—",
|
||
disabled,
|
||
}: {
|
||
label: string;
|
||
name: string;
|
||
register: UseFormRegister<any>;
|
||
options: Opt[];
|
||
loading?: boolean;
|
||
onQuery?: (q: string) => void;
|
||
placeholder?: string;
|
||
disabled?: boolean;
|
||
}) {
|
||
const [filter, setFilter] = useState("");
|
||
useEffect(() => {
|
||
onQuery?.(filter);
|
||
}, [filter, onQuery]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!filter) return options;
|
||
const f = filter.toLowerCase();
|
||
return options.filter((o) => o.label.toLowerCase().includes(f));
|
||
}, [options, filter]);
|
||
|
||
return (
|
||
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
|
||
<label className="block text-sm mb-1">{label}</label>
|
||
<input
|
||
className="w-full border rounded px-2 py-1 mb-1"
|
||
placeholder="Type to filter…"
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
disabled={disabled}
|
||
/>
|
||
<select className="w-full border rounded px-2 py-1" {...register(name)} disabled={disabled}>
|
||
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
|
||
{filtered.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Page
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
export default function MyRigsPage() {
|
||
const [rigs, setRigs] = useState<Rig[]>([]);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
|
||
// form
|
||
const { register, handleSubmit, watch, reset, setValue, formState: { isSubmitting } } = useForm<any>({
|
||
defaultValues: {
|
||
name: "",
|
||
kind: "fiber" as RigKind,
|
||
source: "",
|
||
scan_lens: "",
|
||
focus_lens: "",
|
||
scan_aperture: "",
|
||
beam_expander: "",
|
||
software: "",
|
||
},
|
||
});
|
||
|
||
// load saved rigs
|
||
useEffect(() => {
|
||
setRigs(loadRigs());
|
||
}, []);
|
||
|
||
// kind → target (for filtering laser source & lens APIs)
|
||
const kind = watch("kind") as RigKind;
|
||
const target = TARGET_FOR_KIND[kind];
|
||
|
||
const isGalvo = kind === "fiber" || kind === "uv" || kind === "co2_galvo";
|
||
const isGantry = kind === "co2_gantry";
|
||
|
||
// options
|
||
const sourceOpts = useOptions(`laser_source?target=${target}`);
|
||
const lensOpts = useOptions(`lens?target=${target}`); // scan lens for galvo, focus lens for gantry
|
||
const softOpts = useOptions("laser_software");
|
||
const aptOpts = useOptions("laser_scan_lens_apt");
|
||
const expOpts = useOptions("laser_scan_lens_exp");
|
||
|
||
// helpers to resolve id→label from fetched option lists
|
||
const findLabel = (id: string | undefined, opts: Opt[]) =>
|
||
id ? (opts.find((o) => o.id === id)?.label || id) : "";
|
||
|
||
function onEdit(rig: Rig) {
|
||
setEditingId(rig.id);
|
||
reset({
|
||
name: rig.name,
|
||
kind: rig.kind,
|
||
source: rig.source?.id || "",
|
||
scan_lens: rig.scan_lens?.id || "",
|
||
focus_lens: rig.focus_lens?.id || "",
|
||
scan_aperture: rig.scan_aperture?.id || "",
|
||
beam_expander: rig.beam_expander?.id || "",
|
||
software: rig.software?.id || "",
|
||
});
|
||
}
|
||
|
||
function onDelete(id: string) {
|
||
const next = rigs.filter((r) => r.id !== id);
|
||
setRigs(next);
|
||
saveRigs(next);
|
||
if (editingId === id) {
|
||
setEditingId(null);
|
||
reset();
|
||
}
|
||
}
|
||
|
||
function onClearForm() {
|
||
setEditingId(null);
|
||
reset({
|
||
name: "",
|
||
kind: "fiber",
|
||
source: "",
|
||
scan_lens: "",
|
||
focus_lens: "",
|
||
scan_aperture: "",
|
||
beam_expander: "",
|
||
software: "",
|
||
});
|
||
}
|
||
|
||
const onSubmit = handleSubmit((vals) => {
|
||
// basic validation
|
||
if (!vals.name?.trim()) {
|
||
alert("Please provide a name for your Rig.");
|
||
return;
|
||
}
|
||
if (!vals.source) {
|
||
alert("Please select a Laser Source.");
|
||
return;
|
||
}
|
||
if (isGalvo && !vals.scan_lens) {
|
||
alert("Please select a Scan Lens.");
|
||
return;
|
||
}
|
||
if (isGantry && !vals.focus_lens) {
|
||
alert("Please select a Focus Lens.");
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const rig: Rig = {
|
||
id: editingId || uuid(),
|
||
name: String(vals.name).trim(),
|
||
kind: vals.kind as RigKind,
|
||
source: vals.source ? { id: vals.source, label: findLabel(vals.source, sourceOpts.opts) } : null,
|
||
scan_lens: vals.scan_lens ? { id: vals.scan_lens, label: findLabel(vals.scan_lens, lensOpts.opts) } : null,
|
||
focus_lens: vals.focus_lens ? { id: vals.focus_lens, label: findLabel(vals.focus_lens, lensOpts.opts) } : null,
|
||
scan_aperture: vals.scan_aperture ? { id: vals.scan_aperture, label: findLabel(vals.scan_aperture, aptOpts.opts) } : null,
|
||
beam_expander: vals.beam_expander ? { id: vals.beam_expander, label: findLabel(vals.beam_expander, expOpts.opts) } : null,
|
||
software: vals.software ? { id: vals.software, label: findLabel(vals.software, softOpts.opts) } : null,
|
||
created_at: editingId ? rigs.find((r) => r.id === editingId)?.created_at ?? now : now,
|
||
updated_at: now,
|
||
};
|
||
|
||
const next = editingId
|
||
? rigs.map((r) => (r.id === editingId ? rig : r))
|
||
: [rig, ...rigs];
|
||
|
||
setRigs(next);
|
||
saveRigs(next);
|
||
setEditingId(null);
|
||
reset();
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto p-4 space-y-6">
|
||
<h1 className="text-2xl font-bold">My Rigs</h1>
|
||
|
||
<Section title={editingId ? "Edit Rig" : "Create a Rig"}>
|
||
<form onSubmit={onSubmit} className="space-y-4">
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm mb-1">Rig Name *</label>
|
||
<input
|
||
className="w-full border rounded px-2 py-1"
|
||
placeholder="e.g., Fiber + 110×110mm (F160)"
|
||
{...register("name")}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm mb-1">Rig Type *</label>
|
||
<select className="w-full border rounded px-2 py-1" {...register("kind")}>
|
||
<option value="fiber">Fiber (Galvo)</option>
|
||
<option value="uv">UV (Galvo)</option>
|
||
<option value="co2_galvo">CO₂ (Galvo)</option>
|
||
<option value="co2_gantry">CO₂ (Gantry)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
<FilterableSelect
|
||
label="Laser Source *"
|
||
name="source"
|
||
register={register}
|
||
options={sourceOpts.opts}
|
||
loading={sourceOpts.loading}
|
||
onQuery={sourceOpts.setQ}
|
||
/>
|
||
|
||
<FilterableSelect
|
||
label={isGantry ? "Focus Lens *" : "Scan Lens *"}
|
||
name={isGantry ? "focus_lens" : "scan_lens"}
|
||
register={register}
|
||
options={lensOpts.opts}
|
||
loading={lensOpts.loading}
|
||
onQuery={lensOpts.setQ}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-3 gap-3">
|
||
<FilterableSelect
|
||
label="Scan Head Aperture"
|
||
name="scan_aperture"
|
||
register={register}
|
||
options={aptOpts.opts}
|
||
loading={aptOpts.loading}
|
||
onQuery={aptOpts.setQ}
|
||
disabled={!isGalvo}
|
||
placeholder={isGalvo ? "—" : "Not applicable"}
|
||
/>
|
||
<FilterableSelect
|
||
label="Beam Expander"
|
||
name="beam_expander"
|
||
register={register}
|
||
options={expOpts.opts}
|
||
loading={expOpts.loading}
|
||
onQuery={expOpts.setQ}
|
||
disabled={!isGalvo}
|
||
placeholder={isGalvo ? "—" : "Not applicable"}
|
||
/>
|
||
<FilterableSelect
|
||
label="Software"
|
||
name="software"
|
||
register={register}
|
||
options={softOpts.opts}
|
||
loading={softOpts.loading}
|
||
onQuery={softOpts.setQ}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{editingId ? "Save Changes" : "Save Rig"}
|
||
</button>
|
||
<button type="button" className="px-3 py-2 border rounded" onClick={onClearForm}>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Section>
|
||
|
||
<Section title="Saved Rigs">
|
||
{rigs.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No rigs yet. Create one above.</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{rigs.map((r) => (
|
||
<li key={r.id} className="border rounded p-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-medium">{r.name}</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
Type: {r.kind.replace("_", " ")}
|
||
</div>
|
||
<div className="text-xs mt-1 space-y-0.5">
|
||
<div>Source: {r.source?.label || "—"}</div>
|
||
{r.scan_lens && <div>Scan Lens: {r.scan_lens.label}</div>}
|
||
{r.focus_lens && <div>Focus Lens: {r.focus_lens.label}</div>}
|
||
{r.scan_aperture && <div>Scan Head Aperture: {r.scan_aperture.label}</div>}
|
||
{r.beam_expander && <div>Beam Expander: {r.beam_expander.label}</div>}
|
||
{r.software && <div>Software: {r.software.label}</div>}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button className="px-2 py-1 border rounded" onClick={() => onEdit(r)}>
|
||
Edit
|
||
</button>
|
||
<button className="px-2 py-1 border rounded" onClick={() => onDelete(r.id)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</Section>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
Rigs are currently saved locally in your browser. We can switch this to Directus-backed storage once
|
||
auth is in place; the UI can stay the same.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|