v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue