From 59b26635d9e7844757efbf346f5916c55b6c63de Mon Sep 17 00:00:00 2001 From: makearmy Date: Tue, 30 Sep 2025 00:07:43 -0400 Subject: [PATCH] rigbuilder fix + laser source 403 fix --- .../{lasers.tsx => LaserDetailsClient.tsx} | 0 app/lasers/[id]/page.tsx | 83 ++++- app/lasers/page.tsx | 316 +++------------- app/rigs/RigBuilderClient.tsx | 350 +++++++++++++----- 4 files changed, 379 insertions(+), 370 deletions(-) rename app/lasers/[id]/{lasers.tsx => LaserDetailsClient.tsx} (100%) diff --git a/app/lasers/[id]/lasers.tsx b/app/lasers/[id]/LaserDetailsClient.tsx similarity index 100% rename from app/lasers/[id]/lasers.tsx rename to app/lasers/[id]/LaserDetailsClient.tsx diff --git a/app/lasers/[id]/page.tsx b/app/lasers/[id]/page.tsx index 3cea35eb..25fc2ea8 100644 --- a/app/lasers/[id]/page.tsx +++ b/app/lasers/[id]/page.tsx @@ -1,2 +1,83 @@ // app/lasers/[id]/page.tsx -export { default } from "./lasers"; +import { cookies } from "next/headers"; +import { redirect, notFound } from "next/navigation"; +import { dxGET } from "@/lib/directus"; +import LaserDetailsClient from "./LaserDetailsClient"; + +const FIELD_GROUPS = [ + { + title: "General Information", + fields: { + make: "Make", + model: "Model", + op: "Pulse Operation Mode", + notes: "Notes", + }, + }, +{ + title: "Optical Specifications", + fields: { + w: "Laser Wattage (W)", + mj: "milliJoule Max (mJ)", + nm: "Wavelength (nm)", + k_hz: "Pulse Repetition Rate (kHz)", + ns: "Pulse Width (ns)", + d: "Beam Diameter (mm)", + m2: "M² - Quality", + instability: "Instability", + polarization: "Polarization", + band: "Band (nm)", + anti: "Anti-Reflection Coating", + mw: "Red Dot Wattage (mW)", + }, +}, +{ + title: "Electrical & Timing", + fields: { + v: "Operating Voltage (V)", + temp_op: "Operating Temperature (°C)", + temp_store: "Storage Temperature (°C)", + l_on: "l_on", + l_off: "l_off", + mj_c: "mj_c", + ns_c: "ns_c", + d_c: "d_c", + on_c: "on_c", + off_c: "off_c", + }, +}, +{ + title: "Integration & Physical", + fields: { + cable: "Cable Length (m)", + cooling: "Cooling Method", + weight: "Weight (kg)", + dimensions: "Dimensions (cm)", + }, +}, +] as const; + +const FIELD_KEYS = Array.from( + new Set(["make", "model", ...FIELD_GROUPS.flatMap((g) => Object.keys(g.fields))]) +); + +export default async function Page({ params }: { params: { id: string } }) { + const token = cookies().get("ma_at")?.value; + if (!token) { + redirect(`/auth/sign-in?next=${encodeURIComponent(`/lasers/${params.id}`)}`); + } + const bearer = `Bearer ${token}`; + const fields = encodeURIComponent(FIELD_KEYS.join(",")); + const submissionId = params.id; // CONFIRMED: primary key is submission_id + + // Fetch by item endpoint using submission_id as PK + const res = await dxGET( + `/items/laser_source/${encodeURIComponent(submissionId)}?fields=${fields}`, + bearer + ); + const row = res?.data ?? res ?? null; + + if (!row) notFound(); + + return ; +} diff --git a/app/lasers/page.tsx b/app/lasers/page.tsx index 32a39a69..eb689922 100644 --- a/app/lasers/page.tsx +++ b/app/lasers/page.tsx @@ -1,288 +1,58 @@ -'use client'; +"use client"; -import { useEffect, useState, useMemo } from 'react'; -import Link from 'next/link'; +import Link from "next/link"; -type LaserRow = { - id: string | number; - submission_id?: string | number; - make?: string; - model?: string; - w?: string; - mj?: string; - nm?: string; - kHz?: string; - ns?: string; - v?: string; - // Directus returns `op` as a string for an “options” field. - // We also defensively support an object with {label|name} in case of custom responses. - op?: { label?: string; name?: string } | string | null; +type Group = { title: string; fields: Record }; +type Laser = Record; + +const CHOICE_LABELS: Record> = { + op: { pm: "MOPA", pq: "Q-Switch" }, + cooling: { aa: "Air, Active", ap: "Air, Passive", w: "Water" }, }; -export default function LaserSourcesPage() { - const [sources, setSources] = useState([]); - const [query, setQuery] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); - const [wavelengthFilters, setWavelengthFilters] = useState>({}); - const [sortKey, setSortKey] = useState('model'); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); - - // canonical href builder (prefers submission_id if present) - const detailHref = (row: LaserRow) => `/lasers/${row.submission_id ?? row.id}`; - - useEffect(() => { - const timer = setTimeout(() => setDebouncedQuery(query), 300); - return () => clearTimeout(timer); - }, [query]); - - useEffect(() => { - // Request everything; `op` will come back as a simple string for an “options” field. - fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/laser_source?limit=-1&fields=*`, { - cache: 'no-store', - }) - .then((res) => res.json()) - .then((data) => setSources(data?.data || [])); - }, []); - - const highlightMatch = (text?: string, q?: string) => { - const safeText = String(text ?? ''); - const query = String(q ?? ''); - if (!query) return safeText; - const parts = safeText.split(new RegExp(`(${query})`, 'gi')); - return parts.map((part, i) => - part.toLowerCase() === query.toLowerCase() ? {part} : {part} - ); - }; - - // Render OP as a string safely (supports string or {label|name}) - const opText = (row: LaserRow) => { - const v = row.op as any; - if (v && typeof v === 'object') { - return String(v.label ?? v.name ?? '—'); - } - return v == null || v === '' ? '—' : String(v); - }; - - const filtered = useMemo(() => { - const q = debouncedQuery.toLowerCase(); - return sources.filter((src) => { - const matchesQuery = [src.make, src.model] - .filter(Boolean) - .some((field) => String(field).toLowerCase().includes(q)); - return matchesQuery; - }); - }, [sources, debouncedQuery]); - - const grouped = useMemo>(() => { - return filtered.reduce((acc, src) => { - const key = src.make || 'Unknown Make'; - (acc[key] = acc[key] || []).push(src); - return acc; - }, {} as Record); - }, [filtered]); - - const wavelengths = [10600, 1064, 455, 355]; - - const toggleFilter = (make: string, value: number) => { - setWavelengthFilters((prev) => ({ - ...prev, - [make]: prev[make] === value ? null : value, - })); - }; - - const toggleSort = (key: keyof LaserRow | 'op' | 'model') => { - setSortKey(key); - setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); - }; - - const getSortableValue = (row: LaserRow, key: keyof LaserRow | 'op' | 'model') => { - const val = key === 'op' ? opText(row) : (row as any)[key]; - if (val == null) return ''; - const k = String(key).toLowerCase(); - - if (k === 'w') return parseFloat(String(val).replace(/[^\d.]/g, '')) || 0; - if (['mj', 'nm', 'khz', 'ns', 'v'].includes(k)) return parseFloat(String(val)) || 0; - - return String(val).toLowerCase(); - }; - - const sortArrow = (key: keyof LaserRow | 'op' | 'model') => - sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : ''; - - const summaryStats = useMemo(() => { - const makes = new Set(); - const nmCounts: Record = {}; - for (const src of sources) { - if (src.make) makes.add(src.make); - if (src.nm) { - const nm = String(src.nm); - nmCounts[nm] = (nmCounts[nm] || 0) + 1; - } - } - const mostCommonNm = - Object.entries(nmCounts).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || '—'; - return { - total: sources.length, - uniqueMakes: makes.size, - commonNm: mostCommonNm, - }; - }, [sources]); - - const recentSources = useMemo(() => { - return [...sources] - .filter((src) => src.submission_id != null) - .sort((a, b) => Number(b.submission_id) - Number(a.submission_id)) - .slice(0, 5); - }, [sources]); +function resolveLabel(field: string, value: any) { + if (value == null || value === "") return "—"; + const map = CHOICE_LABELS[field]; + if (map && typeof value === "string" && map[value]) return map[value]; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : "—"; + return String(value); +} +export default function LaserDetailsClient({ + laser, + fieldGroups, +}: { + laser: Laser; + fieldGroups: Group[]; +}) { return ( -
-
-
-

Database Summary

-

Total Sources: {summaryStats.total}

-

Unique Makes: {summaryStats.uniqueMakes}

-

Most Common Wavelength: {summaryStats.commonNm}

-
+
+

+ {(laser.make as string) || "—"} {laser.model || ""} +

-
-

Recent Additions

-
    - {recentSources.map((src) => ( -
  • - - {src.make} {src.model} - -
  • +
    + {fieldGroups.map(({ title, fields }) => ( +
    +

    {title}

    +
    + {Object.entries(fields).map(([key, label]) => ( +
    +
    {label}
    +
    {resolveLabel(key, laser[key])}
    +
    + ))} +
    +
    ))} -
-
-

Feedback

-

See something wrong or want to suggest an improvement?

- - Submit Feedback +
+ + ← Back to Laser Sources
- -
-

Laser Source Database

- setQuery(e.target.value)} - placeholder="Search by make or model..." - className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2" - /> -

- Browse laser source specifications collected from community-submitted and verified sources. -

-
- - {Object.entries(grouped).length === 0 ? ( -

No laser sources found.

- ) : ( -
- {Object.entries(grouped).map(([make, items]) => { - const filteredItems = - wavelengthFilters[make] != null - ? items.filter((item) => Number(item.nm) === wavelengthFilters[make]) - : items; - - const sortedItems = [...filteredItems].sort((a, b) => { - const aVal = getSortableValue(a, sortKey); - const bVal = getSortableValue(b, sortKey); - if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1; - if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - - return ( -
- - - {make} ({filteredItems.length}) - -
- {[10600, 1064, 455, 355].map((w) => ( - - ))} -
-
-
- - - - - - - - - - - - - - - - {sortedItems.map((src) => ( - - - - - - - - - - - - ))} - -
Make - - - - - - - - - - - - - - - -
- {highlightMatch(src.make || '—', debouncedQuery)} - - - {highlightMatch(src.model || '—', debouncedQuery)} - - {src.w || '—'}{src.mj || '—'}{opText(src)}{src.nm || '—'}{src.kHz || '—'}{src.ns || '—'}{src.v || '—'}
-
-
- ); - })} -
- )} -
); } diff --git a/app/rigs/RigBuilderClient.tsx b/app/rigs/RigBuilderClient.tsx index 5e8da28b..1032e221 100644 --- a/app/rigs/RigBuilderClient.tsx +++ b/app/rigs/RigBuilderClient.tsx @@ -10,10 +10,10 @@ const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); // helper: render multipliers like "06x", "8x", "1.5x"; avoid double "x" function formatMultiplier(raw: any) { const s = String(raw ?? "").trim(); + if (!s) return "—"; if (/x$/i.test(s)) return s; // already has x // pure number? if (/^\d+(\.\d+)?$/.test(s)) { - // pad single-digit integers to 2 chars (e.g., 6 -> 06) if (/^\d$/.test(s)) return `0${s}x`; return `${s}x`; } @@ -27,13 +27,13 @@ function formatMultiplier(raw: any) { return `${s}x`; } +// tiny helper to ensure we always iterate an array +function toArray(v: any): T[] { + return Array.isArray(v) ? (v as T[]) : []; +} + function useOptions( - kind: - | "laser_software" - | "laser_source" - | "lens" - | "scan_lens_apt" - | "scan_lens_exp", + kind: "laser_software" | "laser_source" | "lens" | "scan_lens_apt" | "scan_lens_exp", targetKey?: string ) { const [opts, setOpts] = useState([]); @@ -44,94 +44,104 @@ function useOptions( setLoading(true); (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), - })); + try { + let url = ""; + // default normalize + 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 === "laser_software") { - url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; - } else if (kind === "laser_source") { - 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`; + 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 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 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 ?? ""), + })); + }; + } + } else if (kind === "scan_lens_apt") { + url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; + } else if (kind === "scan_lens_exp") { + url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; + normalize = (rows) => + rows.map((r: any) => ({ + id: String(r.id ?? ""), + label: formatMultiplier(r.name ?? r.label ?? r.id ?? ""), + })); } - } else if (kind === "scan_lens_apt") { - url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; - } else if (kind === "scan_lens_exp") { - url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; - normalize = (rows) => - rows.map((r: any) => ({ - id: String(r.id), - label: formatMultiplier(r.name ?? r.label ?? r.id), // add "x" - })); - } - 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)); + const res = await fetch(url, { credentials: "include", cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${kind}`); + const json = await res.json().catch(() => ({} as any)); + const rows = toArray(json?.data); + + let mapped: Opt[] = []; + try { + mapped = toArray(normalize(rows)); + } catch { + // Safe fallback: minimal id/label mapping to prevent render crashes + mapped = rows.map((r: any) => ({ + id: String(r?.id ?? r?.submission_id ?? ""), + label: String(r?.name ?? r?.label ?? r?.title ?? r?.model ?? r?.id ?? ""), + })); + } + + if (alive) setOpts(mapped); + } catch (_err) { + if (alive) setOpts([]); // swallow errors → empty list; form still renders + } finally { + if (alive) setLoading(false); + } + })(); return () => { alive = false; @@ -148,14 +158,14 @@ export default function RigBuilderClient({ rigTypes }: { rigTypes: Opt[] }) { const [rigType, setRigType] = useState(""); const [laserSource, setLaserSource] = useState(""); const [scanLens, setScanLens] = useState(""); - const [scanLensApt, setScanLensApt] = useState(""); - const [scanLensExp, setScanLensExp] = useState(""); + const [scanLensApt, setScanLensApt] = useState(""); // galvo-only + const [scanLensExp, setScanLensExp] = useState(""); // galvo-only const [focusLens, setFocusLens] = useState(""); const [software, setSoftware] = useState(""); + // rigTypes come from the SERVER as props. const targetKey = useMemo(() => { - const rt = - rigTypes.find((o) => String(o.id) === String(rigType))?.label || ""; + const rt = rigTypes.find((o) => String(o.id) === String(rigType))?.label || ""; return rt; }, [rigTypes, rigType]); @@ -205,10 +215,158 @@ export default function RigBuilderClient({ rigTypes }: { rigTypes: Opt[] }) { return (
- {/* ...existing fields... */} +
+ + setName(e.target.value)} + required + /> +
- {/* Lens sections unchanged, expander select now shows labels with trailing "x" */} - {/* (full component omitted for brevity—only the hook/normalize changed) */} +
+
+ + +
+ +
+ + +
+
+ + {/* Lens (focus for gantry, scan + apt/exp for others) */} + {isGantry ? ( +
+ + +
+ ) : ( + <> +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + )} + +
+ + +
+ +
+ +