Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s

This commit is contained in:
2026-07-19 09:04:43 +00:00
23 changed files with 3083 additions and 8 deletions

View File

@@ -51,6 +51,7 @@ import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
@@ -62,6 +63,7 @@ import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
return (
@@ -134,6 +136,7 @@ export default function App() {
>
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
@@ -181,6 +184,7 @@ export default function App() {
<Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -250,6 +250,21 @@ export const api = {
addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- moderation appeals (admin + moderator) -----
getAppeals: (params = {}) => {
const qs = new URLSearchParams()
if (params.status) qs.set('status', params.status)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`)
},
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
resolveAppeal: (id, data) =>
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
@@ -338,6 +353,12 @@ export const api = {
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
},
}

View File

@@ -63,6 +63,7 @@ const NAV = [
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
@@ -97,6 +98,7 @@ const TITLES = {
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
@@ -145,7 +147,7 @@ export default function AdminLayout() {
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false
if (isModerator) return MOD_PATHS.includes(item.to)

View File

@@ -0,0 +1,286 @@
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) // { text, 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 <Loading />
if (error) return <ErrorState message="Could not load appeals." />
const rows = data || []
return (
<section>
{/* Status filter */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Target</th>
<th className="adm-th">Action</th>
<th className="adm-th">Appeal</th>
<th className="adm-th">Submitted by</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th">Reversal</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={8} style={muted}>
No appeals match this filter.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td">
<span className="link-accent" onClick={() => goUser(a.discord_user_id)}>
{a.action_target_tag || a.discord_user_id}
</span>
</td>
<td className="adm-td">
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
</td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 320 }}>
{excerpt(a.submitted_text)}
</td>
<td className="adm-td dim">{a.submitter_username || '—'}</td>
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[a.status]}>{STATUS_LABEL[a.status] || a.status}</span>
</td>
<td className="adm-td dim">
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{a.status === 'pending' && (
<button
onClick={() => claim(a)}
disabled={busyId === a.id}
className="pill"
style={{ marginRight: 6 }}
>
{busyId === a.id ? 'Claiming…' : 'Claim'}
</button>
)}
{(a.status === 'pending' || a.status === 'under_review') && (
<button onClick={() => setResolving(a)} className="btn btn-primary btn-sq" style={{ padding: '5px 12px', fontSize: '0.82rem' }}>
Resolve
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{resolving && (
<ResolveModal appeal={resolving} onClose={() => setResolving(null)} onResolved={onResolved} />
)}
</section>
)
}
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)
}
}
return (
<Modal
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
onClose={onClose}
width={560}
footer={
<>
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : `Mark ${status === 'approved' ? 'approved' : 'denied'}`}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<div>
<span className="field-label">Submitted appeal</span>
<div
className="sans"
style={{
marginTop: 6,
padding: '10px 12px',
border: '1px solid var(--line)',
borderRadius: 8,
color: 'var(--text)',
fontSize: '0.86rem',
whiteSpace: 'pre-wrap',
maxHeight: 200,
overflow: 'auto',
}}
>
{appeal.submitted_text}
</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => setStatus('approved')}
className="pill"
style={status === 'approved' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#7fd0a4' } : undefined}
>
Approve
</button>
<button
onClick={() => setStatus('denied')}
className="pill"
style={status === 'denied' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#d98b84' } : undefined}
>
Deny
</button>
</div>
<label>
<span className="field-label">Staff response (optional)</span>
<textarea
className="textarea"
placeholder="Message shown to the player…"
value={staffResponse}
onChange={(e) => setStaffResponse(e.target.value)}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

View File

@@ -35,6 +35,7 @@ export default function ModerationUser() {
api.admin.modUser(discordId),
api.admin.modUserActions(discordId, { limit: 200 }),
api.admin.modUserNotes(discordId),
api.admin.getUserAppeals(discordId),
]),
[discordId, tick],
)
@@ -42,7 +43,7 @@ export default function ModerationUser() {
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this users history." />
const [summary, actions, notes] = data
const [summary, actions, notes, appeals] = data
const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab)
@@ -83,10 +84,15 @@ export default function ModerationUser() {
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0})
</TabButton>
<TabButton active={tab === 'appeals'} onClick={() => setTab('appeals')}>
Appeals ({appeals.length})
</TabButton>
</div>
{tab === 'notes' ? (
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : tab === 'appeals' ? (
<AppealsTab rows={appeals} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
@@ -164,6 +170,63 @@ function ActionTable({ rows, showDuration }) {
)
}
const APPEAL_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 APPEAL_STATUS_LABEL = {
pending: 'Pending',
under_review: 'Under review',
approved: 'Approved',
denied: 'Denied',
withdrawn: 'Withdrawn',
}
function AppealsTab({ rows }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Appeal</th>
<th className="adm-th">Staff response</th>
<th className="adm-th">Status</th>
<th className="adm-th">Reversal</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No appeals from this user.</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 260, whiteSpace: 'pre-wrap' }}>{a.submitted_text}</td>
<td className="adm-td dim" style={{ maxWidth: 220, whiteSpace: 'pre-wrap' }}>{a.staff_response || '—'}</td>
<td className="adm-td">
<span className="badge" style={APPEAL_STATUS_STYLE[a.status]}>{APPEAL_STATUS_LABEL[a.status] || a.status}</span>
</td>
<td className="adm-td dim">
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
</td>
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only')

View File

@@ -0,0 +1,221 @@
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
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'
// Player-facing appeals: eligible sanctions the player can appeal, plus the
// status of appeals they've already submitted. Mirrors PlayerAccount's
// Section layout.
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 fmtDuration(seconds) {
if (!seconds) return null
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
function Section({ title, children }) {
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{children}
</section>
)
}
function EligibleItem({ item, onSubmitted }) {
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function submit() {
if (!text.trim()) return
setBusy(true)
setError('')
try {
await api.player.submitAppeal({ mod_action_id: item.id, submitted_text: text.trim() })
setText('')
setOpen(false)
onSubmitted()
} catch (err) {
setError(err.message || 'Could not submit your appeal.')
} finally {
setBusy(false)
}
}
const duration = fmtDuration(item.duration_seconds)
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className={`badge badge-${item.action_type}`}>{item.action_type}</span>
{duration && <span className="sans dim" style={{ fontSize: '0.78rem' }}>{duration}</span>}
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(item.created_at)}>
{ago(item.created_at)}
</span>
</div>
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem' }}>
{item.reason || 'No reason given.'}
</p>
{!open ? (
<div style={{ marginTop: 12 }}>
<button onClick={() => setOpen(true)} className="btn btn-primary btn-sq">
Appeal this
</button>
</div>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<textarea
className="textarea"
placeholder="Explain why this action should be reversed…"
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={submit} disabled={busy || !text.trim()} className="btn btn-primary btn-sq">
{busy ? 'Submitting…' : 'Submit appeal'}
</button>
<button onClick={() => { setOpen(false); setError('') }} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
)}
</div>
)
}
function EligibleAppeals({ items, onSubmitted }) {
if (items.length === 0) {
return (
<div>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>You have no sanctions available to appeal right now.</p>
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
If you were sanctioned on Discord, link your Discord account on the{' '}
<Link to="/account" className="link-accent">Account</Link> page to appeal.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{items.map((item) => (
<EligibleItem key={item.id} item={item} onSubmitted={onSubmitted} />
))}
</div>
)
}
function MyAppealItem({ appeal, onWithdrawn }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const canWithdraw = appeal.status === 'pending' || appeal.status === 'under_review'
async function withdraw() {
setBusy(true)
setError('')
try {
await api.player.withdrawAppeal(appeal.id)
onWithdrawn()
} catch (err) {
setError(err.message || 'Could not withdraw this appeal.')
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className={`badge badge-${appeal.action_type}`}>{appeal.action_type}</span>
<span className="badge" style={STATUS_STYLE[appeal.status]}>{STATUS_LABEL[appeal.status] || appeal.status}</span>
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(appeal.submitted_at)}>
{ago(appeal.submitted_at)}
</span>
</div>
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem', whiteSpace: 'pre-wrap' }}>
{appeal.submitted_text}
</p>
{appeal.staff_response && (
<div style={{ marginTop: 10, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div className="field-label" style={{ marginBottom: 4 }}>Staff response</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', whiteSpace: 'pre-wrap' }}>{appeal.staff_response}</p>
</div>
)}
{error && <p className="sans" style={{ margin: '10px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{canWithdraw && (
<div style={{ marginTop: 12 }}>
<button onClick={withdraw} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
{busy ? 'Withdrawing…' : 'Withdraw'}
</button>
</div>
)}
</div>
)
}
function MyAppeals({ appeals, onChange }) {
if (appeals.length === 0) {
return <p className="sans dim" style={{ fontSize: '0.88rem' }}>You haven't submitted any appeals yet.</p>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{appeals.map((a) => (
<MyAppealItem key={a.id} appeal={a} onWithdrawn={onChange} />
))}
</div>
)
}
export default function PlayerAppeals() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() => Promise.all([api.player.getEligibleAppeals(), api.player.getMyAppeals()]),
[tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load your appeals." />
const [eligible, mine] = data
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Appeal a Discord ban or mute, or check the status of an appeal you've already submitted.
</p>
<Section title="Appealable sanctions">
<EligibleAppeals items={eligible} onSubmitted={reload} />
</Section>
<Section title="My appeals">
<MyAppeals appeals={mine} onChange={reload} />
</Section>
</div>
)
}

View File

@@ -28,10 +28,12 @@ function Icon({ children, size = 16 }) {
}
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account', label: 'Account', icon: IconGear },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
// The sticky content header mirrors the active page. Character sheets live under
@@ -39,6 +41,7 @@ const NAV = [
const TITLES = {
'/player': 'Characters',
'/account': 'Account',
'/account/appeals': 'Appeals',
}
const navBtnBase = {