v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -2,8 +2,15 @@
ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through
PATCH. Deliberately a flat list rather than a table: the message
is the content, and messages don't fit in a cell.
PATCH, and deletes through DELETE. Deliberately a flat list
rather than a table: the message is the content, and messages
don't fit in a cell.
Two capabilities, two ranks. Editors and above can change a
status or leave a note; deleting is admin and above, matching
requireRole on the server. Both come from the roles ladder
rather than an equality check — a superadmin is not role ===
"admin", and reading it that way is what hid these controls.
Every read passes ttl: 0. The api cache exists for public
content that changes weekly; a triage queue two people are
@ -13,8 +20,9 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { get, patch, ApiError } from "../../lib/api.js";
import { del, get, patch, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
@ -44,10 +52,11 @@ function locationOf(row) {
/* ── One submission ──────────────────────────────────────────── */
function FeedbackCard({ row, onChange, canWrite }) {
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
const [note, setNote] = useState(row.admin_note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [confirming, setConfirming] = useState(false);
const noteDirty = note !== (row.admin_note ?? "");
@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) {
}
}
// On success this card unmounts, so there's no finally here:
// busy only needs clearing on the path where the row survives.
async function remove() {
setBusy(true);
setError(null);
try {
await del(`/admin/feedback/${row.id}`);
onRemove(row);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't delete that.");
setConfirming(false);
setBusy(false);
}
}
return (
<article className="rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) {
</option>
))}
</select>
{error && <span className="text-sm text-[#b3261e]">{error}</span>}
</div>
<textarea
@ -150,6 +172,52 @@ function FeedbackCard({ row, onChange, canWrite }) {
)}
</div>
)}
{/* Deleting is the irreversible option; marking something
spam or archived is the habit this defers to. Hence the
second click rather than a window.confirm. */}
{canRemove && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[#4a6b72]/15 pt-4">
{confirming ? (
<>
<span className="text-sm text-[#26454c]">
Delete #{row.id} for good? Marking it spam keeps it recoverable.
</span>
<button
type="button"
disabled={busy}
onClick={remove}
className="rounded-full bg-[#b3261e] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#8f1e18] disabled:bg-[#4a6b72]/25"
>
{busy ? "Deleting…" : "Delete"}
</button>
<button
type="button"
disabled={busy}
onClick={() => setConfirming(false)}
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#4a6b72]/50"
>
Keep it
</button>
</>
) : (
<button
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="rounded-full border border-[#b3261e]/30 px-4 py-1.5 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</div>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-[#b3261e]">
{error}
</p>
)}
</article>
);
}
@ -170,7 +238,9 @@ export default function AdminFeedback() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const canWrite = user?.role === "admin";
// Minimums, not equality — see lib/roles.ts.
const canWrite = roleCanWrite(user);
const canRemove = canDelete(user);
const load = useCallback(
async (before = null) => {
@ -218,6 +288,16 @@ export default function AdminFeedback() {
setCounts((prev) => ({ ...prev })); // counts refresh on next load
}
// The deleted row is passed whole rather than by id: its status
// is what says which tab count to drop.
function removeRow(removed) {
setRows((prev) => prev.filter((row) => row.id !== removed.id));
setCounts((prev) => ({
...prev,
[removed.status]: Math.max((prev[removed.status] ?? 1) - 1, 0),
}));
}
const tabs = [
{ id: "all", label: "All" },
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
@ -294,7 +374,9 @@ export default function AdminFeedback() {
key={row.id}
row={row}
canWrite={canWrite}
canRemove={canRemove}
onChange={replaceRow}
onRemove={removeRow}
/>
))}
</div>