import { useCallback, useState } from 'react' import { useNavigate } from 'react-router-dom' 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' // Staff queue for moderation appeals (bans/mutes appealed by players). Mirrors // the Moderation.jsx tile/feed layout: a status-filter segmented control over a // flat table, with per-row Claim / Resolve actions. Resolve opens a modal — no // browser confirm()/alert() anywhere here. const STATUS_TABS = [ { key: 'open', label: 'Open', param: undefined }, { key: 'pending', label: 'Pending', param: 'pending' }, { key: 'under_review', label: 'Under review', param: 'under_review' }, { key: 'approved', label: 'Approved', param: 'approved' }, { key: 'denied', label: 'Denied', param: 'denied' }, { key: 'withdrawn', label: 'Withdrawn', param: 'withdrawn' }, { key: 'all', label: 'All', param: 'all' }, ] const STATUS_STYLE = { pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' }, under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' }, approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' }, denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' }, withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' }, } const STATUS_LABEL = { pending: 'Pending', under_review: 'Under review', approved: 'Approved', denied: 'Denied', withdrawn: 'Withdrawn', } function excerpt(text, n = 90) { if (!text) return '' return text.length > n ? `${text.slice(0, n)}…` : text } export default function Appeals() { const navigate = useNavigate() const [tab, setTab] = useState('open') const [tick, setTick] = useState(0) const reload = useCallback(() => setTick((t) => t + 1), []) const [busyId, setBusyId] = useState('') const [resolving, setResolving] = useState(null) // the appeal being resolved const [notice, setNotice] = useState(null) // fields text and tone const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0] const { loading, error, data } = useAsync( () => api.admin.getAppeals({ status: activeTab.param, limit: 100 }), [tab, tick], ) const goUser = (id) => navigate(`/admin/moderation/user/${id}`) async function claim(appeal) { setBusyId(appeal.id) setNotice(null) try { await api.admin.claimAppeal(appeal.id) reload() } catch (err) { setNotice({ text: err.message || 'Could not claim this appeal.', tone: 'error' }) } finally { setBusyId('') } } function onResolved(appeal, result) { setResolving(null) const { reversal } = result if (reversal?.attempted && reversal.ok) { setNotice({ text: `Discord ${appeal.action_type} lifted.`, tone: 'ok' }) } else if (reversal?.attempted && !reversal.ok) { setNotice({ text: 'Reversal failed — reverse manually in Discord.', tone: 'error' }) } else { setNotice(null) } reload() } if (loading) return if (error) return const rows = data || [] return (
{/* Status filter */}
{STATUS_TABS.map((t) => ( ))}
{notice && (

{notice.text}

)}
{rows.length === 0 && ( )} {rows.map((a) => ( ))}
Target Action Appeal Submitted by Age Status Reversal
No appeals match this filter.
goUser(a.discord_user_id)}> {a.action_target_tag || a.discord_user_id} {a.action_type} {excerpt(a.submitted_text)} {a.submitter_username || '—'} {ago(a.submitted_at)} {STATUS_LABEL[a.status] || a.status} {a.reversal_status === 'done' && Lifted} {a.reversal_status === 'failed' && Failed} {(!a.reversal_status || a.reversal_status === 'none') && '—'} {a.status === 'pending' && ( )} {(a.status === 'pending' || a.status === 'under_review') && ( )}
{resolving && ( setResolving(null)} onResolved={onResolved} /> )}
) } function ResolveModal({ appeal, onClose, onResolved }) { const [status, setStatus] = useState('approved') const [staffResponse, setStaffResponse] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') async function submit() { setBusy(true) setError('') try { const result = await api.admin.resolveAppeal(appeal.id, { status, staff_response: staffResponse.trim() || undefined, }) onResolved(appeal, result) } catch (err) { setError(err.message || 'Could not resolve this appeal.') setBusy(false) } } const verb = status === 'approved' ? 'approved' : 'denied' return ( } >
{error &&

{error}

}
Submitted appeal
{appeal.submitted_text}