397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
|
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 (
|
|
<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">
|
|
<span className="font-semibold text-[#26454c]">
|
|
{feedbackTypeLabel(row.feedback_type)}
|
|
</span>
|
|
<span
|
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
STATUS_STYLE[row.status] ?? ""
|
|
}`}
|
|
>
|
|
{row.status}
|
|
</span>
|
|
<span className="text-[#4a6b72]">{locationOf(row)}</span>
|
|
<span className="ml-auto text-xs text-[#4a6b72]">
|
|
#{row.id} · {formatDate(row.created_at)}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="mt-4 whitespace-pre-wrap text-[#26454c]">{row.message}</p>
|
|
|
|
<p className="mt-4 text-sm text-[#4a6b72]">
|
|
{row.name || row.email ? (
|
|
<>
|
|
{row.name && <span>{row.name}</span>}
|
|
{row.name && row.email && " · "}
|
|
{row.email && (
|
|
<a
|
|
href={`mailto:${row.email}?subject=Your%20NGU%20site%20feedback`}
|
|
className="text-[#138ba0] underline underline-offset-2"
|
|
>
|
|
{row.email}
|
|
</a>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="italic">Sent anonymously</span>
|
|
)}
|
|
</p>
|
|
|
|
{canWrite && (
|
|
<div className="mt-5 border-t border-[#4a6b72]/15 pt-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<label
|
|
htmlFor={`status-${row.id}`}
|
|
className="text-sm font-medium text-[#26454c]"
|
|
>
|
|
Status
|
|
</label>
|
|
<select
|
|
id={`status-${row.id}`}
|
|
value={row.status}
|
|
disabled={busy}
|
|
onChange={(e) => save({ status: e.target.value })}
|
|
className="rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
|
>
|
|
{STATUSES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<textarea
|
|
rows={2}
|
|
value={note}
|
|
disabled={busy}
|
|
placeholder="Internal note — who's handling it, what was done"
|
|
onChange={(e) => setNote(e.target.value)}
|
|
className="mt-3 w-full resize-y rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
|
/>
|
|
{noteDirty && (
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => save({ admin_note: note })}
|
|
className="mt-2 rounded-full bg-[#138ba0] px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
|
|
>
|
|
{busy ? "Saving…" : "Save note"}
|
|
</button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/* ── The page ────────────────────────────────────────────────── */
|
|
|
|
export default function AdminFeedback() {
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [status, setStatus] = useState("new");
|
|
const [query, setQuery] = useState("");
|
|
const [search, setSearch] = useState(""); // applied, not typed
|
|
|
|
const [rows, setRows] = useState([]);
|
|
const [counts, setCounts] = useState({});
|
|
const [cursor, setCursor] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
// Minimums, not equality — see lib/roles.ts.
|
|
const canWrite = roleCanWrite(user);
|
|
const canRemove = canDelete(user);
|
|
|
|
const load = useCallback(
|
|
async (before = null) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const params = new URLSearchParams();
|
|
if (status !== "all") params.set("status", status);
|
|
if (search) params.set("q", search);
|
|
if (before) params.set("before", String(before));
|
|
|
|
try {
|
|
const data = await get(`/admin/feedback?${params}`, { ttl: 0 });
|
|
setRows((prev) => (before ? [...prev, ...data.feedback] : data.feedback));
|
|
setCounts(data.counts);
|
|
setCursor(data.nextCursor);
|
|
} catch (err) {
|
|
if (isUnauthorized(err)) {
|
|
// Session expired while the page was open.
|
|
navigate("/admin/login", { replace: true });
|
|
return;
|
|
}
|
|
setError(
|
|
err instanceof ApiError ? err.message : "Couldn't reach the server.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[status, search, navigate],
|
|
);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
function replaceRow(updated) {
|
|
setRows((prev) =>
|
|
prev
|
|
.map((row) => (row.id === updated.id ? updated : row))
|
|
// A row that no longer matches the filter drops out, so
|
|
// marking something 'read' clears it from the 'new' queue.
|
|
.filter((row) => status === "all" || row.status === status),
|
|
);
|
|
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] })),
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-[#138ba0]">Feedback</h1>
|
|
<p className="mt-2 text-sm text-[#4a6b72]">
|
|
{canWrite
|
|
? "Everything submitted through the site form."
|
|
: "Read-only: your account can't change statuses or notes."}
|
|
</p>
|
|
|
|
{/* Filters */}
|
|
<div className="mt-6 flex flex-wrap items-center gap-2">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setStatus(tab.id)}
|
|
className={
|
|
"rounded-full px-4 py-1.5 text-sm transition-colors " +
|
|
(status === tab.id
|
|
? "bg-[#138ba0] font-semibold text-white"
|
|
: "border border-[#4a6b72]/25 text-[#4a6b72] hover:border-[#138ba0]/50")
|
|
}
|
|
>
|
|
{tab.label}
|
|
{tab.count ? ` (${tab.count})` : ""}
|
|
</button>
|
|
))}
|
|
|
|
<div className="ml-auto flex gap-2">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
placeholder="Search messages"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") setSearch(query.trim());
|
|
}}
|
|
className="rounded-full border border-[#4a6b72]/25 bg-white px-4 py-1.5 text-sm text-[#26454c] outline-none focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearch(query.trim())}
|
|
className="rounded-full border border-[#4a6b72]/25 px-4 py-1.5 text-sm text-[#4a6b72] transition-colors hover:border-[#138ba0]/50"
|
|
>
|
|
Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
className="mt-6 rounded-xl border border-[#b3261e]/30 bg-[#fdf3f2] px-4 py-3 text-sm text-[#b3261e]"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{!loading && rows.length === 0 && !error && (
|
|
<p className="mt-10 text-[#4a6b72]">
|
|
Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-6 space-y-4">
|
|
{rows.map((row) => (
|
|
<FeedbackCard
|
|
key={row.id}
|
|
row={row}
|
|
canWrite={canWrite}
|
|
canRemove={canRemove}
|
|
onChange={replaceRow}
|
|
onRemove={removeRow}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{loading && <p className="mt-6 text-sm text-[#4a6b72]">Loading…</p>}
|
|
|
|
{cursor && !loading && (
|
|
<button
|
|
type="button"
|
|
onClick={() => load(cursor)}
|
|
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
|
>
|
|
Load older
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|