{row.message}
{row.name || row.email ? ( <> {row.name && {row.name}} {row.name && row.email && " · "} {row.email && ( {row.email} )} > ) : ( Sent anonymously )}
{canWrite && ({error}
)}/* ═══════════════════════════════════════════════════════════════
ADMIN — FEEDBACK TRIAGE
Reads /api/admin/feedback, writes status and notes back through
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
working at the same time is the opposite case.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
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"];
const STATUS_STYLE = {
new: "bg-[#138ba0] text-white",
read: "bg-[#eef9fb] text-[#138ba0]",
actioned: "bg-[#eaf3e2] text-[#4a6b2f]",
archived: "bg-[#4a6b72]/10 text-[#4a6b72]",
spam: "bg-[#fdf3f2] text-[#b3261e]",
};
// created_at is UTC in 'YYYY-MM-DD HH:MM:SS' form, which Safari
// won't parse without the T and the Z.
function formatDate(value) {
const date = new Date(`${value.replace(" ", "T")}Z`);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
function locationOf(row) {
if (!row.page_path) return "Not page-specific";
return row.section_id ? `${row.page_path} #${row.section_id}` : row.page_path;
}
/* ── One submission ──────────────────────────────────────────── */
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 ?? "");
async function save(changes) {
setBusy(true);
setError(null);
try {
const data = await patch(`/admin/feedback/${row.id}`, changes);
onChange(data.feedback);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't save that.");
} finally {
setBusy(false);
}
}
// 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 (
{row.message}
{row.name || row.email ? (
<>
{row.name && {row.name}}
{row.name && row.email && " · "}
{row.email && (
{row.email}
)}
>
) : (
Sent anonymously
)}
{error}
{canWrite ? "Everything submitted through the site form." : "Read-only: your account can't change statuses or notes."}
{/* Filters */}{error}
)} {!loading && rows.length === 0 && !error && (Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
)}Loading…
} {cursor && !loading && ( )}