import { useCallback, useState } from 'react' import Modal from '../../../components/Modal.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAsync } from '../../../lib/useAsync.js' import { ago, dateTime } from '../../../lib/format.js' import { api } from '../../../api/client.js' // The member-raised content-report queue (TEAMS.md §5.6). // // **This is the only view of this queue, and that is the design.** The gap §5.6 // exists to close has a specific shape: leaders moderate their own Team's forum, // and a Team's leaders are exactly the people who will not report their own Team. // A leader-visible queue would route a complaint about a leader back to that // leader. Org lead, 2026-08-18: reports are **site administration only**. If a // leader-facing view is ever wanted it is a design decision, not a component. // // It sits beside Appeals rather than under Teams because a staffer working a // queue should have one place to work — and because `target_type` is deliberately // open-ended, so the next consumer (a wiki page, a news comment) arrives as a new // row here rather than as a new screen. // // **Handling a report is bookkeeping about the REPORT, not moderation of the // content.** Acting on the content itself is the ordinary forum moderation // control, or a site-wide sanction against the account. Keeping those separate is // what stops "report" from becoming a way for any member to hide anything, so // this screen deliberately offers no hide/delete button of its own. const STATUS_TABS = [ { key: 'open_work', label: 'Open work', param: undefined }, { key: 'open', label: 'Open', param: 'open' }, { key: 'reviewing', label: 'Reviewing', param: 'reviewing' }, { key: 'actioned', label: 'Actioned', param: 'actioned' }, { key: 'dismissed', label: 'Dismissed', param: 'dismissed' }, { key: 'all', label: 'All', param: 'all' }, ] const STATUS_STYLE = { open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' }, reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' }, actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' }, dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' }, } const STATUS_LABEL = { open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed', } const REASON_LABEL = { spam: 'Spam', abuse: 'Abuse', sexual: 'Sexual', illegal: 'Illegal', impersonation: 'Impersonation', other: 'Other', } const bytes = (n) => { if (!n && n !== 0) return '' if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB` return `${(n / (1024 * 1024)).toFixed(1)} MB` } /** * What was reported, rendered from the row the queue already resolved. * * Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size * and sniffed type without hunting, and the server attaches all of it in three * batched reads. A `null` target is a target that has since been hard-deleted, * and the row still shows — "somebody reported this and by the time we looked it * was gone" is a fact worth seeing, and dropping it would hide the pattern of a * member deleting their own content the moment it is reported. */ function TargetCell({ report }) { const t = report.target if (!t) { return ( {report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists ) } if (t.kind === 'upload') { return ( {t.filename} {t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)} {t.deleted && ' · removed'} ) } if (t.kind === 'thread') { return ( {t.title} {t.type} by {t.author || 'unknown'} {t.status !== 'visible' && ` · ${t.status}`} ) } return ( {t.excerpt || (no text)} {t.author || 'unknown'} in “{t.threadTitle}” {t.status !== 'visible' && ` · ${t.status}`} ) } export default function ContentReports() { const [tab, setTab] = useState('open_work') const [tick, setTick] = useState(0) const reload = useCallback(() => setTick((t) => t + 1), []) const [handling, setHandling] = useState(null) const [notice, setNotice] = useState(null) const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0] const { loading, error, data } = useAsync( () => api.admin.contentReports({ status: activeTab.param }), [tab, tick], ) if (loading) return if (error) return const rows = data?.reports || [] return (

Reports raised by members about Team forum content. They come to site staff and are not visible to a Team’s own leaders — a leader moderates their own forum, so a report about a leader has to reach someone above them. Handling a report records a decision about the report; hiding or removing the content itself is done from the forum, or as a sanction against the account. {typeof data?.openCount === 'number' && ` ${data.openCount} open.`}

{STATUS_TABS.map((t) => ( ))}
{notice && (

{notice.text}

)}
{rows.length === 0 && ( )} {rows.map((r) => ( ))}
Reported content Reason Detail Reporter Age Status
No reports match this filter.
{REASON_LABEL[r.reason] || r.reason} {r.detail || '—'} {r.reporter} {ago(r.createdAt)} {STATUS_LABEL[r.status] || r.status} {r.handledBy && ( {r.handledBy} {r.handledNote ? ` — ${r.handledNote}` : ''} )}
{handling && ( setHandling(null)} onDone={() => { setHandling(null) setNotice({ text: 'Report updated.', tone: 'ok' }) reload() }} onError={(message) => setNotice({ text: message, tone: 'error' })} /> )}
) } /** * Record a decision about a report. * * The note is optional and worth writing: every transition is audited, dismissals * included, and the note is what the next staffer to see a repeat report about the * same content reads to find out why the last one was closed. */ function HandleModal({ report, onCancel, onDone, onError }) { const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned') const [note, setNote] = useState('') const [busy, setBusy] = useState(false) const submit = async () => { setBusy(true) try { await api.admin.handleContentReport(report.id, { status, note: note || undefined }) onDone() } catch (err) { onError(err.message || 'Could not update that report.') setBusy(false) } } return ( )} >

This records a decision about the report. It does not hide, delete or restore the content — do that from the forum itself, or against the account.

{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => ( ))}