246 lines
8.2 KiB
TypeScript
246 lines
8.2 KiB
TypeScript
/**
|
|
* Admin content reports management page.
|
|
*
|
|
* Lists user-submitted issue reports with filtering by status,
|
|
* inline triage (acknowledge/resolve/dismiss), and admin notes.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
fetchReports,
|
|
updateReport,
|
|
type ContentReport,
|
|
} from "../api/public-client";
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "", label: "All" },
|
|
{ value: "open", label: "Open" },
|
|
{ value: "acknowledged", label: "Acknowledged" },
|
|
{ value: "resolved", label: "Resolved" },
|
|
{ value: "dismissed", label: "Dismissed" },
|
|
];
|
|
|
|
const STATUS_ACTIONS: Record<string, { label: string; next: string }[]> = {
|
|
open: [
|
|
{ label: "Acknowledge", next: "acknowledged" },
|
|
{ label: "Resolve", next: "resolved" },
|
|
{ label: "Dismiss", next: "dismissed" },
|
|
],
|
|
acknowledged: [
|
|
{ label: "Resolve", next: "resolved" },
|
|
{ label: "Dismiss", next: "dismissed" },
|
|
{ label: "Reopen", next: "open" },
|
|
],
|
|
resolved: [{ label: "Reopen", next: "open" }],
|
|
dismissed: [{ label: "Reopen", next: "open" }],
|
|
};
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function reportTypeLabel(rt: string): string {
|
|
return rt.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
|
}
|
|
|
|
export default function AdminReports() {
|
|
const [reports, setReports] = useState<ContentReport[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState("");
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [noteText, setNoteText] = useState("");
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetchReports({
|
|
status: statusFilter || undefined,
|
|
limit: 100,
|
|
});
|
|
setReports(res.items);
|
|
setTotal(res.total);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load reports");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [statusFilter]);
|
|
|
|
const handleAction = async (reportId: string, newStatus: string) => {
|
|
setActionLoading(reportId);
|
|
try {
|
|
const updated = await updateReport(reportId, {
|
|
status: newStatus,
|
|
...(noteText.trim() ? { admin_notes: noteText.trim() } : {}),
|
|
});
|
|
setReports((prev) =>
|
|
prev.map((r) => (r.id === reportId ? updated : r)),
|
|
);
|
|
setNoteText("");
|
|
if (newStatus === "resolved" || newStatus === "dismissed") {
|
|
setExpandedId(null);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Action failed");
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const toggleExpand = (id: string) => {
|
|
if (expandedId === id) {
|
|
setExpandedId(null);
|
|
setNoteText("");
|
|
} else {
|
|
setExpandedId(id);
|
|
const report = reports.find((r) => r.id === id);
|
|
setNoteText(report?.admin_notes ?? "");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="admin-reports">
|
|
<h2 className="admin-reports__title">Content Reports</h2>
|
|
<p className="admin-reports__subtitle">
|
|
{total} report{total !== 1 ? "s" : ""} total
|
|
</p>
|
|
|
|
{/* Status filter */}
|
|
<div className="admin-reports__filters">
|
|
<div className="sort-toggle" role="group" aria-label="Filter by status">
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
className={`sort-toggle__btn${statusFilter === opt.value ? " sort-toggle__btn--active" : ""}`}
|
|
onClick={() => setStatusFilter(opt.value)}
|
|
aria-pressed={statusFilter === opt.value}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="loading">Loading reports…</div>
|
|
) : error ? (
|
|
<div className="loading error-text">Error: {error}</div>
|
|
) : reports.length === 0 ? (
|
|
<div className="empty-state">
|
|
{statusFilter ? `No ${statusFilter} reports.` : "No reports yet."}
|
|
</div>
|
|
) : (
|
|
<div className="admin-reports__list">
|
|
{reports.map((report) => (
|
|
<div
|
|
key={report.id}
|
|
className={`report-card report-card--${report.status}`}
|
|
>
|
|
<div
|
|
className="report-card__header"
|
|
onClick={() => toggleExpand(report.id)}
|
|
>
|
|
<div className="report-card__meta">
|
|
<span className={`pill pill--${report.status}`}>
|
|
{report.status}
|
|
</span>
|
|
<span className="pill">{reportTypeLabel(report.report_type)}</span>
|
|
<span className="report-card__date">
|
|
{formatDate(report.created_at)}
|
|
</span>
|
|
</div>
|
|
<div className="report-card__summary">
|
|
{report.content_title && (
|
|
<span className="report-card__content-title">
|
|
{report.content_title}
|
|
</span>
|
|
)}
|
|
<span className="report-card__description">
|
|
{report.description.length > 120
|
|
? report.description.slice(0, 120) + "…"
|
|
: report.description}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedId === report.id && (
|
|
<div className="report-card__detail">
|
|
<div className="report-card__full-description">
|
|
<strong>Full description:</strong>
|
|
<p>{report.description}</p>
|
|
</div>
|
|
|
|
{report.page_url && (
|
|
<div className="report-card__url">
|
|
<strong>Page:</strong>{" "}
|
|
<a href={report.page_url} target="_blank" rel="noopener noreferrer">
|
|
{report.page_url}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
<div className="report-card__info-row">
|
|
<span>Type: {report.content_type}</span>
|
|
{report.content_id && <span>ID: {report.content_id.slice(0, 8)}…</span>}
|
|
{report.resolved_at && (
|
|
<span>Resolved: {formatDate(report.resolved_at)}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Admin notes */}
|
|
<label className="report-card__notes-label">
|
|
Admin notes:
|
|
<textarea
|
|
className="report-card__notes"
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.target.value)}
|
|
rows={2}
|
|
placeholder="Add notes…"
|
|
/>
|
|
</label>
|
|
|
|
{/* Action buttons */}
|
|
<div className="report-card__actions">
|
|
{(STATUS_ACTIONS[report.status] ?? []).map((action) => (
|
|
<button
|
|
key={action.next}
|
|
className={`btn btn--${action.next === "resolved" ? "primary" : action.next === "dismissed" ? "danger" : "secondary"}`}
|
|
onClick={() => handleAction(report.id, action.next)}
|
|
disabled={actionLoading === report.id}
|
|
>
|
|
{actionLoading === report.id ? "…" : action.label}
|
|
</button>
|
|
))}
|
|
{noteText !== (report.admin_notes ?? "") && (
|
|
<button
|
|
className="btn btn--secondary"
|
|
onClick={() => handleAction(report.id, report.status)}
|
|
disabled={actionLoading === report.id}
|
|
>
|
|
Save notes
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|