Merge branch 'main' into feat/password-reset
This commit is contained in:
221
client/src/routes/player/PlayerAppeals.jsx
Normal file
221
client/src/routes/player/PlayerAppeals.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user