makearmy-app/app/my-rigs/page.tsx

418 lines
14 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.

"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>
);
}