makearmy-app/app/portal/my-settings/page.tsx

248 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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">
Couldnt 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>
);
}