From 0cf4661264e417d6b473986d0195ec4c60248702 Mon Sep 17 00:00:00 2001 From: makearmy Date: Mon, 29 Sep 2025 15:34:49 -0400 Subject: [PATCH] added v2 RigSwitcher and Builder --- app/api/rigs/route.ts | 115 +++++++++++ app/rigs/RigBuilderClient.tsx | 309 ++++++++++++++++++----------- app/rigs/RigsListClient.tsx | 150 ++++---------- app/rigs/page.tsx | 15 +- components/portal/RigsSwitcher.tsx | 13 +- 5 files changed, 352 insertions(+), 250 deletions(-) create mode 100644 app/api/rigs/route.ts diff --git a/app/api/rigs/route.ts b/app/api/rigs/route.ts new file mode 100644 index 00000000..6e1f82b3 --- /dev/null +++ b/app/api/rigs/route.ts @@ -0,0 +1,115 @@ +// app/api/my/rigs/route.ts +import { NextResponse } from "next/server"; +import { dxGET, dxPOST, dxDELETE } from "@/lib/directus"; +import { requireBearer } from "@/app/api/_lib/auth"; + +export const runtime = "nodejs"; + +function bad(msg: string, code = 400) { + return NextResponse.json({ error: msg }, { status: code }); +} + +export async function GET(req: Request) { + try { + const bearer = requireBearer(req); + const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer); + const q = new URL(req.url).searchParams; + const limit = Math.min(parseInt(q.get("limit") || "50", 10), 100); + const fields = [ + "id", + "name", + "notes", + "rig_type.id", + "rig_type.name", + "laser_source.submission_id", + "laser_source.make", + "laser_source.model", + "laser_scan_lens.id", + "laser_scan_lens.field_size", + "laser_scan_lens.focal_length", + "laser_focus_lens.id", + "laser_focus_lens.name", + "laser_software.id", + "laser_software.name", + "date_created", + "date_updated", + ].join(","); + + const path = + `/items/user_rigs?filter[owner][_eq]=${encodeURIComponent(me.id)}` + + `&fields=${encodeURIComponent(fields)}&sort=-date_updated&limit=${limit}`; + + const res = await dxGET(path, bearer); + return NextResponse.json(res?.data ?? []); + } catch (e: any) { + return bad(e?.message || "Failed to load rigs", e?.status || 500); + } +} + +export async function POST(req: Request) { + try { + const bearer = requireBearer(req); + const body = await req.json().catch(() => ({})); + + const name = (body?.name || "").trim(); + const rig_type = body?.rig_type; // id + const laser_source = body?.laser_source; // submission_id + const laser_scan_lens = body?.laser_scan_lens || null; + const laser_focus_lens = body?.laser_focus_lens || null; + const laser_software = body?.laser_software || null; + const notes = (body?.notes || "").trim(); + + if (!name) return bad("Missing: name"); + if (!rig_type) return bad("Missing: rig_type"); + if (!laser_source) return bad("Missing: laser_source"); + + // Derive owner from auth’d user; ignore any spoofed owner in body + const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer); + + // Only set one lens m2o depending on rig type on the client; server tolerates both null. + const payload: any = { + owner: me.id, + name, + rig_type, + laser_source, + laser_scan_lens, + laser_focus_lens, + laser_software, + notes, + }; + + const res = await dxPOST<{ data: { id: string } }>( + "/items/user_rigs", + bearer, + payload + ); + + return NextResponse.json({ ok: true, id: String(res?.data?.id) }); + } catch (e: any) { + return bad(e?.message || "Failed to create rig", e?.status || 500); + } +} + +export async function DELETE(req: Request) { + try { + const bearer = requireBearer(req); + const url = new URL(req.url); + const id = url.searchParams.get("id"); + if (!id) return bad("Missing: id"); + + // Hard-guard: ensure the rig belongs to the current user + const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer); + const rig = await dxGET( + `/items/user_rigs/${encodeURIComponent(id)}?fields=id,owner`, + bearer + ); + if (!rig || String(rig.owner) !== String(me.id)) { + return bad("Not your rig", 403); + } + + await dxDELETE(`/items/user_rigs/${encodeURIComponent(id)}`, bearer); + return NextResponse.json({ ok: true }); + } catch (e: any) { + return bad(e?.message || "Failed to delete rig", e?.status || 500); + } +} diff --git a/app/rigs/RigBuilderClient.tsx b/app/rigs/RigBuilderClient.tsx index 70b4d855..bd0d8f02 100644 --- a/app/rigs/RigBuilderClient.tsx +++ b/app/rigs/RigBuilderClient.tsx @@ -1,140 +1,215 @@ -// app/rigs/RigsListClient.tsx "use client"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { useToast } from "@/hooks/use-toast"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; -type RigRow = { - id: number | string; - name: string; - rig_type_name?: string | null; -}; +type Opt = { id: string | number; label: string }; -async function apiJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { - Accept: "application/json", - ...(init?.headers || {}), - }, - credentials: "include", - cache: "no-store", - }); +const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); - const txt = await res.text().catch(() => ""); - if (res.ok) { - try { - return (txt ? JSON.parse(txt) : ({} as any)) as T; - } catch { - return {} as T; - } - } +function useOptions(kind: "user_rig_type" | "laser_software" | "laser_source" | "lens", targetKey?: string) { + const [opts, setOpts] = useState([]); + const [loading, setLoading] = useState(false); - let body: any = null; - try { body = txt ? JSON.parse(txt) : null; } catch {} - const message = body?.error || body?.message || txt || `HTTP ${res.status} for ${url}`; + useEffect(() => { + let alive = true; + setLoading(true); - // If unauthorized, send to sign-in (new flow always lands on /portal) - if (res.status === 401 && typeof window !== "undefined") { - window.location.assign("/auth/sign-in"); - } + (async () => { + let url = ""; + let normalize = (rows: any[]): Opt[] => rows.map(r => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.label ?? r.title ?? r.model ?? r.id) })); + if (kind === "user_rig_type") { + url = `${API}/items/user_rig_type?fields=id,name&sort=sort`; + } else if (kind === "laser_software") { + url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; + } else if (kind === "laser_source") { + // fetch all sources; client filter by nm band from targetKey + url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; + const parseNum = (v: any) => { + if (v == null) return null; + const m = String(v).match(/(\d+(\.\d+)?)/); + return m ? Number(m[1]) : null; + }; + const nmRange = (t?: string | null): [number, number] | null => { + if (!t) return null; + const s = t.toLowerCase(); + if (s.includes("fiber")) return [1000, 9000]; + if (s.includes("uv")) return [300, 400]; + if (s.includes("gantry") || s.includes("co2 gantry") || s.includes("co₂ gantry")) return [10000, 11000]; + if (s.includes("galvo") || s.includes("co2 galvo") || s.includes("co₂ galvo")) return [10000, 11000]; + return null; + }; + const range = nmRange(targetKey); + normalize = (rows) => { + const filtered = range + ? rows.filter((r: any) => { + const nm = parseNum(r.nm); + return nm != null && nm >= range[0] && nm <= range[1]; + }) + : rows; + return filtered.map((r: any) => ({ + id: String(r.submission_id), + label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), + })); + }; + } else if (kind === "lens") { + if (targetKey && targetKey.toLowerCase().includes("gantry")) { + url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000&sort=name`; + } else { + url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`; + normalize = (rows) => { + const toNum = (v: any) => { + const m = String(v ?? "").match(/-?\d+(\.\d+)?/); + return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY; + }; + return [...rows] + .sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length)) + .map((r) => ({ + id: String(r.id), + label: [r.field_size && `${r.field_size} mm`, r.focal_length && `${r.focal_length} mm`].filter(Boolean).join(" — ") || String(r.id), + })); + }; + } + } - const err: any = new Error(message); - err.status = res.status; - err.body = body ?? txt; - throw err; + const res = await fetch(url, { credentials: "include", cache: "no-store" }); + const json = await res.json(); + const rows = json?.data ?? []; + const mapped = normalize(rows); + if (alive) setOpts(mapped); + })() + .catch(() => alive && setOpts([])) + .finally(() => alive && setLoading(false)); + + return () => { + alive = false; + }; + }, [kind, targetKey]); + + return { opts, loading }; } -export default function RigsListClient() { - const { toast } = useToast(); - const [rigs, setRigs] = useState(null); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(null); - const [deleting, setDeleting] = useState>({}); +export default function RigBuilderClient() { + const router = useRouter(); + const [name, setName] = useState(""); + const [notes, setNotes] = useState(""); + const [rigType, setRigType] = useState(""); + const [laserSource, setLaserSource] = useState(""); + const [scanLens, setScanLens] = useState(""); + const [focusLens, setFocusLens] = useState(""); + const [software, setSoftware] = useState(""); - // Load all rigs for the current user - useEffect(() => { - (async () => { - try { - setLoading(true); - setErr(null); - const j = await apiJson<{ data: RigRow[] }>("/api/my/rigs"); - setRigs(j?.data ?? []); - } catch (e: any) { - setErr(e?.message || "Failed to load rigs"); - } finally { - setLoading(false); - } - })(); - }, []); + const rigTypes = useOptions("user_rig_type"); + const targetKey = useMemo(() => { + const rt = rigTypes.opts.find(o => String(o.id) === String(rigType))?.label || ""; + return rt; + }, [rigTypes.opts, rigType]); - async function onDelete(id: string | number) { - if (!confirm("Delete this rig?")) return; - try { - setDeleting((d) => ({ ...d, [id]: true })); - await apiJson(`/api/my/rigs/${id}`, { method: "DELETE" }); - setRigs((prev) => (prev ? prev.filter((r) => r.id !== id) : prev)); - toast({ title: "Rig deleted" }); - } catch (e: any) { - toast({ - title: "Delete failed", - description: e?.message || "Could not delete rig.", - variant: "destructive", - }); - } finally { - setDeleting((d) => { - const copy = { ...d }; - delete copy[id]; - return copy; - }); + const sources = useOptions("laser_source", targetKey); + const lens = useOptions("lens", targetKey); + const softwares = useOptions("laser_software"); + + const isGantry = (targetKey || "").toLowerCase().includes("gantry"); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const body: any = { + name, + rig_type: rigType ? Number(rigType) : null, + laser_source: laserSource ? Number(laserSource) : null, + notes: notes || "", + laser_software: software ? Number(software) : null, + }; + if (isGantry) { + body.laser_focus_lens = focusLens ? Number(focusLens) : null; + body.laser_scan_lens = null; + } else { + body.laser_scan_lens = scanLens ? Number(scanLens) : null; + body.laser_focus_lens = null; } - } - if (loading) { - return ( -
- Loading your rigs… -
- ); - } - - if (err) { - return ( -
- {err} -
- ); + const res = await fetch("/api/my/rigs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + alert(j?.error || "Failed to create rig"); + return; + } + // Go back to list tab + router.replace("/portal/rigs?t=my", { scroll: false }); + router.refresh(); } return ( -
-
- {(!rigs || rigs.length === 0) && ( -
No rigs yet.
+
+
+ + setName(e.target.value)} required /> +
+ +
+
+ + +
+ +
+ + +
+
+ + {/* Lens (focus for gantry, scan for others) */} + {isGantry ? ( +
+ + +
+ ) : ( +
+ + +
)} - {rigs?.map((r) => ( -
-
-
{r.name}
- {r.rig_type_name && ( - {r.rig_type_name} - )} -
+
+ + +
- -
- ))} -
+
+ +