From b41fbd98a0c5886ff8296f47476aaccaa75d7765 Mon Sep 17 00:00:00 2001 From: makearmy Date: Thu, 2 Oct 2025 22:15:06 -0400 Subject: [PATCH] my-settings tab update --- app/api/dx/[...path]/route.ts | 77 ++++++++-- app/portal/my-settings/page.tsx | 189 +++++++++++++++++++++++++ components/portal/SettingsSwitcher.tsx | 32 ++++- 3 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 app/portal/my-settings/page.tsx diff --git a/app/api/dx/[...path]/route.ts b/app/api/dx/[...path]/route.ts index 43eb5d02..1cb7dc24 100644 --- a/app/api/dx/[...path]/route.ts +++ b/app/api/dx/[...path]/route.ts @@ -1,22 +1,23 @@ // app/api/dx/[...path]/route.ts import { NextResponse } from "next/server"; -import { dxGET } from "@/lib/directus"; +import { dxGET, dxPOST, dxPATCH, dxDELETE } from "@/lib/directus"; import { requireBearer } from "@/app/api/_lib/auth"; export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; // proxy to Directus; never static +export const dynamic = "force-dynamic"; // these are true proxies, never prerender -// GET /api/dx/? -// Proxies to Directus using the user's ma_at, no caching. +function buildPath(req: Request, context: any) { + const search = new URL(req.url).search || ""; + const params = (context?.params ?? {}) as { path?: string[] }; + const pathParts = Array.isArray(params.path) ? params.path : []; + return `/${pathParts.join("/")}${search}`; +} + +// GET /api/dx/? export async function GET(req: Request, context: any) { try { - const bearer = requireBearer(req); // ← pulls ma_at from Cookie - const search = new URL(req.url).search || ""; - const params = (context?.params ?? {}) as { path?: string[] }; - - const pathParts = Array.isArray(params.path) ? params.path : []; - const p = `/${pathParts.join("/")}${search}`; - + const bearer = requireBearer(req); + const p = buildPath(req, context); const json = await dxGET(p, bearer); return NextResponse.json(json, { status: 200 }); } catch (e: any) { @@ -27,3 +28,57 @@ export async function GET(req: Request, context: any) { ); } } + +// POST JSON → /api/dx/ +export async function POST(req: Request, context: any) { + try { + const bearer = requireBearer(req); + const p = buildPath(req, context); + const body = await req.json().catch(() => { + throw new Error("Invalid JSON body"); + }); + const json = await dxPOST(p, bearer, body); + return NextResponse.json(json, { status: 200 }); + } catch (e: any) { + const status = e?.status ?? 500; + return NextResponse.json( + { errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] }, + { status } + ); + } +} + +// PATCH JSON → /api/dx/ +export async function PATCH(req: Request, context: any) { + try { + const bearer = requireBearer(req); + const p = buildPath(req, context); + const body = await req.json().catch(() => { + throw new Error("Invalid JSON body"); + }); + const json = await dxPATCH(p, bearer, body); + return NextResponse.json(json, { status: 200 }); + } catch (e: any) { + const status = e?.status ?? 500; + return NextResponse.json( + { errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] }, + { status } + ); + } +} + +// DELETE → /api/dx/ +export async function DELETE(req: Request, context: any) { + try { + const bearer = requireBearer(req); + const p = buildPath(req, context); + const json = await dxDELETE(p, bearer); + return NextResponse.json(json, { status: 200 }); + } catch (e: any) { + const status = e?.status ?? 500; + return NextResponse.json( + { errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] }, + { status } + ); + } +} diff --git a/app/portal/my-settings/page.tsx b/app/portal/my-settings/page.tsx new file mode 100644 index 00000000..6e9755df --- /dev/null +++ b/app/portal/my-settings/page.tsx @@ -0,0 +1,189 @@ +// app/portal/my-settings/page.tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; + +type Row = { + id: string | number; + submission_id?: string | number | null; + setting_title?: string | null; + status?: string | null; + last_modified_date?: string | null; + collection: "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv"; +}; + +const COLLECTIONS: Array = [ + "settings_co2gal", +"settings_co2gan", +"settings_fiber", +"settings_uv", +]; + +const LABEL: Record = { + settings_co2gal: "CO₂ Galvo", + settings_co2gan: "CO₂ Gantry", + settings_fiber: "Fiber", + settings_uv: "UV", +}; + +// Route to the existing detail page for view/edit (you can customize) +function detailHref(row: Row) { + const subId = row.submission_id ?? row.id; + switch (row.collection) { + case "settings_co2gal": return `/settings/co2-galvo/${subId}?edit=1`; + case "settings_co2gan": return `/settings/co2-gantry/${subId}?edit=1`; + case "settings_fiber": return `/settings/fiber/${subId}?edit=1`; + case "settings_uv": return `/settings/uv/${subId}?edit=1`; + } +} + +export default function MySettingsPage() { + const [loading, setLoading] = useState(true); + const [me, setMe] = useState<{ id: string; username?: string | null } | null>(null); + const [rows, setRows] = useState([]); + const [q, setQ] = useState(""); + + // 1) get current user id + useEffect(() => { + let canceled = false; + (async () => { + try { + const r = await fetch(`/api/dx/users/me?fields=id,username`, { + credentials: "include", + cache: "no-store", + }); + const j = await r.json(); + const id = j?.data?.id ?? j?.id; + if (!canceled) setMe(id ? { id: String(id), username: j?.data?.username ?? j?.username } : null); + } catch { + if (!canceled) setMe(null); + } + })(); + return () => { canceled = true; }; + }, []); + + // 2) fetch my settings from each collection + useEffect(() => { + if (!me?.id) return; + let canceled = false; + setLoading(true); + + (async () => { + const all: Row[] = []; + for (const coll of COLLECTIONS) { + const url = new URL(`/api/dx/items/${coll}`, window.location.origin); + url.searchParams.set("limit", "-1"); + url.searchParams.set("sort", "-last_modified_date"); + url.searchParams.set("fields", "id,submission_id,setting_title,status,last_modified_date"); + url.searchParams.set("filter[owner][_eq]", me.id); + + try { + const r = await fetch(url.toString(), { credentials: "include", cache: "no-store" }); + const j = await r.json(); + const data = Array.isArray(j?.data) ? j.data : []; + for (const item of data) { + all.push({ + id: item.id, + submission_id: item.submission_id ?? null, + setting_title: item.setting_title ?? null, + status: item.status ?? null, + last_modified_date: item.last_modified_date ?? null, + collection: coll, + }); + } + } catch (e) { + console.warn(`Failed to load ${coll}:`, e); + } + } + + if (!canceled) { + setRows(all); + setLoading(false); + } + })(); + + return () => { canceled = true; }; + }, [me?.id]); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((r) => + [r.setting_title, LABEL[r.collection], r.status, r.last_modified_date] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(needle)) + ); + }, [rows, q]); + + async function onDelete(row: Row) { + if (!confirm(`Delete "${row.setting_title || "Untitled"}" from ${LABEL[row.collection]}?`)) return; + try { + const r = await fetch(`/api/dx/items/${row.collection}/${row.id}`, { + method: "DELETE", + credentials: "include", + }); + if (!r.ok) { + const j = await r.json().catch(() => null); + throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`); + } + setRows((prev) => prev.filter((x) => !(x.collection === row.collection && String(x.id) === String(row.id)))); + } catch (e: any) { + alert(`Delete failed: ${e?.message || e}`); + } + } + + return ( +
+

My Settings

+ +
+ setQ(e.currentTarget.value)} + /> + {rows.length} total +
+ + {loading ? ( +

Loading…

+ ) : filtered.length === 0 ? ( +

No settings yet.

+ ) : ( +
+ + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + ))} + +
TitleCollectionStatusUpdatedActions
{r.setting_title || "Untitled"}{LABEL[r.collection]}{r.status || "—"}{r.last_modified_date ? new Date(r.last_modified_date).toLocaleString() : "—"} +
+ Edit + +
+
+
+ )} +
+ ); +} diff --git a/components/portal/SettingsSwitcher.tsx b/components/portal/SettingsSwitcher.tsx index fe4dc2f4..62217c86 100644 --- a/components/portal/SettingsSwitcher.tsx +++ b/components/portal/SettingsSwitcher.tsx @@ -18,18 +18,22 @@ const SettingsSubmit = dynamic( ); type DataTab = "fiber" | "uv" | "co2-gantry" | "co2-galvo"; -type Tab = DataTab | "add"; +type Tab = DataTab | "add" | "my"; const TABS: { key: Tab; label: string }[] = [ { key: "fiber", label: "Fiber" }, { key: "uv", label: "UV" }, { key: "co2-gantry", label: "CO₂ Gantry" }, { key: "co2-galvo", label: "CO₂ Galvo" }, +{ key: "my", label: "My Settings" }, // ← NEW { key: "add", label: "Add Setting" }, ]; const isDataTab = (k: string): k is DataTab => -k === "fiber" || k === "uv" || k === "co2-gantry" || k === "co2-galvo"; +k === "fiber" || k === "uv" || k === "co2-ganry" || k === "co2-gantry" || k === "co2-galvo" +// keep typo guard for "co2-ganry" if you want; remove if unnecessary +? true +: (k === "fiber" || k === "uv" || k === "co2-gantry" || k === "co2-galvo"); const tabToTarget: Record ); + case "my": + // We navigate away for "My Settings", so this branch is not normally rendered. + // Fallback content (just in case someone forces ?t=my on this page): + return ( +
+

+ Opening My Settings… +

+
+ ); default: return ; } @@ -67,21 +81,25 @@ export default function SettingsSwitcher() { // last data tab is taken from ?last=… (or fallback to current active if it’s a data tab) const lastParam = (sp.get("last") || (isDataTab(active) ? active : "fiber")).toLowerCase(); - const lastDataTab: DataTab = isDataTab(lastParam) ? lastParam : "fiber"; + const lastDataTab: DataTab = isDataTab(lastParam) ? (lastParam as DataTab) : "fiber"; function setTab(nextKey: Tab) { + // NEW: "My Settings" navigates to its own page + if (nextKey === "my") { + router.push("/portal/my-settings"); + return; + } + const q = new URLSearchParams(sp.toString()); q.set("t", nextKey); // keep track of last data tab so the Add tab knows which target to preselect if (nextKey === "add") { - q.set("last", isDataTab(active) ? active : lastDataTab); - } else { + q.set("last", isDataTab(active) ? (active as DataTab) : lastDataTab); + } else if (isDataTab(nextKey)) { q.set("last", nextKey); } - // optional: clear detail id if switching away from a data tab you don’t want to show - // (leaving as-is preserves your existing behavior) router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false }); }