248 lines
9.7 KiB
TypeScript
248 lines
9.7 KiB
TypeScript
// app/portal/my-settings/page.tsx
|
||
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
|
||
type Coll = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv";
|
||
|
||
type Row = {
|
||
submission_id: string | number;
|
||
setting_title?: string | null;
|
||
last_modified_date?: string | null;
|
||
// NOTE: we intentionally do NOT request id or status due to permissions
|
||
};
|
||
|
||
const COLLECTIONS: Coll[] = ["settings_co2gal", "settings_co2gan", "settings_fiber", "settings_uv"];
|
||
const LABEL: Record<Coll, string> = {
|
||
settings_co2gal: "CO₂ Galvo",
|
||
settings_co2gan: "CO₂ Gantry",
|
||
settings_fiber: "Fiber",
|
||
settings_uv: "UV",
|
||
};
|
||
|
||
function detailHref(coll: Coll, submissionId: string | number | null | undefined) {
|
||
const sid = submissionId ?? "";
|
||
switch (coll) {
|
||
case "settings_co2gal": return `/settings/co2-galvo/${sid}?edit=1`;
|
||
case "settings_co2gan": return `/settings/co2-gantry/${sid}?edit=1`;
|
||
case "settings_fiber": return `/settings/fiber/${sid}?edit=1`;
|
||
case "settings_uv": return `/settings/uv/${sid}?edit=1`;
|
||
}
|
||
}
|
||
|
||
export default function MySettingsPage() {
|
||
const [loading, setLoading] = useState(true);
|
||
const [meId, setMeId] = useState<string | null>(null);
|
||
const [meUsername, setMeUsername] = useState<string | null>(null); // used only for display
|
||
const [q, setQ] = useState("");
|
||
const [byColl, setByColl] = useState<Record<Coll, Row[]>>({
|
||
settings_co2gal: [],
|
||
settings_co2gan: [],
|
||
settings_fiber: [],
|
||
settings_uv: [],
|
||
});
|
||
const [errs, setErrs] = useState<Record<Coll | "me", string | null>>({
|
||
me: null,
|
||
settings_co2gal: null,
|
||
settings_co2gan: null,
|
||
settings_fiber: null,
|
||
settings_uv: null,
|
||
});
|
||
|
||
async function readJson(res: Response) {
|
||
const text = await res.text();
|
||
try {
|
||
return text ? JSON.parse(text) : null;
|
||
} catch {
|
||
throw new Error(`Unexpected response (HTTP ${res.status})`);
|
||
}
|
||
}
|
||
|
||
// 1) Load current user id + username
|
||
useEffect(() => {
|
||
let dead = false;
|
||
(async () => {
|
||
try {
|
||
const r = await fetch(`/api/dx/users/me?fields=id,username`, { credentials: "include", cache: "no-store" });
|
||
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 id = j?.data?.id ?? j?.id ?? null;
|
||
const un = j?.data?.username ?? j?.username ?? null;
|
||
if (!dead) {
|
||
setMeId(id ? String(id) : null);
|
||
setMeUsername(un ? String(un) : null);
|
||
setErrs((e) => ({ ...e, me: null }));
|
||
}
|
||
} catch (e: any) {
|
||
if (!dead) setErrs((er) => ({ ...er, me: e?.message || String(e) }));
|
||
}
|
||
})();
|
||
return () => { dead = true; };
|
||
}, []);
|
||
|
||
// 2) Load my items per collection (STRICT owner-only)
|
||
useEffect(() => {
|
||
if (!meId) return; // we require the user id to filter by owner
|
||
let dead = false;
|
||
setLoading(true);
|
||
setErrs((e) => ({ ...e, settings_co2gal: null, settings_co2gan: null, settings_fiber: null, settings_uv: null }));
|
||
|
||
(async () => {
|
||
const acc: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
|
||
|
||
for (const coll of COLLECTIONS) {
|
||
const qs = new URLSearchParams();
|
||
qs.set("limit", "-1");
|
||
qs.set("sort", "-last_modified_date");
|
||
qs.set("fields", "submission_id,setting_title,last_modified_date"); // minimal, safe fields
|
||
|
||
// STRICT owner filter. We OR the two shapes:
|
||
// - owner stored as primitive id
|
||
// - owner stored as relation object { id: ... }
|
||
qs.set(`filter[_or][0][owner][_eq]`, meId);
|
||
qs.set(`filter[_or][1][owner][id][_eq]`, meId);
|
||
|
||
const url = `/api/dx/items/${coll}?${qs.toString()}`;
|
||
|
||
try {
|
||
const r = await fetch(url, { credentials: "include", cache: "no-store" });
|
||
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 rows: Row[] = Array.isArray(j?.data) ? j.data : [];
|
||
acc[coll] = rows;
|
||
} catch (e: any) {
|
||
acc[coll] = [];
|
||
setErrs((er) => ({ ...er, [coll]: e?.message || String(e) }));
|
||
}
|
||
}
|
||
|
||
if (!dead) {
|
||
setByColl(acc);
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => { dead = true; };
|
||
}, [meId]);
|
||
|
||
// 3) Filter client-side
|
||
const filtered = useMemo(() => {
|
||
const needle = q.trim().toLowerCase();
|
||
if (!needle) return byColl;
|
||
const out: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
|
||
for (const coll of COLLECTIONS) {
|
||
out[coll] = (byColl[coll] || []).filter(r =>
|
||
[r.setting_title, r.last_modified_date]
|
||
.filter(Boolean)
|
||
.some(v => String(v).toLowerCase().includes(needle))
|
||
);
|
||
}
|
||
return out;
|
||
}, [byColl, q]);
|
||
|
||
async function onDelete(coll: Coll, row: Row) {
|
||
if (!row.submission_id) return alert("Missing submission id.");
|
||
if (!confirm(`Delete "${row.setting_title || "Untitled"}" from ${LABEL[coll]}?`)) return;
|
||
|
||
try {
|
||
const r = await fetch(`/api/my-settings/delete`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ collection: coll, submission_id: row.submission_id }),
|
||
});
|
||
const j = await readJson(r).catch(() => null);
|
||
if (!r.ok || !j?.ok) {
|
||
throw new Error(j?.error || `HTTP ${r.status}`);
|
||
}
|
||
setByColl(prev => ({
|
||
...prev,
|
||
[coll]: prev[coll].filter(x => String(x.submission_id) !== String(row.submission_id))
|
||
}));
|
||
} catch (e: any) {
|
||
alert(`Delete failed: ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
const total = COLLECTIONS.reduce((n, c) => n + (filtered[c]?.length || 0), 0);
|
||
|
||
return (
|
||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||
<h1 className="text-2xl font-semibold mb-4">My Settings</h1>
|
||
|
||
{!!errs.me && (
|
||
<div className="mb-4 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||
Couldn’t load your profile: {errs.me}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-4 flex items-center gap-3">
|
||
<input
|
||
className="border rounded px-3 py-2 w-full max-w-md"
|
||
placeholder="Search my settings…"
|
||
value={q}
|
||
onChange={(e) => setQ(e.currentTarget.value)}
|
||
/>
|
||
<span className="text-sm opacity-70">{total} total</span>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<p>Loading…</p>
|
||
) : (
|
||
COLLECTIONS.map((coll) => {
|
||
const rows = filtered[coll] || [];
|
||
return (
|
||
<section key={coll} className="mb-8">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">
|
||
{LABEL[coll]} <span className="text-xs opacity-70">({rows.length})</span>
|
||
</h2>
|
||
{!!errs[coll] && (
|
||
<span className="text-xs text-red-600">Error: {errs[coll]}</span>
|
||
)}
|
||
</div>
|
||
{rows.length === 0 ? (
|
||
<p className="opacity-70">No items.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-muted">
|
||
<th className="px-2 py-2 text-left">Title</th>
|
||
<th className="px-2 py-2 text-left">Updated</th>
|
||
<th className="px-2 py-2 text-left">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={`${coll}:${r.submission_id}`} className="border-b hover:bg-muted/30">
|
||
<td className="px-2 py-2">{r.setting_title || "Untitled"}</td>
|
||
<td className="px-2 py-2">
|
||
{r.last_modified_date ? new Date(r.last_modified_date).toLocaleString() : "—"}
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
<div className="flex items-center gap-3">
|
||
<Link href={detailHref(coll, r.submission_id)} className="underline">Edit</Link>
|
||
<button className="text-red-600 underline" onClick={() => onDelete(coll, r)}>Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
})
|
||
)}
|
||
</main>
|
||
);
|
||
}
|