/* ═══════════════════════════════════════════════════════════════ 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 (
{feedbackTypeLabel(row.feedback_type)} {row.status} {locationOf(row)} #{row.id} · {formatDate(row.created_at)}

{row.message}

{row.name || row.email ? ( <> {row.name && {row.name}} {row.name && row.email && " · "} {row.email && ( {row.email} )} ) : ( Sent anonymously )}

{canWrite && (