From 3195ff5d74260504323a5342edc522ba3803ebe9 Mon Sep 17 00:00:00 2001 From: makearmy Date: Sun, 5 Oct 2025 17:45:09 -0400 Subject: [PATCH] settings overhaul and reset --- app/api/{submit => }/project/route.ts | 0 app/api/settings/route.ts | 306 +++++++ app/api/submit/settings/route.ts | 330 -------- components/details/CO2GalvoDetail.tsx | 567 ++++--------- components/forms/SettingsSubmit.tsx | 1082 +++++++++---------------- components/lists/CO2GalvoList.tsx | 270 ++---- 6 files changed, 904 insertions(+), 1651 deletions(-) rename app/api/{submit => }/project/route.ts (100%) create mode 100644 app/api/settings/route.ts delete mode 100644 app/api/submit/settings/route.ts diff --git a/app/api/submit/project/route.ts b/app/api/project/route.ts similarity index 100% rename from app/api/submit/project/route.ts rename to app/api/project/route.ts diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 00000000..469c37f3 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,306 @@ +// app/api/settings/route.ts +import { NextResponse } from "next/server"; + +/** + * Fresh, minimal Directus client (no external helpers). + * - Upload assets to /files with multipart/form-data. + * - Create and update records via /items/{collection}. + * - Auth is via user cookie (ma_at) or a submit token (DIRECTUS_TOKEN_SUBMIT). + */ + +export const runtime = "nodejs"; + +// ───────────────────────────────────────────────────────────── +// Env +// ───────────────────────────────────────────────────────────── +const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); +const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || ""; + +// Folder IDs from env (data sheet says fixed, not browsable) +const FOLDERS = { + settings_co2gal: { + photo: process.env.DX_FOLDER_GALVO_PHOTOS || "", + screen: process.env.DX_FOLDER_GALVO_SCREENS || "", + }, + settings_co2gan: { + photo: process.env.DX_FOLDER_GANTRY_PHOTOS || "", + screen: process.env.DX_FOLDER_GANTRY_SCREENS || "", + }, + settings_fiber: { + photo: process.env.DX_FOLDER_FIBER_PHOTOS || "", + screen: process.env.DX_FOLDER_FIBER_SCREENS || "", + }, + settings_uv: { + photo: process.env.DX_FOLDER_UV_PHOTOS || "", + screen: process.env.DX_FOLDER_UV_SCREENS || "", + }, +} as const; + +type Target = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv"; + +function bearerFrom(req: Request) { + // Prefer user cookie (session) else fall back to submit token for server ops. + const cookie = req.headers.get("cookie") || ""; + const m = cookie.match(/(?:^|;\s*)ma_at=([^;]+)/); + const at = m?.[1]; + return at ? `Bearer ${at}` : SUBMIT_TOKEN ? `Bearer ${SUBMIT_TOKEN}` : ""; +} + +async function dxUpload(file: File, folderId: string, bearer: string) { + const form = new FormData(); + form.set("file", file, file.name || "upload"); + if (folderId) form.set("folder", folderId); + + const res = await fetch(`${DX}/files`, { + method: "POST", + headers: bearer ? { authorization: bearer } : undefined, + body: form, + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = j?.errors?.[0]?.message || `Directus /files failed (HTTP ${res.status})`; + throw new Error(msg); + } + return j?.data?.id as string; +} + +async function dxCreate(target: Target, data: any, bearer: string) { + const res = await fetch(`${DX}/items/${target}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(bearer ? { authorization: bearer } : {}), + }, + body: JSON.stringify(data), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = j?.errors?.[0]?.message || `Directus create failed (HTTP ${res.status})`; + throw new Error(msg); + } + return j?.data; +} + +async function dxUpdate(target: Target, pk: string | number, data: any, bearer: string) { + const res = await fetch(`${DX}/items/${target}/${encodeURIComponent(String(pk))}`, { + method: "PATCH", + headers: { + "content-type": "application/json", + ...(bearer ? { authorization: bearer } : {}), + }, + body: JSON.stringify(data), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = j?.errors?.[0]?.message || `Directus update failed (HTTP ${res.status})`; + throw new Error(msg); + } + return j?.data; +} + +// Guard numeric +const num = (v: any) => (v === "" || v == null || Number.isNaN(Number(v)) ? null : Number(v)); +// Guard bool +const bool = (v: any) => !!v; +// Guard id string +const idOrNull = (v: any) => (v === "" || v == null ? null : String(v)); + +type ReadResult = { + mode: "json" | "multipart"; + body: any; + photoFile: File | null; + screenFile: File | null; +}; + +async function readJsonOrMultipart(req: Request): Promise { + const ct = (req.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("multipart/form-data")) { + const form = await (req as any).formData(); + const payloadRaw = String(form.get("payload") ?? "{}"); + let body: any = {}; + try { + body = JSON.parse(payloadRaw); + } catch { + throw new Error("Invalid JSON in 'payload'"); + } + const p = form.get("photo"); + const s = form.get("screen"); + return { + mode: "multipart", + body, + photoFile: p instanceof File && p.size > 0 ? (p as File) : null, + screenFile: s instanceof File && s.size > 0 ? (s as File) : null, + }; + } + const body = await (req as any).json().catch(() => ({})); + return { mode: "json", body, photoFile: null, screenFile: null }; +} + +/** + * POST: create or update a settings_* record + * Body (JSON or multipart with { payload }): + * { + * target: "settings_co2gal" | ..., + * mode?: "edit", + * submission_id?: string|number, + * // fields per data sheet (CO2 Galvo shown) + * setting_title: string (required), + * setting_notes?: string, + * photo?: string (asset id) // if not provided in create, require file in multipart + * screen?: string (asset id) // optional + * // Material & Rig / Optics + * mat: string (id), + * mat_coat: string (id), + * mat_color: string (id), + * mat_opacity: string (id), + * mat_thickness?: number, + * laser_soft: string (id), + * source: string (submission_id of laser_source), + * lens: string (id), + * focus?: number, + * // CO2 Galvo Options (part of Rig & Optics per sheet) + * lens_conf: string (id), + * lens_apt: string (id), + * lens_exp: string (id), + * repeat_all?: number, + * // Repeaters + * fill_settings?: Array<...>, + * line_settings?: Array<...>, + * raster_settings?: Array<...> + * } + */ +export async function POST(req: Request) { + try { + const { body, photoFile, screenFile } = await readJsonOrMultipart(req); + + const target = String(body?.target || "") as Target; + if (!target || !FOLDERS[target]) { + return NextResponse.json({ error: "Invalid or missing target." }, { status: 400 }); + } + + const isEdit = body?.mode === "edit"; + const pk = isEdit ? body?.submission_id : null; + + // Upload assets if files are present + const bearer = bearerFrom(req); + + const folderCfg = FOLDERS[target]; + let photoId = idOrNull(body.photo); + let screenId = idOrNull(body.screen); + + if (photoFile) { + if (!folderCfg.photo) throw new Error("Photo folder not configured."); + photoId = await dxUpload(photoFile, folderCfg.photo, bearer); + } + if (screenFile) { + if (!folderCfg.screen) throw new Error("Screen folder not configured."); + screenId = await dxUpload(screenFile, folderCfg.screen, bearer); + } + + // Enforce requireds (data sheet: title + result photo on create) + if (!body.setting_title || String(body.setting_title).trim() === "") { + return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 }); + } + if (!isEdit && !photoId) { + return NextResponse.json({ error: "Result photo is required." }, { status: 400 }); + } + + // Build Directus payload strictly as the collection expects (ids + arrays) + const payload: any = { + setting_title: String(body.setting_title), + setting_notes: String(body.setting_notes || ""), + // Assets + ...(photoId ? { photo: photoId } : {}), + ...(screenId ? { screen: screenId } : {}), + // Material/Rig & Optics (M2O ids) + mat: idOrNull(body.mat), + mat_coat: idOrNull(body.mat_coat), + mat_color: idOrNull(body.mat_color), + mat_opacity: idOrNull(body.mat_opacity), + mat_thickness: num(body.mat_thickness), + laser_soft: idOrNull(body.laser_soft), + source: idOrNull(body.source), // note: this is submission_id for laser_source; schema should be configured accordingly + lens: idOrNull(body.lens), + focus: num(body.focus), + // CO2 Galvo option triplet (Rig & Optics) + lens_conf: idOrNull(body.lens_conf), + lens_apt: idOrNull(body.lens_apt), + lens_exp: idOrNull(body.lens_exp), + repeat_all: num(body.repeat_all), + // Repeaters (arrays of plain objects) + fill_settings: Array.isArray(body.fill_settings) ? body.fill_settings.map(mapFill) : [], + line_settings: Array.isArray(body.line_settings) ? body.line_settings.map(mapLine) : [], + raster_settings: Array.isArray(body.raster_settings) ? body.raster_settings.map(mapRaster) : [], + }; + + let saved; + if (isEdit) { + if (!pk) return NextResponse.json({ error: "Missing submission_id for edit mode." }, { status: 400 }); + saved = await dxUpdate(target, pk, payload, bearer); + } else { + saved = await dxCreate(target, payload, bearer); + } + + return NextResponse.json({ id: saved?.submission_id ?? saved?.id ?? null, data: saved }, { status: 200 }); + } catch (e: any) { + return NextResponse.json({ error: e?.message || "Failed" }, { status: 500 }); + } +} + +// ───────────────────────────────────────────────────────────── +// Mappers (ensure numeric/bool normalization per sheet) +// ───────────────────────────────────────────────────────────── +function mapFill(r: any) { + return { + name: r?.name || "", + type: (r?.type || "").toString(), // uni|bi|offset + power: num(r?.power), + speed: num(r?.speed), + interval: num(r?.interval), + pass: num(r?.pass), + frequency: num(r?.frequency), + pulse: num(r?.pulse), + angle: num(r?.angle), + auto: bool(r?.auto), + increment: num(r?.increment), + cross: bool(r?.cross), + flood: bool(r?.flood), + air: bool(r?.air), + }; +} +function mapLine(r: any) { + return { + name: r?.name || "", + power: num(r?.power), + speed: num(r?.speed), + perf: bool(r?.perf), + cut: bool(r?.cut), + skip: bool(r?.skip), + pass: num(r?.pass), + air: bool(r?.air), + frequency: num(r?.frequency), + pulse: num(r?.pulse), + wobble: bool(r?.wobble), + step: num(r?.step), + size: num(r?.size), + }; +} +function mapRaster(r: any) { + return { + name: r?.name || "", + type: (r?.type || "").toString(), // uni|bi|offset + dither: (r?.dither || "").toString(), // threshold|ordered|... + halftone_cell: num(r?.halftone_cell), + halftone_angle: num(r?.halftone_angle), + inversion: bool(r?.inversion), + interval: num(r?.interval), + dot: num(r?.dot), + power: num(r?.power), + speed: num(r?.speed), + pass: num(r?.pass), + air: bool(r?.air), + frequency: num(r?.frequency), + pulse: num(r?.pulse), + cross: bool(r?.cross), + }; +} diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts deleted file mode 100644 index c5956e5b..00000000 --- a/app/api/submit/settings/route.ts +++ /dev/null @@ -1,330 +0,0 @@ -// app/api/submit/settings/route.ts -import { NextResponse } from "next/server"; -import { uploadFile, createSettingsItem, bytesFromMB, dxGET, dxPATCH } from "@/lib/directus"; -import { requireBearer } from "@/app/api/_lib/auth"; - -/** ───────────────────────────────────────────────────────────── - * Accepts EITHER: - * - application/json - * (photo/screen can be existing file ids on the body) - * - multipart/form-data with: - * - payload = JSON string (same shape as JSON body) - * - photo = File (required if no photo id present) ← create only - * - screen = File (optional) - * - * Targets (collections): - * - settings_fiber (+ laser_soft, repeat_all) - * - settings_co2gan - * - settings_co2gal - * - settings_uv - * - * Also supports editing: - * Body must include { mode: "edit", submission_id: string|number } - * We PATCH via filter[submission_id][_eq] and owner = current user. - * ──────────────────────────────────────────────────────────── */ - -export const runtime = "nodejs"; - -const MAX_MB = Number(process.env.FILE_MAX_MB || 25); -const MAX_BYTES = bytesFromMB(MAX_MB); - -// simple in-memory rate limiter -const BUCKET = new Map(); -const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW || 60) * 1000; -const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 15); -function rateLimitOk(ip: string) { - const now = Date.now(); - const rec = BUCKET.get(ip); - if (!rec || now > rec.resetAt) { - BUCKET.set(ip, { c: 1, resetAt: now + WINDOW_MS }); - return true; - } - if (rec.c >= MAX_REQ) return false; - rec.c += 1; - return true; -} - -type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; - -function num(v: any, fallback: number | null = null) { - if (v === "" || v == null) return fallback; - const n = Number(v); - return Number.isFinite(n) ? n : fallback; -} - -type ReadResult = { - mode: "json" | "multipart"; // transport mode, not create/edit - body: any; - photoFile: File | null; - screenFile: File | null; -}; - -async function readJsonOrMultipart(req: Request): Promise { - const ct = (req.headers.get("content-type") || "").toLowerCase(); - - if (ct.includes("multipart/form-data")) { - const form = await (req as any).formData(); - - const payloadRaw = String(form.get("payload") ?? "{}"); - let body: any = {}; - try { - body = JSON.parse(payloadRaw); - } catch { - throw new Error("Invalid JSON in 'payload'"); - } - - const p = form.get("photo"); - const s = form.get("screen"); - const photoFile = p instanceof File && p.size > 0 ? (p as File) : null; - const screenFile = s instanceof File && s.size > 0 ? (s as File) : null; - - return { mode: "multipart", body, photoFile, screenFile }; - } - - const body = await (req as any).json().catch(() => ({})); - return { mode: "json", body, photoFile: null, screenFile: null }; -} - -/** Env-based folder IDs (no folder browsing) */ -function folderIdFor( - target: Target, - kind: "photo" | "screen" | "notes" -): string | undefined { - const E = process.env; - const map: Record = { - settings_co2gal: { - photo: E.DX_FOLDER_GALVO_PHOTOS, - screen: E.DX_FOLDER_GALVO_SCREENS, - notes: E.DX_FOLDER_GALVO_NOTES, - }, - settings_co2gan: { - photo: E.DX_FOLDER_GANTRY_PHOTOS, - screen: E.DX_FOLDER_GANTRY_SCREENS, - notes: E.DX_FOLDER_GANTRY_NOTES, - }, - settings_fiber: { - photo: E.DX_FOLDER_FIBER_PHOTOS, - screen: E.DX_FOLDER_FIBER_SCREENS, - notes: E.DX_FOLDER_FIBER_NOTES, - }, - settings_uv: { - photo: E.DX_FOLDER_UV_PHOTOS, - screen: E.DX_FOLDER_UV_SCREENS, - notes: E.DX_FOLDER_UV_NOTES, - }, - }; - return map[target]?.[kind]; -} - -export async function POST(req: Request) { - const started = Date.now(); - try { - const ip = - (req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() as string) || - "0.0.0.0"; - if (!rateLimitOk(ip)) { - return NextResponse.json({ error: "Rate limited" }, { status: 429 }); - } - - // Enforce user auth (everything uses the user's token) - const bearer = requireBearer(req); - - const { body, photoFile, screenFile } = await readJsonOrMultipart(req); - - const target: Target = body?.target; - if ( - !target || - !["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"].includes(target) - ) { - return NextResponse.json({ error: "Invalid target" }, { status: 400 }); - } - - // Create vs Edit - const op: "create" | "edit" = body?.mode === "edit" ? "edit" : "create"; - - // Required basics - const setting_title = String(body?.setting_title || "").trim(); - if (!setting_title) { - return NextResponse.json( - { error: "Missing required: setting_title" }, - { status: 400 } - ); - } - - // Current user (handle both {data:{...}} and {...} shapes) - const meRes = await dxGET("/users/me?fields=id,username", bearer); - const meId = meRes?.data?.id ?? meRes?.id ?? null; - const meUsername = meRes?.data?.username ?? meRes?.username ?? null; - if (!meId) { - return NextResponse.json( - { error: "Unable to resolve current user." }, - { status: 401 } - ); - } - - // Attribution - const uploader = meUsername || "user"; // string field mirrors owner.username - - // Relations & numerics - const mat = body?.mat ?? null; - const mat_coat = body?.mat_coat ?? null; - const mat_color = body?.mat_color ?? null; - const mat_opacity = body?.mat_opacity ?? null; - const mat_thickness = num(body?.mat_thickness, null); - const source = body?.source ?? null; - const lens = body?.lens ?? null; - const focus = num(body?.focus, null); - const setting_notes = String(body?.setting_notes || "").trim(); - - // Shared string fields - const laser_soft = body?.laser_soft ?? null; // exact key: 'laser_soft' - const repeat_all = num(body?.repeat_all, null); // universally applicable - - // CO2 lens extras (may be null on non-co2) - const lens_conf = body?.lens_conf ?? null; - const lens_apt = body?.lens_apt ?? null; - const lens_exp = body?.lens_exp ?? null; - - // Upload / accept existing file ids - let photo_id: string | null = body?.photo ?? null; - let screen_id: string | null = body?.screen ?? null; - - // In CREATE mode: require a photo (either an id or a file upload) - if (op === "create" && !photo_id && photoFile) { - if (photoFile.size > MAX_BYTES) { - return NextResponse.json( - { error: `Photo exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(photoFile, (photoFile as File).name, bearer, { - folderId: folderIdFor(target, "photo"), - title: setting_title, - }); - photo_id = up.id; - } - if (op === "create" && !photo_id) { - return NextResponse.json( - { error: "Missing required: photo" }, - { status: 400 } - ); - } - - // Optional screen (both modes) - if (!screen_id && screenFile) { - if (screenFile.size > MAX_BYTES) { - return NextResponse.json( - { error: `Screenshot exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(screenFile, (screenFile as File).name, bearer, { - folderId: folderIdFor(target, "screen"), - title: `${setting_title} (screen)`, - }); - screen_id = up.id; - } - - // Repeaters (pass-through; UI coerces numbers/bools) - const fills = Array.isArray(body?.fill_settings) ? body.fill_settings : []; - const lines = Array.isArray(body?.line_settings) ? body.line_settings : []; - const rasters = Array.isArray(body?.raster_settings) ? body.raster_settings : []; - - // timestamps - const nowIso = new Date().toISOString(); - - // Build payload common to both modes - const basePayload: Record = { - setting_title, - setting_notes, - - // Ownership & attribution - owner: meId || null, // M2O to directus_users - uploader, // string mirror of username - - // exact keys - laser_soft, - repeat_all, - - mat, - mat_coat, - mat_color, - mat_opacity, - mat_thickness, - source, - lens, - focus, - - // CO2-specific lens extras - lens_conf, - lens_apt, - lens_exp, - - fill_settings: fills, - line_settings: lines, - raster_settings: rasters, - - status: "pending", - last_modified_date: nowIso, - }; - - if (op === "create") { - // Create-only fields - basePayload.photo = photo_id; - basePayload.screen = screen_id ?? null; - basePayload.submission_date = nowIso; - basePayload.submitted_via = "makearmy-app"; - basePayload.submitted_at = nowIso; - - // Helper is expected to wrap as { data: … } internally - const { data } = await createSettingsItem(target, basePayload, bearer); - return NextResponse.json({ ok: true, id: data.id }); - } - - // EDIT mode - const submission_id = body?.submission_id ?? null; - if (!submission_id) { - return NextResponse.json( - { error: "Edit mode requires submission_id" }, - { status: 400 } - ); - } - - // Only include photo/screen if provided; otherwise leave untouched - const editPayload: Record = { ...basePayload }; - if (photo_id) editPayload.photo = photo_id; - if (screen_id) editPayload.screen = screen_id; - - // Patch by filter to avoid needing internal item id, and restrict to your own record - const qs = new URLSearchParams(); - qs.set("filter[_and][0][submission_id][_eq]", String(submission_id)); - // enforce owner matches current user (works whether owner is id or M2O) - qs.set("filter[_and][1][owner][_eq]", String(meId)); - - // ⬇⬇⬇ Directus expects { data: {...} } here (this fixes the 400 "data is required") - const res = await dxPATCH<{ data: any[] }>( - `/items/${target}?${qs.toString()}`, - bearer, - { data: editPayload } - ); - - const updatedCount = Array.isArray(res?.data) ? res.data.length : 0; - if (updatedCount < 1) { - return NextResponse.json( - { error: "Nothing updated (not found or not owned by you)" }, - { status: 404 } - ); - } - - return NextResponse.json({ ok: true, updated: updatedCount, submission_id }); - } catch (err: any) { - console.error("[submit/settings] error", err?.message || err); - return NextResponse.json( - { error: err?.message || "Unknown error" }, - { status: err?.status ?? 500 } - ); - } finally { - const ms = Date.now() - started; - if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); - } -} diff --git a/components/details/CO2GalvoDetail.tsx b/components/details/CO2GalvoDetail.tsx index b04f416c..85401cca 100644 --- a/components/details/CO2GalvoDetail.tsx +++ b/components/details/CO2GalvoDetail.tsx @@ -2,8 +2,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import SettingsSubmit from "@/components/forms/SettingsSubmit"; type Rec = { submission_id: string | number; @@ -13,25 +11,23 @@ type Rec = { photo?: { id?: string } | string | null; screen?: { id?: string } | string | null; - // ids & readable fields 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; + 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; - laser_soft?: { id?: string | number; name?: string | null } | string | number | null; - repeat_all?: number | null; - - // CO₂ Galvo extras (M2O) lens_conf?: { id?: string | number; name?: string | null } | null; lens_apt?: { id?: string | number; name?: string | null } | null; lens_exp?: { id?: string | number; name?: string | null } | null; + repeat_all?: number | null; + fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; @@ -42,308 +38,110 @@ type Rec = { last_modified_date?: string | null; }; -async function readJson(res: Response) { - const text = await res.text(); +const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); +const asset = (id?: string | number) => (id ? `${API}/assets/${id}` : ""); + +async function readJson(r: Response) { + const t = await r.text(); try { - return text ? JSON.parse(text) : null; + return t ? JSON.parse(t) : null; } catch { - throw new Error(`Unexpected response (HTTP ${res.status})`); + return null; } } -function ownerLabel(o: Rec["owner"]) { - if (!o) return "—"; - if (typeof o === "string" || typeof o === "number") return String(o); - return o.username || String(o.id ?? "—"); -} - -// Prefer public assets if available (avoids auth cookie issues in ) -const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -function fileUrl(id?: string) { - if (!id) return ""; - return API_BASE ? `${API_BASE}/assets/${id}` : `/api/dx/assets/${id}`; -} - -function ZoomableSquareImage(props: { src: string; alt: string; onOpen: () => void }) { - const { src, alt, onOpen } = props; - return ( -
- {alt} { - (e.currentTarget as HTMLImageElement).style.display = "none"; - }} - /> -
- ); -} - -export default function CO2GalvoDetail({ - id, - mode, - onSaved, - onBack, - showOwnerEdit = true, -}: { - id: string | number; - mode?: "view" | "edit"; - onSaved?: (submission_id: string | number) => void; - onBack?: () => void; - showOwnerEdit?: boolean; -}) { - const sp = useSearchParams(); - const router = useRouter(); - const editParam = sp.get("edit") === "1"; - const editMode = mode ? mode === "edit" : editParam; - +export default function CO2GalvoDetail({ id, editable = true }: { id: string | number; editable?: boolean }) { const [rec, setRec] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); - // Current user id to gate the Edit button - const [meId, setMeId] = useState(null); useEffect(() => { - let dead = false; + let live = true; (async () => { - try { - const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" }); - if (!r.ok) return; - const j = await readJson(r); - const id = j?.data?.id ?? j?.id ?? null; - if (!dead) setMeId(id ? String(id) : null); - } catch { - /* ignore */ + setLoading(true); + setErr(null); + const fields = [ + "submission_id", + "setting_title", + "setting_notes", + "photo.id", + "screen.id", + "mat.id", + "mat.name", + "mat_coat.id", + "mat_coat.name", + "mat_color.id", + "mat_color.name", + "mat_opacity.id", + "mat_opacity.opacity", + "mat_thickness", + "laser_soft.id", + "laser_soft.name", + "source.submission_id", + "source.make", + "source.model", + "source.nm", + "lens.id", + "lens.field_size", + "lens.focal_length", + "focus", + "lens_conf.id", + "lens_conf.name", + "lens_apt.id", + "lens_apt.name", + "lens_exp.id", + "lens_exp.name", + "repeat_all", + "fill_settings", + "line_settings", + "raster_settings", + "owner.id", + "owner.username", + "uploader", + "last_modified_date", + ].join(","); + + const url = `${API}/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent( + String(id) + )}&limit=1`; + + const res = await fetch(url, { credentials: "include", cache: "no-store" }); + if (!res.ok) { + const j = await readJson(res); + throw new Error(j?.errors?.[0]?.message || `HTTP ${res.status}`); } - })(); + const j = await res.json(); + const row = Array.isArray(j?.data) ? j.data[0] : null; + if (!row) throw new Error("Not found"); + if (live) setRec(row); + })() + .catch((e: any) => live && setErr(e?.message || "Failed")) + .finally(() => live && setLoading(false)); return () => { - dead = true; - }; - }, []); - - // lightbox - const [viewerSrc, setViewerSrc] = useState(null); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setViewerSrc(null); - }; - if (viewerSrc) window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [viewerSrc]); - - // load record (with human-readable fields, +laser_soft.name) - 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", - - "mat.id", - "mat.name", - "mat_coat.id", - "mat_coat.name", - "mat_color.id", - "mat_color.name", - "mat_opacity.id", - "mat_opacity.opacity", - "mat_thickness", - - "source.submission_id", - "source.make", - "source.model", - "source.nm", - - "lens.id", - "lens.field_size", - "lens.focal_length", - - // CO₂ Galvo extras - "lens_conf.id", - "lens_conf.name", - "lens_apt.id", - "lens_apt.name", - "lens_exp.id", - "lens_exp.name", - - "focus", - "laser_soft.id", - "laser_soft.name", - "repeat_all", - - "fill_settings", - "line_settings", - "raster_settings", - - "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" }); - if (!r.ok) { - const j = await readJson(r).catch(() => null); - throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`); - } - const j = await readJson(r); - 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; + live = false; }; }, [id]); - const initialValues = useMemo(() => { - if (!rec) return null; - const toId = (v: any) => (v == null ? null : typeof v === "object" ? v.id ?? v.submission_id ?? null : v); + 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 photoId = typeof rec.photo === "string" || typeof rec.photo === "number" ? String(rec.photo) : rec.photo?.id ?? null; - const screenId = - typeof rec.screen === "string" || typeof rec.screen === "number" ? String(rec.screen) : rec.screen?.id ?? null; - - const matId = toId(rec.mat); - const coatId = toId(rec.mat_coat); - const colorId = toId(rec.mat_color); - const opacityId = toId(rec.mat_opacity); - const lensId = toId(rec.lens); - const sourceId = - rec.source && typeof rec.source === "object" ? rec.source.submission_id ?? null : (rec.source as any) ?? null; - - // CO₂ Galvo M2O ids - const lensConfId = toId(rec.lens_conf); - const lensAptId = toId(rec.lens_apt); - const lensExpId = toId(rec.lens_exp); - - return { - submission_id: rec.submission_id, // keep for edit mode parity - setting_title: rec.setting_title ?? "", - setting_notes: rec.setting_notes ?? "", - - photo: photoId, - screen: screenId, - - mat: matId ? String(matId) : null, - mat_coat: coatId ? String(coatId) : null, - mat_color: colorId ? String(colorId) : null, - mat_opacity: opacityId ? String(opacityId) : null, - mat_thickness: rec.mat_thickness ?? null, - - source: sourceId != null ? String(sourceId) : null, - lens: lensId != null ? String(lensId) : null, - focus: rec.focus ?? null, - - laser_soft: (typeof rec.laser_soft === "object" ? rec.laser_soft?.id : (rec.laser_soft as any)) ?? null, - repeat_all: rec.repeat_all ?? null, - - // pass through for prefill - lens_conf: lensConfId != null ? String(lensConfId) : null, - lens_apt: lensAptId != null ? String(lensAptId) : null, - lens_exp: lensExpId != null ? String(lensExpId) : null, - - fill_settings: rec.fill_settings ?? [], - line_settings: rec.line_settings ?? [], - raster_settings: rec.raster_settings ?? [], - }; - }, [rec]); - - function clearEditParam() { - const params = new URLSearchParams(sp.toString()); - params.delete("edit"); - router.replace(`?${params.toString()}`); - } - - if (loading) return

Loading setting…

; - if (err) - return ( -
-
{err}
-
- ); - if (!rec) return

Setting not found.

; - - // ── EDIT MODE ─────────────────────────────────────────────── - if (editMode && initialValues) { - return ( -
-
-

Edit CO₂ Galvo Setting

- -
- -
- ); - } - - // ── VIEW MODE (readable) ─────────────────────────────────── - const ownerDisplay = ownerLabel(rec.owner); - 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; - - 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 = photoId ? fileUrl(String(photoId)) : ""; - const screenSrc = screenId ? fileUrl(String(screenId)) : ""; + if (loading) return

Loading…

; + if (err) return
{err}
; + if (!rec) return

Not found.

; const softName = typeof rec.laser_soft === "object" ? rec.laser_soft?.name ?? "—" : "—"; - function openEdit() { - const q = new URLSearchParams(sp.toString()); - q.set("edit", "1"); - router.replace(`?${q.toString()}`, { scroll: false }); - } - return (
-

{rec.setting_title || "Untitled"}

+

{rec.setting_title || "Untitled"}

Last modified: {rec.last_modified_date || "—"}
-
-
+
- Owner: {ownerDisplay} + Owner: {typeof rec.owner === "object" ? rec.owner?.username || rec.owner?.id : rec.owner || "—"}
Uploader: {rec.uploader || "—"} @@ -379,161 +177,100 @@ export default function CO2GalvoDetail({ Software: {softName}
- Repeat All: {rec.repeat_all ?? "—"} + Lens Config: {rec.lens_conf?.name || "—"}
+
+ Scan Head Aperture: {rec.lens_apt?.name || "—"} +
+
+ Beam Expander: {rec.lens_exp?.name || "—"} +
+
+ Repeat All: {rec.repeat_all ?? "—"}
{rec.setting_notes ? ( -
+
Notes
-

{rec.setting_notes}

+

{rec.setting_notes}

) : null} - - {showOwnerEdit && isMine && ( -
- -
- )}
-
- {photoSrc ? ( -
- setViewerSrc(photoSrc)} /> -
Result
+
+ {photoId ? ( +
+ Result +
Result
) : null} - {screenSrc ? ( -
- setViewerSrc(screenSrc)} /> -
Settings Screenshot
+ {screenId ? ( +
+ Settings Screenshot +
Settings
) : null}
- {(rec.fill_settings?.length ?? 0) > 0 && ( -
-

Fill Settings

-
- - - - - - - - - - - - - - - - {rec.fill_settings!.map((r: any, i: number) => ( - - - - - - - - - - - - ))} - -
NameTypePower (%)Speed (mm/s)IntervalAnglePassFreq (kHz)Pulse (ns)
{r.name || "—"}{r.type || "—"}{r.power ?? "—"}{r.speed ?? "—"}{r.interval ?? "—"}{r.angle ?? "—"}{r.pass ?? "—"}{r.frequency ?? "—"}{r.pulse ?? "—"}
-
-
+ {/* Tables */} + {Array.isArray(rec.fill_settings) && rec.fill_settings.length > 0 && ( + [ + r.name ?? "—", + r.type ?? "—", + r.power ?? "—", + r.speed ?? "—", + r.interval ?? "—", + r.angle ?? "—", + r.pass ?? "—", + r.frequency ?? "—", + r.pulse ?? "—", + ])} /> )} - {(rec.line_settings?.length ?? 0) > 0 && ( -
-

Line Settings

-
-
- - - - - - - - - - - - - {rec.line_settings!.map((r: any, i: number) => ( - - - - - - - - - - ))} - -
NamePowerSpeedFreqPulsePassAir
{r.name || "—"}{r.power ?? "—"}{r.speed ?? "—"}{r.frequency ?? "—"}{r.pulse ?? "—"}{r.pass ?? "—"}{r.air ? "Yes" : "No"}
-
- + {Array.isArray(rec.line_settings) && rec.line_settings.length > 0 && ( + [ + r.name ?? "—", + r.power ?? "—", + r.speed ?? "—", + r.frequency ?? "—", + r.pulse ?? "—", + r.pass ?? "—", + r.air ? "Yes" : "No", + ])} /> )} - {(rec.raster_settings?.length ?? 0) > 0 && ( -
-

Raster Settings

-
-
- - - - - - - - - - - - - {rec.raster_settings!.map((r: any, i: number) => ( - - - - - - - - - - ))} - -
NameTypeDitherPowerSpeedIntervalPass
{r.name || "—"}{r.type || "—"}{r.dither || "—"}{r.power ?? "—"}{r.speed ?? "—"}{r.interval ?? "—"}{r.pass ?? "—"}
- - - )} - - {viewerSrc && ( -
setViewerSrc(null)} - > - e.stopPropagation()} - /> -
+ {Array.isArray(rec.raster_settings) && rec.raster_settings.length > 0 && ( + [ + r.name ?? "—", + r.type ?? "—", + r.dither ?? "—", + r.power ?? "—", + r.speed ?? "—", + r.interval ?? "—", + r.pass ?? "—", + ])} /> )} ); } + +function Table({ title, cols, rows }: { title: string; cols: string[]; rows: any[][] }) { + return ( +
+

{title}

+
+
+ + {cols.map((c) => )} + + + {rows.map((r, i) => ( + {r.map((v, j) => )} + ))} + +
{c}
{String(v)}
+ + + ); +} diff --git a/components/forms/SettingsSubmit.tsx b/components/forms/SettingsSubmit.tsx index 3cfbefcd..54599d2d 100644 --- a/components/forms/SettingsSubmit.tsx +++ b/components/forms/SettingsSubmit.tsx @@ -3,647 +3,316 @@ import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; + +/** + * From-scratch CO₂ Galvo form that follows the data sheet. + * - Prefill works via reset() with raw IDs. + * - Submits via /app/api/settings (this file does not assume any old helpers). + * - Lens options belong to Rig & Optics (not a separate "Lens Options" section). + */ -/** CO₂ Galvo implementation only (per spec). Other targets intentionally omitted. */ type Target = "settings_co2gal"; -// Accept any of the known targets from callers (e.g., SettingsSwitcher), -// but we still hard-lock behavior to CO₂ Galvo internally. -type ExternalTarget = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; type Opt = { id: string; label: string }; type Me = { id: string; username?: string; email?: string }; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -/* ───────────────────────────────────────────────────────────── - * Local enums (simple, stable lists that UI controls use) - * ───────────────────────────────────────────────────────────── */ -const FILL_TYPE_OPTIONS = [ - { label: "UniDirectional", value: "uni" }, -{ label: "BiDirectional", value: "bi" }, -{ label: "Offset Fill", value: "offset" }, -]; -const RASTER_TYPE_OPTIONS = [ - { label: "UniDirectional", value: "uni" }, -{ label: "BiDirectional", value: "bi" }, -{ label: "Offset Fill", value: "offset" }, -]; -const RASTER_DITHER_OPTIONS = [ - { label: "Threshold", value: "threshold" }, -{ label: "Ordered", value: "ordered" }, -{ label: "Atkinson", value: "atkinson" }, -{ label: "Dither", value: "dither" }, -{ label: "Stucki", value: "stucki" }, -{ label: "Jarvis", value: "jarvis" }, -{ label: "Newsprint", value: "newsprint" }, -{ label: "Halftone", value: "halftone" }, -{ label: "Sketch", value: "sketch" }, -{ label: "Grayscale", value: "grayscale" }, -]; - -const toOpts = (arr: { label: string; value: string }[]): Opt[] => -arr.map((x) => ({ id: x.value, label: x.label })); - -/* ───────────────────────────────────────────────────────────── - * Edit-mode initial values - * ───────────────────────────────────────────────────────────── */ type EditInitialValues = { submission_id: string | number; setting_title?: string; setting_notes?: string; - photo?: string | { id?: string } | null; - screen?: string | { id?: string } | null; + photo?: string | null; + screen?: string | null; - /* material / optics */ - mat?: any; - mat_coat?: any; - mat_color?: any; - mat_opacity?: any; + // Material + mat?: string | null; + mat_coat?: string | null; + mat_color?: string | null; + mat_opacity?: string | null; mat_thickness?: number | null; - source?: any; - lens?: any; + // Rig & Optics + laser_soft?: string | null; + source?: string | null; // submission_id + lens?: string | null; focus?: number | null; + // CO2 Galvo triplet (Rig & Optics) + lens_conf?: string | null; + lens_apt?: string | null; + lens_exp?: string | null; - /* dropdown relations (CO₂ Galvo) */ - lens_conf?: any; // laser_scan_lens_config (Lens Configuration) - lens_apt?: any; // laser_scan_lens_apt (Scan Head Aperture) - lens_exp?: any; // laser_scan_lens_exp (Beam Expander) - - /* shared */ - laser_soft?: any; repeat_all?: number | null; - /* repeaters */ + // Repeaters fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; }; -type BaseProps = { initialTarget?: ExternalTarget }; -type CreateProps = BaseProps & { mode?: "create"; submissionId?: never; initialValues?: never }; -type EditProps = BaseProps & { mode: "edit"; submissionId: string | number; initialValues: EditInitialValues }; -function isEditProps(p: CreateProps | EditProps): p is EditProps { - return (p as any)?.mode === "edit"; -} - -/* ───────────────────────────────────────────────────────────── - * Helpers - * ───────────────────────────────────────────────────────────── */ -function idToString(v: any): string { - if (v == null || v === "") return ""; - if (typeof v === "object") { - if ((v as any).id != null) return String((v as any).id); - if ((v as any).submission_id != null) return String((v as any).submission_id); - } - return String(v); -} - -function normalizeEnums(value: any, allowed: string[], fallback: string) { - const v = value == null ? "" : String(value).toLowerCase(); - return allowed.includes(v) ? v : fallback; -} - -function normalizeForReset(iv: EditInitialValues) { - return { - ...iv, - mat: idToString(iv.mat), - mat_coat: idToString(iv.mat_coat), - mat_color: idToString(iv.mat_color), - mat_opacity: idToString(iv.mat_opacity), - source: idToString(iv.source), - lens: idToString(iv.lens), - laser_soft: idToString(iv.laser_soft), - lens_conf: idToString(iv.lens_conf), - lens_apt: idToString(iv.lens_apt), - lens_exp: idToString(iv.lens_exp), - fill_settings: (iv.fill_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni") })), - raster_settings: (iv.raster_settings ?? []).map((r: any) => ({ - ...r, - type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"), - dither: normalizeEnums( - r?.dither, - ["threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale"], - "threshold" - ), - })), - line_settings: (iv.line_settings ?? []).map((r: any) => ({ ...r })), - }; -} - -/* ───────────────────────────────────────────────────────────── - * Options loader (Materials, Software, Source, Lens + fixed lens lists) - * ───────────────────────────────────────────────────────────── */ -function useOptions(path: string, forceIncludeId?: string) { - const [opts, setOpts] = useState([]); - const [loading, setLoading] = useState(false); - const [q, setQ] = useState(""); - - useEffect(() => { - let alive = true; - setLoading(true); - - (async () => { - let url = ""; - let normalize: (rows: any[]) => Opt[] = (rows) => - rows.map((r) => ({ id: String(r.id ?? r.submission_id ?? r.value), label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id) })); - - if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; - else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; - else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; - else if (path === "material_opacity") { - url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`; - normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) })); - } else if (path === "laser_software") { - url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; - } else if (path === "laser_source_co2_galvo") { - // Only show CO₂-range sources (10,000–11,000 nm) to match galvo - url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; - normalize = (rows) => { - const toNum = (v: any): number | null => { - const m = String(v ?? "").match(/-?\d+(\.\d+)?/); - return m ? Number(m[0]) : null; - }; - return rows - .filter((r: any) => { - const nm = toNum(r.nm); - return nm != null && nm >= 10000 && nm <= 11000; - }) - .map((r: any) => ({ - id: String(r.submission_id), - label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), - })); - }; - } else if (path === "laser_scan_lens") { - 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; - }; - const sorted = [...rows].sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length)); - return sorted.map((r) => { - const fs = r.field_size != null ? `${r.field_size}` : ""; - const fl = r.focal_length != null ? `${r.focal_length}` : ""; - const composed = [fs && `${fs} mm`, fl && `${fl} mm`].filter(Boolean).join(" — "); - return { id: String(r.id), label: composed || String(r.id) }; - }); - }; - } else if (path === "laser_scan_lens_config") { - url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`; - } else if (path === "laser_scan_lens_apt") { - url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; - } else if (path === "laser_scan_lens_exp") { - url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; - } else { - setOpts([]); - setLoading(false); - return; - } - - const res = await fetch(url, { cache: "no-store", credentials: "include" }); - if (!res.ok) throw new Error(`Directus ${res.status} fetching ${url}`); - const json = await res.json(); - const rows = json?.data ?? []; - let mapped = normalize(rows); - - if (forceIncludeId && !mapped.some((o) => String(o.id) === String(forceIncludeId))) { - mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped]; - } - - const needle = (q || "").trim().toLowerCase(); - const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; - if (alive) setOpts(filtered); - })() - .catch(() => setOpts([])) - .finally(() => alive && setLoading(false)); - - return () => { - alive = false; - }; - }, [path, q, forceIncludeId]); - - return { opts, loading, setQ }; -} - -/* ───────────────────────────────────────────────────────────── - * Small UI bits - * ───────────────────────────────────────────────────────────── */ -function FilterableSelect({ - label, - name, - register, - options, - loading, - onQuery, - placeholder = "—", - required = false, -}: { - label: string; - name: string; - register: UseFormRegister; - options: Opt[]; - loading?: boolean; - onQuery?: (q: string) => void; - placeholder?: string; - required?: 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 ( -
- - setFilter(e.target.value)} - /> - -
- ); -} - -function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister }) { - return ( - - ); -} - -function LabeledInput({ - label, - name, - type = "text", - step, - register, - required = false, - min, - max, -}: { - label: string; - name: string; - type?: "text" | "number"; - step?: string | number; - register: UseFormRegister; - required?: boolean; - min?: number; - max?: number; -}) { - return ( -
- - -
- ); -} - -/* ───────────────────────────────────────────────────────────── - * Component (CO₂ Galvo only) - * ───────────────────────────────────────────────────────────── */ -export default function SettingsSubmit(props: CreateProps | EditProps) { +type BaseProps = { mode?: "create" | "edit"; submissionId?: string | number; initialValues?: EditInitialValues | null }; +export default function SettingsSubmit({ mode = "create", submissionId, initialValues }: BaseProps) { const router = useRouter(); - const sp = useSearchParams(); + const isEdit = mode === "edit"; - const isEdit = isEditProps(props); - const edit = isEdit ? props : null; - - // CO₂ Galvo is the only target in this implementation. - const target: Target = "settings_co2gal"; - - // Image inputs - const [photoFile, setPhotoFile] = useState(null); - const [screenFile, setScreenFile] = useState(null); - const [photoPreview, setPhotoPreview] = useState(""); - const [screenPreview, setScreenPreview] = useState(""); - - // UX error for auth/submit - const [submitErr, setSubmitErr] = useState(null); - - // Current signed-in user (banner + uploader) const [me, setMe] = useState(null); - const [meErr, setMeErr] = useState(null); + const [submitErr, setSubmitErr] = useState(null); useEffect(() => { let alive = true; fetch(`/api/me`, { cache: "no-store", credentials: "include" }) - .then((r) => (r.ok ? r.json() : Promise.reject(r))) - .then((j) => { if (!alive) return; setMe(j || null); }) - .catch(() => { if (alive) setMeErr("not-signed-in"); }); - return () => { alive = false; }; + .then((r) => (r.ok ? r.json() : null)) + .then((j) => alive && setMe(j || null)) + .catch(() => alive && setMe(null)); + return () => { + alive = false; + }; }, []); - const meLabel = me?.username ?? me?.email ?? ""; + // Options loaders (raw Directus reads) + function useOptions(path: string, includeId?: string | null) { + const [opts, setOpts] = useState([]); + useEffect(() => { + let live = true; - // For edit-mode, compute normalized current values once - const current = useMemo( - () => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null), - [isEdit, edit?.initialValues] - ); + (async () => { + let url = ""; + let map = (rows: any[]) => rows.map((r) => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.model ?? r.opacity ?? r.id) })); - // Options (CO₂ Galvo) - const mats = useOptions("material", current?.mat || undefined); - const coats = useOptions("material_coating", current?.mat_coat || undefined); - const colors= useOptions("material_color", current?.mat_color || undefined); - const opacs = useOptions("material_opacity", current?.mat_opacity || undefined); - const soft = useOptions("laser_software", current?.laser_soft || undefined); - const srcs = useOptions("laser_source_co2_galvo", current?.source || undefined); - const lens = useOptions("laser_scan_lens", current?.lens || undefined); + if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; + else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; + else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; + else if (path === "material_opacity") { + url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`; + map = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) })); + } else if (path === "laser_software") { + url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; + } else if (path === "laser_source_co2_galvo") { + url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; + map = (rows) => + rows + .filter((r) => { + const m = String(r.nm ?? "").match(/-?\d+(\.\d+)?/); + const nm = m ? Number(m[0]) : null; + return nm != null && nm >= 10000 && nm <= 11000; + }) + .map((r) => ({ id: String(r.submission_id), label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id) })); + } else if (path === "laser_scan_lens") { + url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`; + map = (rows) => + rows + .slice() + .sort((a, b) => (parseFloat(a.focal_length ?? "99999") || 99999) - (parseFloat(b.focal_length ?? "99999") || 99999)) + .map((r) => { + const fs = r.field_size ? `${r.field_size} mm` : ""; + const fl = r.focal_length ? `${r.focal_length} mm` : ""; + const label = [fs, fl].filter(Boolean).join(" — ") || String(r.id); + return { id: String(r.id), label }; + }); + } else if (path === "laser_scan_lens_config") { + url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`; + } else if (path === "laser_scan_lens_apt") { + url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; + } else if (path === "laser_scan_lens_exp") { + url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; + } else { + setOpts([]); + return; + } - // Fixed lists (server-backed) - const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf || undefined); // Lens Configuration - const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt || undefined); // Scan Head Aperture - const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp || undefined); // Beam Expander + const res = await fetch(url, { cache: "no-store", credentials: "include" }); + const j = await res.json().catch(() => ({})); + const rows = Array.isArray(j?.data) ? j.data : []; + let list = map(rows); - // Repeater choice options (local) - const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; - const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; - const rasterDither= { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} }; + if (includeId && !list.some((o) => String(o.id) === String(includeId))) { + list = [{ id: String(includeId), label: "(current selection)" }, ...list]; + } + if (live) setOpts(list); + })().catch(() => live && setOpts([])); + return () => { + live = false; + }; + }, [path, includeId]); + + return { opts }; + } + + // Enumerations + const FILL_TYPES: Opt[] = [ + { id: "uni", label: "UniDirectional" }, + { id: "bi", label: "BiDirectional" }, + { id: "offset", label: "Offset Fill" }, + ]; + const RASTER_TYPES = FILL_TYPES; + const RASTER_DITHER: Opt[] = [ + "threshold", + "ordered", + "atkinson", + "dither", + "stucki", + "jarvis", + "newsprint", + "halftone", + "sketch", + "grayscale", + ].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) })); + + // react-hook-form const { register, handleSubmit, control, reset, - setValue, - getValues, formState: { isSubmitting }, } = useForm({ defaultValues: { setting_title: "", setting_notes: "", - - // material / optics + // Material mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: "", + // Rig & Optics + laser_soft: "", source: "", lens: "", focus: "", - laser_soft: "", - repeat_all: "", - - // CO₂ Galvo lens dropdowns lens_conf: "", lens_apt: "", lens_exp: "", - - // repeaters + repeat_all: "", + // Repeaters fill_settings: [], line_settings: [], raster_settings: [], }, }); - const fills = useFieldArray({ control, name: "fill_settings" }); - const lines = useFieldArray({ control, name: "line_settings" }); + // Repeaters + const fills = useFieldArray({ control, name: "fill_settings" }); + const lines = useFieldArray({ control, name: "line_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" }); - // Prefill the form in edit mode + // Prefill (edit) useEffect(() => { - if (isEdit && edit?.initialValues) { - const iv = normalizeForReset(edit.initialValues); - reset({ - setting_title: iv.setting_title ?? "", - setting_notes: iv.setting_notes ?? "", - photo: iv.photo ?? null, - screen: iv.screen ?? null, - - mat: iv.mat ?? "", - mat_coat: iv.mat_coat ?? "", - mat_color: iv.mat_color ?? "", - mat_opacity: iv.mat_opacity ?? "", - mat_thickness: iv.mat_thickness ?? "", - - source: iv.source ?? "", - lens: iv.lens ?? "", - focus: iv.focus ?? "", - - laser_soft: iv.laser_soft ?? "", - repeat_all: iv.repeat_all ?? "", - - lens_conf: iv.lens_conf ?? "", - lens_apt: iv.lens_apt ?? "", - lens_exp: iv.lens_exp ?? "", - - fill_settings: iv.fill_settings ?? [], - line_settings: iv.line_settings ?? [], - raster_settings: iv.raster_settings ?? [], - }); - } - }, [isEdit, edit?.initialValues, reset]); - - // After reset, force selects once to show current ids - useEffect(() => { - if (!isEdit || !current) return; - const names = ["laser_soft","mat","mat_coat","mat_color","mat_opacity","source","lens","lens_conf","lens_apt","lens_exp"] as const; - const values = getValues(); - names.forEach((name) => { - const cur = (current as any)[name]; - const now = (values as any)[name]; - if (cur && (now == null || now === "")) { - setValue(name as any, cur, { shouldDirty: false, shouldValidate: false }); - } + if (!isEdit || !initialValues) return; + reset({ + setting_title: initialValues.setting_title ?? "", + setting_notes: initialValues.setting_notes ?? "", + // Material + mat: initialValues.mat ?? "", + mat_coat: initialValues.mat_coat ?? "", + mat_color: initialValues.mat_color ?? "", + mat_opacity: initialValues.mat_opacity ?? "", + mat_thickness: initialValues.mat_thickness ?? "", + // Rig & Optics + laser_soft: initialValues.laser_soft ?? "", + source: initialValues.source ?? "", + lens: initialValues.lens ?? "", + focus: initialValues.focus ?? "", + lens_conf: initialValues.lens_conf ?? "", + lens_apt: initialValues.lens_apt ?? "", + lens_exp: initialValues.lens_exp ?? "", + repeat_all: initialValues.repeat_all ?? "", + // Repeaters + fill_settings: initialValues.fill_settings ?? [], + line_settings: initialValues.line_settings ?? [], + raster_settings: initialValues.raster_settings ?? [], }); - }, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, lensConf.opts, lensApt.opts, lensExp.opts]); + }, [isEdit, initialValues, reset]); - function num(v: any) { return v === "" || v == null ? null : Number(v); } - const bool = (v: any) => !!v; + // Option lists (include current IDs to guarantee a visible option) + const mats = useOptions("material", initialValues?.mat ?? null); + const coats = useOptions("material_coating", initialValues?.mat_coat ?? null); + const colors = useOptions("material_color", initialValues?.mat_color ?? null); + const opacs = useOptions("material_opacity", initialValues?.mat_opacity ?? null); + const soft = useOptions("laser_software", initialValues?.laser_soft ?? null); + const srcs = useOptions("laser_source_co2_galvo", initialValues?.source ?? null); + const lens = useOptions("laser_scan_lens", initialValues?.lens ?? null); + const conf = useOptions("laser_scan_lens_config", initialValues?.lens_conf ?? null); + const apt = useOptions("laser_scan_lens_apt", initialValues?.lens_apt ?? null); + const exp = useOptions("laser_scan_lens_exp", initialValues?.lens_exp ?? null); - /* ───────────────────────────────────────────────────────────── - * SUBMIT (multipart + payload JSON). CO₂ Galvo requireds enforced server-side, - * but we mark client-required for UX. - * ───────────────────────────────────────────────────────────── */ - async function onSubmit(values: any) { + // Image files + const [photoFile, setPhotoFile] = useState(null); + const [screenFile, setScreenFile] = useState(null); + const onPick = (setter: (f: File | null) => void) => (e: React.ChangeEvent) => setter(e.target.files?.[0] ?? null); + + const onSubmit = async (values: any) => { setSubmitErr(null); - - // In create mode, require either existing id (not present here) or a fresh file. - const hasExistingPhotoId = isEdit && typeof edit!.initialValues?.photo === "string" && !!edit!.initialValues.photo; - if (!hasExistingPhotoId && !photoFile) { - (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); - return; - } - const payload: any = { - target, // "settings_co2gal" - ...(isEdit ? { mode: "edit" as const, submission_id: edit!.submissionId } : {}), - - /* required basics */ + target: "settings_co2gal" as Target, + ...(isEdit ? { mode: "edit" as const, submission_id: submissionId } : {}), setting_title: values.setting_title, setting_notes: values.setting_notes || "", - - /* material / optics */ + // Material mat: values.mat || null, mat_coat: values.mat_coat || null, mat_color: values.mat_color || null, mat_opacity: values.mat_opacity || null, - mat_thickness: num(values.mat_thickness), - + mat_thickness: values.mat_thickness === "" ? null : Number(values.mat_thickness), + // Rig & Optics + laser_soft: values.laser_soft || null, source: values.source || null, lens: values.lens || null, - focus: num(values.focus), - - /* CO₂-only dropdowns */ - lens_conf: values.lens_conf || null, // Lens Configuration - lens_apt: values.lens_apt || null, // Scan Head Aperture - lens_exp: values.lens_exp || null, // Beam Expander - - /* shared */ - laser_soft: values.laser_soft || null, - repeat_all: num(values.repeat_all), - - /* repeaters */ - fill_settings: (values.fill_settings || []).map((r: any) => ({ - name: r.name || "", - power: num(r.power), - speed: num(r.speed), - interval: num(r.interval), - pass: num(r.pass), - type: r.type || "", - frequency: num(r.frequency), - pulse: num(r.pulse), - angle: num(r.angle), - auto: bool(r.auto), - increment: num(r.increment), - cross: bool(r.cross), - flood: bool(r.flood), - air: bool(r.air), - })), - line_settings: (values.line_settings || []).map((r: any) => ({ - name: r.name || "", - power: num(r.power), - speed: num(r.speed), - perf: bool(r.perf), - cut: bool(r.cut), - skip: bool(r.skip), - pass: num(r.pass), - air: bool(r.air), - frequency: num(r.frequency), - pulse: num(r.pulse), - wobble: bool(r.wobble), - step: num(r.step), - size: num(r.size), - })), - raster_settings: (values.raster_settings || []).map((r: any) => ({ - name: r.name || "", - power: num(r.power), - speed: num(r.speed), - type: r.type || "", - dither: r.dither || "", - halftone_cell: num(r.halftone_cell), - halftone_angle: num(r.halftone_angle), - inversion: bool(r.inversion), - interval: num(r.interval), - dot: num(r.dot), - pass: num(r.pass), - air: bool(r.air), - frequency: num(r.frequency), - pulse: num(r.pulse), - cross: bool(r.cross), - })), + focus: values.focus === "" ? null : Number(values.focus), + lens_conf: values.lens_conf || null, + lens_apt: values.lens_apt || null, + lens_exp: values.lens_exp || null, + repeat_all: values.repeat_all === "" ? null : Number(values.repeat_all), + // Repeaters (raw pass-through; api will normalize nums/bools) + fill_settings: values.fill_settings || [], + line_settings: values.line_settings || [], + raster_settings: values.raster_settings || [], + // If editing with existing asset IDs, the API will accept them + ...(initialValues?.photo ? { photo: initialValues.photo } : {}), + ...(initialValues?.screen ? { screen: initialValues.screen } : {}), }; - try { - const form = new FormData(); - form.set("payload", JSON.stringify(payload)); - if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); - if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); + const form = new FormData(); + form.set("payload", JSON.stringify(payload)); + if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); + if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); - const res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - if (res.status === 401 || res.status === 403) throw new Error("You must be signed in to submit settings."); - throw new Error(data?.error || "Submission failed"); - } - - if (!isEdit) { - reset(); - setPhotoFile(null); - setScreenFile(null); - setPhotoPreview(""); - setScreenPreview(""); - } - - const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? ""); - if (isEdit) { - const q = new URLSearchParams(sp.toString()); - q.delete("edit"); - router.replace(`/portal/laser-settings?${q.toString()}`); - } else { - router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); - } - } catch (e: any) { - setSubmitErr(e?.message || "Submission failed"); + const res = await fetch("/api/settings", { method: "POST", body: form, credentials: "include" }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + setSubmitErr(j?.error || `Submit failed (${res.status})`); + return; } - } - function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) { - setFile(file); - if (!file) { setPreview(""); return; } - const reader = new FileReader(); - reader.onload = () => setPreview(String(reader.result || "")); - reader.readAsDataURL(file); - } + if (isEdit) { + router.back(); + } else { + reset(); + setPhotoFile(null); + setScreenFile(null); + router.push(`/submit/settings/success?id=${encodeURIComponent(String(j?.id ?? ""))}`); + } + }; - const currentPhotoId = isEdit && typeof edit?.initialValues?.photo === "string" ? (edit!.initialValues.photo as string) : null; - const currentScreenId = isEdit && typeof edit?.initialValues?.screen === "string" ? (edit!.initialValues.screen as string) : null; - - /* ───────────────────────────────────────────────────────────── - * RENDER: Sectioned form (CO₂ Galvo) - * ───────────────────────────────────────────────────────────── */ return ( -
- {/* Header */} +
-

{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}

- {me ? ( -
- Submitting as {meLabel}. -
- ) : meErr ? ( -
- You’re not signed in. Submissions will fail until you sign in. -
- ) : null} - {submitErr ?
{submitErr}
: null} +

{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}

+ {me ?

Submitting as {me.username || me.email}

: null} + {submitErr ?
{submitErr}
: null}
- {/* Section: Overview */} + {/* Info */}
-

Overview

+

Info

- +
@@ -653,204 +322,205 @@ export default function SettingsSubmit(props: CreateProps | EditProps) {
- {/* Section: Images */} + {/* Images */}

Images

-
+
- - {currentPhotoId ? ( -

Current: {currentPhotoId}

- ) : null} - onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} - /> -

- {photoFile ? <>Selected: {photoFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} -

- {photoPreview ? Result preview : null} + +
- {currentScreenId ? ( -

Current: {currentScreenId}

- ) : null} - onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} - /> -

- {screenFile ? <>Selected: {screenFile.name} : "Max 25 MB. JPG/PNG/WebP recommended."} -

- {screenPreview ? Settings preview : null} +
- {/* Section: Material */} + {/* Material */}

Material

- - - - - + + +
- {/* Section: Optics */} + {/* Rig & Optics (includes lens_conf/apt/exp) */}
-

Optics

+

Rig & Optics

- - - + + +
- {/* Section: Process Settings */} + {/* Process Settings */}

Process Settings

- {/* FILL */} -
-
- Fill - -
- {fills.fields.map((f, i) => ( -
- - {}} placeholder="Select type" /> - - - - - - - - -
- - + {/* Fill */} + fills.append({ type: "uni" })} + onRemove={(i) => fills.remove(i)} + render={(i) => ( +
+ + + + + {options.map((o) => ( + + ))} + +
+ ); +} +function Number({ label, name, register, step }: any) { + return ( +
+ + +
+ ); +} +function Text({ label, name, register }: any) { + return ( +
+ + +
+ ); +} +function Check({ label, name, register }: any) { + return ( + + ); +} +function Repeater({ title, fields, onAdd, onRemove, render }: any) { + return ( +
+
+ {title} + +
+ {fields.map((_: any, i: number) => ( +
+ {render(i)} + +
+ ))} +
+ ); +} diff --git a/components/lists/CO2GalvoList.tsx b/components/lists/CO2GalvoList.tsx index d4a1c372..3b5ba81f 100644 --- a/components/lists/CO2GalvoList.tsx +++ b/components/lists/CO2GalvoList.tsx @@ -17,266 +17,136 @@ type Owner = | null | undefined; -type SettingsRow = { +type Row = { submission_id: string | number; setting_title?: string | null; - uploader?: string | null; owner?: Owner; + uploader?: string | null; mat?: { name?: string | null } | null; mat_coat?: { name?: string | null } | null; source?: { model?: string | null } | null; lens?: { field_size?: string | number | null } | null; }; -export type CO2GalvoListProps = { - /** Build the href for a record (portal vs standalone) */ - linkFor: (submission_id: string | number, opts?: { edit?: boolean }) => string; - /** Optional controlled search text (else internal input) */ - queryText?: string; - onQueryChange?: (q: string) => void; -}; +const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -async function readJson(res: Response) { - const text = await res.text(); +async function readJson(r: Response) { + const t = await r.text(); try { - return text ? JSON.parse(text) : null; + return t ? JSON.parse(t) : null; } catch { - throw new Error(`Unexpected response (status ${res.status})`); + return null; } } -export default function CO2GalvoList({ linkFor, queryText, onQueryChange }: CO2GalvoListProps) { - const [settings, setSettings] = useState([]); - const [ownerMap, setOwnerMap] = useState>({}); +export default function CO2GalvoList({ + linkFor, + queryText, + onQueryChange, +}: { + linkFor: (id: string | number) => string; + queryText?: string; + onQueryChange?: (q: string) => void; +}) { + const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); - const [resolvingOwners, setResolvingOwners] = useState(false); - const [meId, setMeId] = useState(null); const [localQuery, setLocalQuery] = useState(queryText ?? ""); - // keep local and external search text in sync useEffect(() => { if (queryText !== undefined) setLocalQuery(queryText); }, [queryText]); - // load current user id (for edit visibility) useEffect(() => { - let dead = false; + let live = true; (async () => { - try { - const r = await fetch(`/api/dx/users/me?fields=id`, { - cache: "no-store", - credentials: "include", - }); - if (!r.ok) return; + setLoading(true); + const fields = [ + "submission_id", + "setting_title", + "owner", + "owner.id", + "owner.username", + "uploader", + "mat.name", + "mat_coat.name", + "source.model", + "lens.field_size", + ].join(","); + const url = `${API}/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`; + const r = await fetch(url, { credentials: "include", cache: "no-store" }); + if (!r.ok) { const j = await readJson(r); - const id = j?.data?.id ?? j?.id ?? null; - if (!dead) setMeId(id ? String(id) : null); - } catch { - /* ignore */ + throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`); } - })(); + const j = await r.json(); + const list = Array.isArray(j?.data) ? j.data : []; + if (live) setRows(list); + })() + .catch(() => live && setRows([])) + .finally(() => live && setLoading(false)); return () => { - dead = true; + live = false; }; }, []); - // load list - useEffect(() => { - const fields = [ - "submission_id", - "setting_title", - "uploader", - "owner", - "owner.id", - "owner.username", - "photo.id", - "photo.title", - "mat.name", - "mat_coat.name", - "source.model", - "lens.field_size", - ].join(","); - - const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`; - - fetch(url, { cache: "no-store", credentials: "include" }) - .then(async (res) => { - if (!res.ok) { - const j = await readJson(res).catch(() => null); - const msg = (j as any)?.errors?.[0]?.message || `HTTP ${res.status}`; - throw new Error(msg); - } - return readJson(res); - }) - .then((json: any) => setSettings(Array.isArray(json?.data) ? json.data : [])) - .catch((e) => { - console.error("CO2 Galvo list fetch failed:", e); - setSettings([]); - }) - .finally(() => setLoading(false)); - }, []); - - // resolve owner usernames if needed - useEffect(() => { - if (!settings.length) return; - - const ids = new Set(); - for (const s of settings) { - const o = s.owner; - if (!o) continue; - - if (typeof o === "string" || typeof o === "number") { - const k = String(o); - if (!ownerMap[k]) ids.add(k); - } else { - const id = o.id != null ? String(o.id) : ""; - const hasUsername = !!o.username; - if (id && !hasUsername && !ownerMap[id]) ids.add(id); - } - } - if (!ids.size) return; - - const all = Array.from(ids); - const chunk = 100; - setResolvingOwners(true); - - (async () => { - const updates: Record = {}; - for (let i = 0; i < all.length; i += chunk) { - const slice = all.slice(i, i + chunk); - const qs = new URLSearchParams(); - qs.set("fields", "id,username"); - qs.set("limit", String(slice.length)); - qs.set("filter[id][_in]", slice.join(",")); - - try { - const r = await fetch(`/api/dx/users?${qs.toString()}`, { - credentials: "include", - cache: "no-store", - }); - if (!r.ok) { - const j = await readJson(r).catch(() => null); - const msg = (j as any)?.errors?.[0]?.message || `HTTP ${r.status}`; - console.warn("Owner lookup failed:", msg); - if (r.status === 401 || r.status === 403) break; - continue; - } - const j = await readJson(r); - const rows: Array<{ id: string; username?: string | null }> = j?.data || []; - for (const row of rows) { - updates[String(row.id)] = row.username || String(row.id); - } - } catch (e) { - console.warn("Owner lookup error:", e); - break; - } - } - - if (Object.keys(updates).length) { - setOwnerMap((prev) => ({ ...prev, ...updates })); - } - setResolvingOwners(false); - })(); - }, [settings, ownerMap]); - - const isMine = (o: Owner) => { - if (!meId || !o) return false; - if (typeof o === "string" || typeof o === "number") return String(o) === meId; - if (o.id != null) return String(o.id) === meId; - return false; - }; - const ownerLabel = (o: Owner) => { if (!o) return "—"; - if (typeof o === "string" || typeof o === "number") { - const id = String(o); - return ownerMap[id] || id; - } - return ( - o.username || - [o.first_name, o.last_name].filter(Boolean).join(" ").trim() || - o.email || - (o.id != null ? String(o.id) : "—") - ); + if (typeof o === "string" || typeof o === "number") return String(o); + return o.username || [o.first_name, o.last_name].filter(Boolean).join(" ").trim() || o.email || (o.id != null ? String(o.id) : "—"); }; const filtered = useMemo(() => { - const nq = (localQuery || "").toLowerCase(); - if (!nq) return settings; - return settings.filter((entry) => { - const fields = [ - entry.setting_title, - ownerLabel(entry.owner), - entry.uploader, - entry.mat?.name, - entry.mat_coat?.name, - entry.source?.model, - entry.lens?.field_size as any, - ].filter(Boolean); - return fields.some((v: any) => String(v).toLowerCase().includes(nq)); - }); - }, [settings, localQuery, ownerMap]); + const q = (localQuery || "").toLowerCase(); + if (!q) return rows; + return rows.filter((r) => + [r.setting_title, ownerLabel(r.owner), r.uploader, r.mat?.name, r.mat_coat?.name, r.source?.model, r.lens?.field_size] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)) + ); + }, [rows, localQuery]); return (
-
{ setLocalQuery(e.currentTarget.value); onQueryChange?.(e.currentTarget.value); }} - placeholder="Search by title, owner, material, model…" + placeholder="Search title, owner, material, model…" className="w-full border rounded px-3 py-2" /> -
{loading ? (

Loading…

) : (
- - + + - + - - {filtered.map((s) => { - const mine = isMine(s.owner); - const ownerText = ownerLabel(s.owner) + (mine ? " (you)" : ""); - return ( - - - - - - - - - - ); - })} + {filtered.map((r) => ( + + + + + + + + + ))}
Title - Owner {resolvingOwners ? "…resolving" : ""} - Owner Material Coating Model FieldEdit
- - {s.setting_title || "Untitled"} - - {ownerText}{s.mat?.name || "—"}{s.mat_coat?.name || "—"}{s.source?.model || "—"}{s.lens?.field_size || "—"} - {mine ? ( - - Edit - - ) : ( - - )} -
+ + {r.setting_title || "Untitled"} + + {ownerLabel(r.owner)}{r.mat?.name || "—"}{r.mat_coat?.name || "—"}{r.source?.model || "—"}{r.lens?.field_size || "—"}