diff --git a/app/_app_settings.zip b/app/_app_settings.zip deleted file mode 100644 index a2d5719f..00000000 Binary files a/app/_app_settings.zip and /dev/null differ diff --git a/app/api/me/route.ts b/app/api/me/route.ts deleted file mode 100644 index 9975575c..00000000 --- a/app/api/me/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -// app/api/me/route.ts -import { NextResponse } from "next/server"; - -function readCookie(name: string, cookieHeader: string) { - const m = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); - return m?.[1] ?? null; -} - -export async function GET(req: Request) { - const base = process.env.NEXT_PUBLIC_API_BASE_URL!; - // include username here - const url = `${base}/users/me?fields=id,username,display_name,first_name,last_name,email`; - - const cookieHeader = req.headers.get("cookie") ?? ""; - const ma_at = readCookie("ma_at", cookieHeader); - - const headers: Record = { "cache-control": "no-store" }; - if (cookieHeader) headers.cookie = cookieHeader; - if (ma_at) headers.authorization = `Bearer ${ma_at}`; - - const res = await fetch(url, { headers, cache: "no-store" }); - const body = await res.json().catch(() => ({})); - - return new NextResponse(JSON.stringify(body), { - status: res.status, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - }, - }); -} diff --git a/app/api/my/rigs/[id]/route.ts b/app/api/my/rigs/[id]/route.ts deleted file mode 100644 index 8e2225da..00000000 --- a/app/api/my/rigs/[id]/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -// app/api/my/rigs/[id]/route.ts -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { directusFetch } from "@/lib/directus"; - -const BASE_COLLECTION = "user_rigs"; - -async function bearerFromCookies() { - const store = await cookies(); - const at = store.get("ma_at")?.value; - if (!at) throw new Error("Not authenticated"); - return `Bearer ${at}`; -} - -export async function PATCH(req: Request, ctx: any) { - try { - const auth = await bearerFromCookies(); - const body = await req.json().catch(() => ({})); - const id = ctx?.params?.id as string | undefined; - if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); - - const data = await directusFetch<{ data: any }>(`/items/${BASE_COLLECTION}/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: auth, // force user-token for this call - Accept: "application/json", - }, - body: JSON.stringify(body), - }); - - return NextResponse.json({ ok: true, data: data.data }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "Update failed" }, - { status: err?.message === "Not authenticated" ? 401 : 400 } - ); - } -} - -export async function DELETE(_req: Request, ctx: any) { - try { - const auth = await bearerFromCookies(); - const id = ctx?.params?.id as string | undefined; - if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); - - await directusFetch(`/items/${BASE_COLLECTION}/${id}`, { - method: "DELETE", - headers: { - Authorization: auth, // force user-token - Accept: "application/json", - }, - }); - - return NextResponse.json({ ok: true }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "Delete failed" }, - { status: err?.message === "Not authenticated" ? 401 : 400 } - ); - } -} diff --git a/app/api/my/rigs/route.ts b/app/api/my/rigs/route.ts deleted file mode 100644 index 34ce51b8..00000000 --- a/app/api/my/rigs/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -// app/api/my/rigs/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); - -async function bearerFromCookies() { - const store = await cookies(); - const at = store.get("ma_at")?.value; - if (!at) throw new Error("Not authenticated"); - return `Bearer ${at}`; -} - -async function getMyUserId(bearer: string) { - const res = await fetch(`${BASE}/users/me`, { - headers: { Authorization: bearer, Accept: "application/json" }, - cache: "no-store", - }); - const txt = await res.text(); - if (!res.ok) throw new Error(txt || res.statusText); - const j = txt ? JSON.parse(txt) : {}; - return j?.data?.id as string; -} - -export async function GET(_req: NextRequest) { - try { - const bearer = await bearerFromCookies(); - const myId = await getMyUserId(bearer); - - const fields = [ - "id", - "name", - "rig_type", - "rig_type.name", - "laser_source", - "laser_focus_lens", - "laser_scan_lens", - "laser_scan_lens_apt", - "laser_scan_lens_exp", - "laser_software", - "notes", - "user_created", - "date_created", - "date_updated", - ].join(","); - - const url = new URL(`${BASE}/items/user_rigs`); - url.searchParams.set("fields", fields); - url.searchParams.set("sort", "-date_created"); - // If you use a custom owner field, switch this to filter[owner][_eq] - url.searchParams.set("filter[user_created][_eq]", myId); - - const res = await fetch(String(url), { - headers: { Authorization: bearer, Accept: "application/json" }, - cache: "no-store", - }); - - const txt = await res.text(); - if (!res.ok) return NextResponse.json({ error: txt || res.statusText }, { status: res.status }); - const j = txt ? JSON.parse(txt) : { data: [] }; - - const data = (j.data ?? []).map((r: any) => ({ - ...r, - rig_type_name: r?.rig_type?.name ?? r?.rig_type_name ?? null, - })); - - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to list rigs" }, { status: 401 }); - } -} - -export async function POST(req: NextRequest) { - try { - const bearer = await bearerFromCookies(); - const body = await req.json().catch(() => ({})); - - // If your collection requires a custom 'owner' field, uncomment: - // const owner = await getMyUserId(bearer); - // const payload = { ...body, owner }; - const payload = body; - - const res = await fetch(`${BASE}/items/user_rigs`, { - method: "POST", - headers: { - Authorization: bearer, - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - const txt = await res.text(); - if (!res.ok) return NextResponse.json({ error: txt || res.statusText }, { status: res.status }); - const j = txt ? JSON.parse(txt) : {}; - return NextResponse.json(j); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to create rig" }, { status: 400 }); - } -} diff --git a/app/api/options/[collection]/route.ts b/app/api/options/[collection]/route.ts deleted file mode 100644 index 83d931ef..00000000 --- a/app/api/options/[collection]/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -// app/api/options/[collection]/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { directusFetch } from "@/lib/directus"; - -// Expandable label-field preferences per collection. -// We’ll try each key in order until we find a value. -const MAP: Record< -string, -{ coll: string; labelFields: string[] } -> = { - material: { coll: "material", labelFields: ["name", "label", "title"] }, - material_coating: { coll: "material_coating", labelFields: ["name", "label", "title"] }, - material_color: { coll: "material_color", labelFields: ["name", "label", "title"] }, - material_opacity: { coll: "material_opacity", labelFields: ["name", "label", "title", "value"] }, - laser_software: { coll: "laser_software", labelFields: ["name", "label", "title"] }, - - // NEW: Galvo scan head aperture list - laser_scan_lens_apt: { coll: "laser_scan_lens_apt", labelFields: ["name", "label", "title", "aperture_mm", "size_mm", "value"] }, - - // NEW: Beam expander multiplier list - laser_scan_lens_exp: { coll: "laser_scan_lens_exp", labelFields: ["name", "label", "title", "multiplier", "value"] }, -}; - -function pickLabel(it: any, candidates: string[]) { - for (const k of candidates) if (it?.[k] != null && it[k] !== "") return String(it[k]); - // fallback: try some common numeric-ish fields if present - if (it?.value != null) return String(it.value); - return String(it?.name ?? it?.label ?? it?.title ?? it?.id ?? ""); -} - -export async function GET(req: NextRequest, ctx: any) { - try { - const key = String(ctx?.params?.collection || ""); - const cfg = MAP[key]; - if (!cfg) return NextResponse.json({ data: [] }); - - const { searchParams } = new URL(req.url); - const q = (searchParams.get("q") || "").toLowerCase(); - - // Keep fields=* so we can build a friendly label from whatever exists - const url = `/items/${cfg.coll}?fields=*&limit=500`; - const res = await directusFetch<{ data: any[] }>(url); - const items = res?.data ?? []; - - const mapped = items.map((it) => ({ - id: String(it.id ?? it.submission_id ?? ""), - name: pickLabel(it, cfg.labelFields), - _search: `${Object.values(it).join(" ")}`.toLowerCase(), - })).filter((m) => !!m.id); - - const filtered = q ? mapped.filter((m) => m._search.includes(q)) : mapped; - filtered.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")); - return NextResponse.json({ data: filtered.map(({ _search, ...r }) => r) }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "options error" }, - { status: 500 } - ); - } -} diff --git a/app/api/options/_lib.ts b/app/api/options/_lib.ts new file mode 100644 index 00000000..75b4d31b --- /dev/null +++ b/app/api/options/_lib.ts @@ -0,0 +1,48 @@ +// app/api/options/_lib.ts +import { NextRequest, NextResponse } from "next/server"; + +export type Option = { id: string | number; label: string }; + +export function readCookie(name: string, cookieHeader: string) { + const m = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return m?.[1] ?? null; +} + +export function getAuthHeaders(req: NextRequest) { + const cookieHeader = req.headers.get("cookie") ?? ""; + const ma_at = readCookie("ma_at", cookieHeader); + const headers: Record = { Accept: "application/json" }; + if (cookieHeader) headers.cookie = cookieHeader; + if (ma_at) headers.authorization = `Bearer ${ma_at}`; + return headers; +} + +export function apiBase() { + const base = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); + if (!base) throw new Error("Missing DIRECTUS_URL or NEXT_PUBLIC_API_BASE_URL"); + return base; +} + +export async function dFetchJSON(req: NextRequest, path: string): Promise { + const res = await fetch(`${apiBase()}${path}`, { + headers: getAuthHeaders(req), + cache: "no-store", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Directus ${res.status} fetching ${path}: ${text}`); + } + return res.json() as Promise; +} + +export function applyQFilter(rows: T[], q: string | null, pick: (row: T) => string): T[] { + if (!q) return rows; + const needle = q.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((r) => (pick(r) || "").toLowerCase().includes(needle)); +} + +export function json(data: Option[] | { data: Option[] }, status = 200) { + const body = Array.isArray(data) ? { data } : data; + return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } }); +} diff --git a/app/api/options/laser_focus_lens/route.ts b/app/api/options/laser_focus_lens/route.ts index 514dcf96..ecf669ac 100644 --- a/app/api/options/laser_focus_lens/route.ts +++ b/app/api/options/laser_focus_lens/route.ts @@ -1,26 +1,11 @@ -export const dynamic = "force-dynamic"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; -const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); -const PATH = `/items/laser_focus_lens?fields=id,name&sort=name`; +type Row = { id: number | string; name?: string | null }; export async function GET(req: NextRequest) { - try { - const userAt = req.cookies.get("ma_at")?.value; - if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - - const res = await fetch(`${BASE}${PATH}`, { - headers: { Accept: "application/json", Authorization: `Bearer ${userAt}` }, - cache: "no-store", - }); - - const txt = await res.text(); - if (!res.ok) return NextResponse.json({ error: txt || res.statusText }, { status: res.status }); - - const j = txt ? JSON.parse(txt) : { data: [] }; - const data = (j.data ?? []).map(({ id, name }: any) => ({ id, name })); - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to load focus lenses" }, { status: 500 }); - } + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/laser_focus_lens?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); } diff --git a/app/api/options/laser_scan_lens/route.ts b/app/api/options/laser_scan_lens/route.ts new file mode 100644 index 00000000..7b918630 --- /dev/null +++ b/app/api/options/laser_scan_lens/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null; focal_length?: number | string | null; field_size?: number | string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/laser_scan_lens?fields=id,name,focal_length,field_size&limit=1000&sort=name"); + const options: Option[] = data.map((r) => { + const fl = r.focal_length != null ? `${r.focal_length}` : ""; + const fs = r.field_size != null ? `${r.field_size}` : ""; + const composed = r.name ?? [fl && `${fl} mm`, fs && `${fs} mm`].filter(Boolean).join(" — ") || String(r.id); + return { id: r.id, label: composed }; + }); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/laser_scan_lens_apt/route.ts b/app/api/options/laser_scan_lens_apt/route.ts new file mode 100644 index 00000000..ddfaf2c1 --- /dev/null +++ b/app/api/options/laser_scan_lens_apt/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/laser_scan_lens_exp/route.ts b/app/api/options/laser_scan_lens_exp/route.ts new file mode 100644 index 00000000..ddfaf2c1 --- /dev/null +++ b/app/api/options/laser_scan_lens_exp/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/laser_software/route.ts b/app/api/options/laser_software/route.ts index 2a41c7b4..11d24cfe 100644 --- a/app/api/options/laser_software/route.ts +++ b/app/api/options/laser_software/route.ts @@ -1,39 +1,11 @@ -// app/api/options/laser_software/route.ts -export const dynamic = "force-dynamic"; +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; -import { NextRequest, NextResponse } from "next/server"; - -const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); -const PATH = `/items/laser_software?fields=id,name&sort=name`; - -async function dFetch(bearer: string) { - const res = await fetch(`${BASE}${PATH}`, { - headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, - cache: "no-store", - }); - const text = await res.text().catch(() => ""); - let json: any = null; - try { json = text ? JSON.parse(text) : null; } catch {} - return { res, json, text }; -} +type Row = { id: number | string; name?: string | null }; export async function GET(req: NextRequest) { - try { - const userAt = req.cookies.get("ma_at")?.value; - if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - - const r = await dFetch(userAt); - if (!r.res.ok) { - return NextResponse.json( - { error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` }, - { status: r.res.status } - ); - } - - const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? []; - const data = rows.map(({ id, name }) => ({ id, name })); - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to load software" }, { status: 500 }); - } + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/laser_software?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); } diff --git a/app/api/options/laser_source/route.ts b/app/api/options/laser_source/route.ts index f6b84678..e93a4f8f 100644 --- a/app/api/options/laser_source/route.ts +++ b/app/api/options/laser_source/route.ts @@ -1,42 +1,43 @@ -// app/api/options/laser_source/route.ts -export const dynamic = "force-dynamic"; +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; -import { NextRequest, NextResponse } from "next/server"; -const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +type Row = { submission_id: string | number; make?: string | null; model?: string | null; nm?: string | null }; + +function rangeForTarget(target?: string | null): [number, number] | null { + if (!target) return null; + const t = target.toLowerCase(); + if (t === "fiber") return [1000, 9000]; + if (t === "uv") return [300, 400]; + if (t === "co2-gantry" || t === "co2-galvo") return [10000, 11000]; + return null; +} +function parseNm(s?: string | null): number | null { + if (!s) return null; + const m = String(s).match(/(\d+(\.\d+)?)/); + return m ? Number(m[1]) : null; +} export async function GET(req: NextRequest) { - try { - const userAt = req.cookies.get("ma_at")?.value; - if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const url = new URL(req.url); + const q = url.searchParams.get("q"); + const target = url.searchParams.get("target"); // fiber | uv | co2-gantry | co2-galvo + const { data } = await dFetchJSON<{ data: Row[] }>( + req, + "/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model" + ); - const url = new URL(`${BASE}/items/laser_source`); - // IMPORTANT: schema uses submission_id as the FK target - url.searchParams.set("fields", "submission_id,make,model,nm"); - url.searchParams.set("sort", "make,model"); + const range = rangeForTarget(target); + const filteredByNm = range + ? data.filter((r) => { + const v = parseNm(r.nm); + return v != null && v >= range[0] && v <= range[1]; + }) + : data; - const res = await fetch(String(url), { - headers: { Accept: "application/json", Authorization: `Bearer ${userAt}` }, - cache: "no-store", - }); + const options: Option[] = filteredByNm.map((r) => ({ + id: r.submission_id, + label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), + })); - const text = await res.text().catch(() => ""); - const json = text ? JSON.parse(text) : {}; - if (!res.ok) { - return NextResponse.json({ error: `Directus ${res.status}: ${text || res.statusText}` }, { status: res.status }); - } - - const rows: Array<{ submission_id: string | number; make?: string; model?: string; nm?: string | number }> = - json?.data ?? []; - - const data = rows - .map((r) => { - const parts = [r.make, r.model, r.nm ? `${r.nm}nm` : null].filter(Boolean); - return { id: r.submission_id, label: parts.join(" ") }; - }) - .filter((x) => x.label); - - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to load laser sources" }, { status: 500 }); - } + return json(applyQFilter(options, q, (o) => o.label)); } diff --git a/app/api/options/lens/route.ts b/app/api/options/lens/route.ts deleted file mode 100644 index 43621906..00000000 --- a/app/api/options/lens/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -// app/api/options/lens/route.ts -export const dynamic = "force-dynamic"; - -import { NextRequest, NextResponse } from "next/server"; - -const BASE = ( - process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "" -).replace(/\/$/, ""); - -function buildPath(target?: string | null) { - // CO2 Gantry → focus lenses (name) - // everything else (Fiber/UV/CO2 Galvo) → scan lenses (field_size + focal_length) - const isGantry = target === "co2-gantry"; - - if (isGantry) { - const url = new URL(`${BASE}/items/laser_focus_lens`); - url.searchParams.set("fields", "id,name"); - url.searchParams.set("sort", "name"); - return String(url); - } - - const url = new URL(`${BASE}/items/laser_scan_lens`); - url.searchParams.set("fields", "id,field_size,focal_length"); - url.searchParams.set("sort", "field_size,focal_length"); - return String(url); -} - -async function dFetch(bearer: string, target?: string | null) { - const res = await fetch(buildPath(target), { - headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, - cache: "no-store", - }); - const text = await res.text().catch(() => ""); - let json: any = null; - try { - json = text ? JSON.parse(text) : null; - } catch {} - return { res, json, text }; -} - -export async function GET(req: NextRequest) { - try { - const userAt = req.cookies.get("ma_at")?.value; - if (!userAt) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } - - const target = req.nextUrl.searchParams.get("target"); - const r = await dFetch(userAt, target); - if (!r.res.ok) { - return NextResponse.json( - { error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` }, - { status: r.res.status } - ); - } - - const rows: any[] = r.json?.data ?? []; - const isGantry = target === "co2-gantry"; - - const data = rows.map((row) => { - if (isGantry) { - // Focus lens: label is just the stored name - return { id: row.id, name: row.name, label: row.name }; - } - // Scan lens: label "300x300 mm | F420" etc - const fs = - row.field_size != null && row.field_size !== "" - ? `${row.field_size} mm` - : ""; - const fl = - row.focal_length != null && row.focal_length !== "" - ? `F${row.focal_length}` - : ""; - const label = [fs, fl].filter(Boolean).join(" | "); - return { id: row.id, name: label, label }; - }); - - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json( - { error: e?.message || "Failed to load lenses" }, - { status: 500 } - ); - } -} diff --git a/app/api/options/material/route.ts b/app/api/options/material/route.ts new file mode 100644 index 00000000..5e17e05d --- /dev/null +++ b/app/api/options/material/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/material?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/material_coating/route.ts b/app/api/options/material_coating/route.ts new file mode 100644 index 00000000..710f59ba --- /dev/null +++ b/app/api/options/material_coating/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/material_coating?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/material_color/route.ts b/app/api/options/material_color/route.ts new file mode 100644 index 00000000..af3a3036 --- /dev/null +++ b/app/api/options/material_color/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; + +type Row = { id: number | string; name?: string | null }; + +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get("q"); + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/material_color?fields=id,name&limit=1000&sort=name"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.name ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); +} diff --git a/app/api/options/material_opacity/route.ts b/app/api/options/material_opacity/route.ts index 9d4ca6ec..fd89f46f 100644 --- a/app/api/options/material_opacity/route.ts +++ b/app/api/options/material_opacity/route.ts @@ -1,36 +1,12 @@ -// app/api/options/material_opacity/route.ts -export const dynamic = "force-dynamic"; +import { NextRequest } from "next/server"; +import { dFetchJSON, applyQFilter, json, Option } from "../_lib"; -import { NextRequest, NextResponse } from "next/server"; -const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +type Row = { id: number | string; opacity?: string | null }; export async function GET(req: NextRequest) { - try { - const userAt = req.cookies.get("ma_at")?.value; - if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - - const url = new URL(`${BASE}/items/material_opacity`); - url.searchParams.set("fields", "id,opacity"); - url.searchParams.set("sort", "opacity"); - - const res = await fetch(String(url), { - headers: { Accept: "application/json", Authorization: `Bearer ${userAt}` }, - cache: "no-store", - }); - - const text = await res.text().catch(() => ""); - const json = text ? JSON.parse(text) : {}; - if (!res.ok) { - return NextResponse.json({ error: `Directus ${res.status}: ${text || res.statusText}` }, { status: res.status }); - } - - const rows: Array<{ id: number | string; opacity?: string | number }> = json?.data ?? []; - const data = rows - .map(({ id, opacity }) => ({ id, label: String(opacity ?? "") })) - .filter((x) => x.label); - - return NextResponse.json({ data }); - } catch (e: any) { - return NextResponse.json({ error: e?.message || "Failed to load opacity options" }, { status: 500 }); - } + const q = new URL(req.url).searchParams.get("q"); + // Ensure role can read id,opacity on this collection. + const { data } = await dFetchJSON<{ data: Row[] }>(req, "/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity"); + const options: Option[] = data.map((r) => ({ id: r.id, label: r.opacity ?? String(r.id) })); + return json(applyQFilter(options, q, (o) => o.label)); } diff --git a/app/api/user/me/route.ts b/app/api/user/me/route.ts new file mode 100644 index 00000000..dad9f214 --- /dev/null +++ b/app/api/user/me/route.ts @@ -0,0 +1,7 @@ +import { NextRequest, NextResponse } from "next/server"; +import { dFetchJSON } from "../../options/_lib"; + +export async function GET(req: NextRequest) { + const body = await dFetchJSON(req, "/users/me?fields=id,username,display_name,first_name,last_name,email"); + return NextResponse.json(body, { headers: { "cache-control": "no-store" } }); +} diff --git a/app/components/forms/SettingsSubmit.tsx b/app/components/forms/SettingsSubmit.tsx index 93f8c340..449fbc3b 100644 --- a/app/components/forms/SettingsSubmit.tsx +++ b/app/components/forms/SettingsSubmit.tsx @@ -15,6 +15,8 @@ type Me = { email?: string; }; +const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); + function shortId(s?: string) { if (!s) return ""; return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`; @@ -25,44 +27,125 @@ function useOptions(path: string) { const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); + // helpers for the "laser_source" nm filtering + const parseNum = (v: any): number | null => { + if (v == null) return null; + const m = String(v).match(/(\d+(\.\d+)?)/); + return m ? Number(m[1]) : null; + }; + const nmRangeFor = (target?: string | null): [number, number] | null => { + if (!target) return null; + const t = target.toLowerCase(); + if (t === "fiber") return [1000, 9000]; + if (t === "uv") return [300, 400]; + if (t === "co2-gantry" || t === "co2-galvo") return [10000, 11000]; + return null; + }; + useEffect(() => { let alive = true; setLoading(true); - const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`; - fetch(url, { cache: "no-store", credentials: "include" }) - .then((r) => r.json()) - .then((j) => { - if (!alive) return; - const raw = (j?.data ?? j) as any[]; + (async () => { + const [rawPath, qs] = path.split("?", 2); + const params = new URLSearchParams(qs || ""); + const target = params.get("target") || ""; - const normalized: Opt[] = Array.isArray(raw) - ? raw - .map((x) => { - const id = String(x?.id ?? x?.value ?? x?.key ?? "").trim(); + let url = ""; + let normalize: (rows: any[]) => Opt[] = (rows) => + rows.map((r) => ({ + id: String(r.id), + label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id), + })); - // Accept common label fields + "opacity" (string field) - let label = - (x?.label ?? - x?.name ?? - x?.title ?? - x?.text ?? - x?.opacity) as string | undefined; - - // Nice fallback for sources where you have make/model - if (!label && (x?.make || x?.model)) { - label = [x.make, x.model].filter(Boolean).join(" "); + if (rawPath === "material") { + url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "material_color") { + url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "material_coating") { + url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "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 (rawPath === "laser_software") { + url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; + } else if (rawPath === "laser_source") { + // fetch all and client-filter by nm until/if a numeric mirror field exists + url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; + const range = nmRangeFor(target); + 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 (rawPath === "lens") { + // Switch collections by target: CO2 Gantry uses laser_focus_lens, others use laser_scan_lens + if (target === "co2-gantry") { + url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000&sort=name`; + normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.name ?? r.id) })); + } else { + url = `${API}/items/laser_scan_lens?fields=id,name,field_size,focal_length&limit=1000&sort=name`; + normalize = (rows) => + rows.map((r) => { + const fs = r.field_size != null ? `${r.field_size}` : ""; + const fl = r.focal_length != null ? `${r.focal_length}` : ""; + const composed = r.name ?? [fs && `${fs} mm`, fl && `${fl} mm`].filter(Boolean).join(" — "); + return { id: String(r.id), label: composed || String(r.id) }; + }); } + } else if (rawPath === "repeater-choices") { + // reads from fields meta: target=, group=, field= + const group = params.get("group") || ""; + const field = params.get("field") || ""; + const fieldsUrl = `${API}/fields/${encodeURIComponent(target)}?fields=collection,field,meta`; + const metaRes = await fetch(fieldsUrl, { cache: "no-store", credentials: "include" }); + if (!metaRes.ok) throw new Error(`Directus ${metaRes.status} fetching ${fieldsUrl}`); + const metaJson = await metaRes.json(); + const rows = metaJson?.data ?? []; + const fullField = `${group}.${field}`; + const def = rows.find((r: any) => r?.field === fullField); + const choices: any[] = def?.meta?.options?.choices || []; + const mapped: Opt[] = choices.map((c: any) => ({ + id: String(c.value ?? c.key ?? c.id), + label: String(c.text ?? c.label ?? c.name ?? c.value), + })); - label = String(label ?? "").trim(); - return { id, label }; - }) - .filter((o) => o.id && o.label) - : []; + if (alive) { + const needle = (q || "").trim().toLowerCase(); + const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; + setOpts(filtered); + setLoading(false); + } + return; + } else { + // unknown path → empty + setOpts([]); + setLoading(false); + return; + } - setOpts(normalized); - }) + 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 ?? []; + const mapped = normalize(rows); + + // client-side text filter + const needle = (q || "").trim().toLowerCase(); + const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; + + if (alive) setOpts(filtered); + })() + .catch(() => alive && setOpts([])) .finally(() => alive && setLoading(false)); + return () => { alive = false; }; @@ -141,7 +224,7 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const initialFromQuery = (sp.get("target") as Target) || initialTarget || "settings_fiber"; const [target, setTarget] = useState(initialFromQuery); - // Map collection -> slug used by options endpoints + // Map collection -> slug used by options selectors const typeForOptions = useMemo(() => { switch (target) { case "settings_fiber": @@ -172,7 +255,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ useEffect(() => { let alive = true; - fetch("/api/me", { cache: "no-store", credentials: "include" }) + fetch(`${API}/users/me?fields=id,username,display_name,first_name,last_name,email`, { + cache: "no-store", + credentials: "include", + }) .then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((j) => { if (alive) setMe(j?.data || j || null); @@ -192,505 +278,504 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ (me?.display_name?.trim()) || (me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "Unknown user"); + // Options + const mats = useOptions("material"); + const coats = useOptions("material_coating"); + const colors = useOptions("material_color"); + const opacs = useOptions("material_opacity"); + const soft = useOptions("laser_software"); // required for ALL targets - // Options - const mats = useOptions("material"); - const coats = useOptions("material_coating"); - const colors = useOptions("material_color"); - const opacs = useOptions("material_opacity"); - const soft = useOptions("laser_software"); // required for ALL targets + // these two need ?target= + const srcs = useOptions(`laser_source?target=${typeForOptions}`); + const lens = useOptions(`lens?target=${typeForOptions}`); - // these two need ?target= - const srcs = useOptions(`laser_source?target=${typeForOptions}`); - const lens = useOptions(`lens?target=${typeForOptions}`); + // Repeater choice options + const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); + const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); + const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); - // Repeater choice options - const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); - const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); - const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); + const { + register, + handleSubmit, + control, + reset, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + setting_title: "", + setting_notes: "", + mat: "", + mat_coat: "", + mat_color: "", + mat_opacity: "", + mat_thickness: "", + source: "", + lens: "", + focus: "", + laser_soft: "", + repeat_all: "", // on all targets + fill_settings: [], + line_settings: [], + raster_settings: [], + }, + }); - const { - register, - handleSubmit, - control, - reset, - formState: { isSubmitting }, - } = useForm({ - defaultValues: { - setting_title: "", - setting_notes: "", - mat: "", - mat_coat: "", - mat_color: "", - mat_opacity: "", - mat_thickness: "", - source: "", - lens: "", - focus: "", - laser_soft: "", - repeat_all: "", // on all targets - fill_settings: [], - line_settings: [], - raster_settings: [], - }, - }); + const fills = useFieldArray({ control, name: "fill_settings" }); + const lines = useFieldArray({ control, name: "line_settings" }); + const rasters = useFieldArray({ control, name: "raster_settings" }); - const fills = useFieldArray({ control, name: "fill_settings" }); - const lines = useFieldArray({ control, name: "line_settings" }); - const rasters = useFieldArray({ control, name: "raster_settings" }); + function num(v: any) { + return v === "" || v == null ? null : Number(v); + } + const bool = (v: any) => !!v; - function num(v: any) { - return v === "" || v == null ? null : Number(v); - } - const bool = (v: any) => !!v; + async function onSubmit(values: any) { + setSubmitErr(null); - async function onSubmit(values: any) { - setSubmitErr(null); - - if (!photoFile) { - (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); - return; - } - - const payload: any = { - target, - setting_title: values.setting_title, - setting_notes: values.setting_notes || "", - 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), - source: values.source || null, - lens: values.lens || null, - focus: num(values.focus), - laser_soft: values.laser_soft || null, // all targets - repeat_all: num(values.repeat_all), // all targets - 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: r.cut || "", - skip: 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), - })), - }; - - try { - let res: Response; - if (photoFile || screenFile) { - 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"); - res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); - } else { - res = await fetch("/api/submit/settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - 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"); - } - - reset(); - setPhotoFile(null); - setScreenFile(null); - setPhotoPreview(""); - setScreenPreview(""); - - const id = data?.id ? String(data.id) : ""; - router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); - } catch (e: any) { - setSubmitErr(e?.message || "Submission failed"); - } + if (!photoFile) { + (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); + return; } - function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) { - setFile(file); - if (!file) { - setPreview(""); - return; + const payload: any = { + target, + setting_title: values.setting_title, + setting_notes: values.setting_notes || "", + 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), + source: values.source || null, + lens: values.lens || null, + focus: num(values.focus), + laser_soft: values.laser_soft || null, // all targets + repeat_all: num(values.repeat_all), // all targets + 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: r.cut || "", + skip: 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), + })), + }; + + try { + let res: Response; + if (photoFile || screenFile) { + 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"); + res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); + } else { + res = await fetch("/api/submit/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }); } - const reader = new FileReader(); - reader.onload = () => setPreview(String(reader.result || "")); - reader.readAsDataURL(file); + + 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"); + } + + reset(); + setPhotoFile(null); + setScreenFile(null); + setPhotoPreview(""); + setScreenPreview(""); + + const id = data?.id ? String(data.id) : ""; + router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); + } catch (e: any) { + setSubmitErr(e?.message || "Submission failed"); } + } - return ( -
- {/* Target + Software (Software required for ALL targets) */} -
-
- - + 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); + } + + return ( +
+ {/* Target + Software (Software required for ALL targets) */} +
+
+ + +
+ +
+ +
+
+ + {/* Submitting-as banner */} + {me ? ( +
+ Submitting as {meLabel}.
+ ) : meErr ? ( +
+ You’re not signed in. Submissions will fail until you sign in. +
+ ) : null} -
+ {submitErr ? ( +
{submitErr}
+ ) : null} + +
+ {/* Title */} +
+
+ + +
+
+ + {/* Images */} +
+
+ + onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)} + /> +

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

+ {photoPreview ? Result preview : null} +
+
+ + onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)} + /> +

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

+ {screenPreview ? Settings preview : null} +
+
+ + {/* Notes */} +
+ +