974 lines
42 KiB
Svelte
974 lines
42 KiB
Svelte
<script lang="ts">
|
||
import { onMount, untrack } from 'svelte';
|
||
import { beforeNavigate, goto } from '$app/navigation';
|
||
import { page, navigating } from '$app/stores';
|
||
import { canModerate } from '$lib/auth.js';
|
||
import {
|
||
canRevokeModerationRestriction,
|
||
moderationCaseStatuses,
|
||
moderationCaseStatusLabels,
|
||
moderationConcernLabels,
|
||
moderationFindingLabels,
|
||
type ModerationOverview,
|
||
type ModerationQuery,
|
||
type ModerationGame
|
||
} from '$lib/moderation.js';
|
||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import {
|
||
moderationActionLabels,
|
||
moderationGameName,
|
||
moderationEventDetail,
|
||
moderationCaseHref
|
||
} from '$ui/moderation.js';
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import Freshness from '$ui/components/Freshness.svelte';
|
||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
|
||
|
||
let { data }: { data: { moderation: ModerationOverview } } = $props();
|
||
let snapshot = $state<ModerationOverview | null>(null);
|
||
const report = $derived(snapshot ?? data.moderation);
|
||
const permitted = $derived(canModerate($page.data?.user?.role));
|
||
const admin = $derived($page.data?.user?.role === 'admin');
|
||
const busy = $derived(!!$navigating);
|
||
let filters = $state<ModerationQuery>(untrack(() => ({ ...data.moderation.query })));
|
||
let tabView = $state<string>(untrack(() => data.moderation.query.view));
|
||
let refreshing = $state(false);
|
||
let refreshError = $state('');
|
||
let navigationError = $state('');
|
||
let notice = $state('');
|
||
let configuring = $state('');
|
||
const configureKeys = new Map<string, string>();
|
||
let updatedAt = $state<number | null>(null);
|
||
let controller: AbortController | undefined;
|
||
let generation = 0;
|
||
let dialogOpen = $state(false);
|
||
let dialogKey = $state(0);
|
||
let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
|
||
let seed = $state<{
|
||
game?: ModerationGame;
|
||
guid?: string;
|
||
server?: string;
|
||
id?: string;
|
||
revision?: number;
|
||
matchId?: number;
|
||
message?: string;
|
||
}>({});
|
||
const tabs = [
|
||
{ id: 'players', label: 'Players' },
|
||
{ id: 'restrictions', label: 'Restrictions' },
|
||
{ id: 'cases', label: 'Cases' },
|
||
{ id: 'history', label: 'History' }
|
||
];
|
||
const currentCoverage = $derived(
|
||
report.coverage.filter((item) => item.state === 'current').length
|
||
);
|
||
|
||
$effect(() => {
|
||
const initial = data.moderation;
|
||
untrack(() => {
|
||
snapshot = null;
|
||
filters = { ...initial.query };
|
||
tabView = initial.query.view;
|
||
refreshError = '';
|
||
});
|
||
});
|
||
function cancelRefresh() {
|
||
generation++;
|
||
controller?.abort();
|
||
refreshing = false;
|
||
}
|
||
beforeNavigate(cancelRefresh);
|
||
async function refresh(signal?: AbortSignal) {
|
||
if (busy || refreshing) return;
|
||
const current = ++generation;
|
||
const request = new AbortController();
|
||
controller = request;
|
||
const abort = () => request.abort();
|
||
signal?.addEventListener('abort', abort, { once: true });
|
||
if (signal?.aborted) request.abort();
|
||
const timeout = setTimeout(abort, 30_000);
|
||
refreshing = true;
|
||
try {
|
||
const response = await fetch(`/api/moderation${$page.url.search}`, {
|
||
signal: request.signal
|
||
});
|
||
if (!response.ok) throw new Error('Refresh failed');
|
||
const next: ModerationOverview = await response.json();
|
||
if (current !== generation || request.signal.aborted) return;
|
||
snapshot = next;
|
||
updatedAt = Date.now();
|
||
refreshError = '';
|
||
} catch {
|
||
if (current === generation)
|
||
refreshError = 'Moderation updates paused. Showing the last loaded records.';
|
||
return false;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
signal?.removeEventListener('abort', abort);
|
||
if (current === generation) refreshing = false;
|
||
}
|
||
}
|
||
onMount(() => {
|
||
updatedAt = Date.now();
|
||
const stop = pollVisible(refresh, 15_000, false);
|
||
return () => {
|
||
stop();
|
||
cancelRefresh();
|
||
};
|
||
});
|
||
async function navigate(changes: Record<string, string | number | null>, refreshData = false) {
|
||
const params = new URLSearchParams($page.url.searchParams);
|
||
for (const [key, value] of Object.entries(changes)) {
|
||
if (value === null || value === '') params.delete(key);
|
||
else params.set(key, String(value));
|
||
}
|
||
navigationError = '';
|
||
try {
|
||
await goto(`/moderation?${params}`, {
|
||
keepFocus: true,
|
||
noScroll: true,
|
||
invalidateAll: refreshData
|
||
});
|
||
} catch {
|
||
tabView = report.query.view;
|
||
navigationError = 'Could not load this moderation view. Please try again.';
|
||
}
|
||
}
|
||
function openDialog(next: typeof mode, target: typeof seed = {}) {
|
||
mode = next;
|
||
const matchId = Number($page.url.searchParams.get('matchId'));
|
||
seed = {
|
||
...(report.query.game ? { game: report.query.game } : {}),
|
||
guid: report.query.guid,
|
||
server: $page.url.searchParams.get('server') ?? '',
|
||
...(next === 'case' && matchId > 0 && $page.url.searchParams.get('message')
|
||
? { matchId, message: $page.url.searchParams.get('message')! }
|
||
: {}),
|
||
...target
|
||
};
|
||
dialogKey++;
|
||
dialogOpen = true;
|
||
}
|
||
function submitted(target: { game?: ModerationGame; guid?: string | null; id?: string }) {
|
||
if (mode === 'case' && target.id) {
|
||
cancelRefresh();
|
||
void goto(moderationCaseHref(target.id), { invalidateAll: true }).catch(() => {
|
||
navigationError = 'Case saved. Open it from the case list or refresh to try again.';
|
||
void refresh();
|
||
});
|
||
return;
|
||
}
|
||
notice = 'Saved. Enforcement and delivery status are shown below.';
|
||
cancelRefresh();
|
||
const view =
|
||
mode === 'action' || mode === 'note'
|
||
? 'history'
|
||
: mode === 'restriction' || mode === 'revoke'
|
||
? 'restrictions'
|
||
: 'cases';
|
||
void navigate(
|
||
{
|
||
view,
|
||
offset: null,
|
||
q: null,
|
||
matchId: null,
|
||
message: null,
|
||
...(target.game ? { game: target.game } : {}),
|
||
...(target.guid !== undefined ? { guid: target.guid } : {})
|
||
},
|
||
true
|
||
);
|
||
}
|
||
async function configure(server: string) {
|
||
if (configuring) return;
|
||
if (
|
||
!(await confirmAction({
|
||
title: 'Enable moderation integration',
|
||
message: `Enable moderation on ${server}? Install the matching moderation scripts and plugins first. Capabilities appear only after the game connects successfully.`,
|
||
confirmLabel: 'Enable integration'
|
||
}))
|
||
)
|
||
return;
|
||
configuring = server;
|
||
navigationError = '';
|
||
const idempotencyKey = configureKeys.get(server) ?? crypto.randomUUID();
|
||
configureKeys.set(server, idempotencyKey);
|
||
try {
|
||
const response = await fetch('/api/moderation', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ op: 'configure', server, idempotencyKey })
|
||
});
|
||
const result = await response.json().catch(() => null);
|
||
if (!response.ok || !result?.ok)
|
||
throw new Error(
|
||
result?.error ||
|
||
'Could not confirm the integration request. Retry uses the same request.'
|
||
);
|
||
configureKeys.delete(server);
|
||
notice = 'Integration configuration queued. Coverage updates when the game connects.';
|
||
cancelRefresh();
|
||
void refresh();
|
||
} catch (cause) {
|
||
navigationError =
|
||
cause instanceof Error
|
||
? cause.message
|
||
: 'Could not confirm the integration request.';
|
||
} finally {
|
||
configuring = '';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head><title>Moderation · Electric Cherry</title></svelte:head>
|
||
|
||
<div class="moderation-page">
|
||
<SectionHeader
|
||
title="Moderation"
|
||
subtitle="Player restrictions, staff cases, and enforcement across your servers."
|
||
>
|
||
{#snippet actions()}
|
||
<Freshness
|
||
{updatedAt}
|
||
intervalLabel="every 15 s"
|
||
error={refreshError ? 'Updates paused' : ''}
|
||
/>
|
||
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
||
<Button href="/matches?matchArchive=1">Chat archive</Button>
|
||
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
|
||
>Your case updates ({report.unreadCount})</Button
|
||
>{/if}
|
||
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
||
>Restrict player</Button
|
||
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
||
{/snippet}
|
||
</SectionHeader>
|
||
{#if !permitted}<p class="notice">
|
||
Read-only moderation access. You can inspect private records and enforcement status.
|
||
</p>{/if}
|
||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||
{#if refreshError}<div class="ui-error" role="alert">
|
||
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
|
||
>Retry</Button
|
||
>
|
||
</div>{/if}
|
||
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||
|
||
<details class="coverage">
|
||
<summary
|
||
>Enforcement coverage · {currentCoverage} of {report.coverage.length} servers current</summary
|
||
>
|
||
<p class="hint">
|
||
Current means the server acknowledged the latest policy. Pending or unavailable servers
|
||
may not apply a new restriction or revocation yet. Cached restrictions continue during
|
||
an outage.
|
||
</p>
|
||
{#if report.coverage.length}
|
||
<div class="table-scroll">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th>Server</th><th>Policy status</th><th>Revision</th><th
|
||
>Last connected</th
|
||
><th>Capabilities</th>{#if admin}<th>Setup</th>{/if}</tr
|
||
></thead
|
||
><tbody>
|
||
{#each report.coverage as item (item.server)}<tr>
|
||
<th
|
||
><a href={`/servers/${encodeURIComponent(item.server)}`}
|
||
><ServerIdentity name={item.server} /></a
|
||
></th
|
||
>
|
||
<td><StatusBadge state={`moderation-${item.state}`} /></td>
|
||
<td>{item.revision ?? '—'} / {item.desiredRevision}</td>
|
||
<td
|
||
>{#if item.connectedAt}<RelativeTime
|
||
value={item.connectedAt}
|
||
/>{:else}Not recorded{/if}</td
|
||
>
|
||
<td
|
||
>{item.capabilities.length
|
||
? item.capabilities.join(', ')
|
||
: 'Not reported'}</td
|
||
>
|
||
{#if admin}<td
|
||
>{#if item.state === 'unavailable'}<Button
|
||
size="sm"
|
||
disabled={!!configuring}
|
||
loading={configuring === item.server}
|
||
onclick={() => configure(item.server)}
|
||
>Enable integration</Button
|
||
>{/if}</td
|
||
>{/if}
|
||
</tr>{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{:else}<p class="hint">No server enforcement reports are available yet.</p>{/if}
|
||
</details>
|
||
|
||
<Tabs
|
||
{tabs}
|
||
bind:value={tabView}
|
||
onchange={(view) => {
|
||
void navigate({ view, offset: null });
|
||
}}
|
||
ariaLabel="Moderation views"
|
||
panelId="moderation-results"
|
||
idPrefix="moderation-tab"
|
||
/>
|
||
<form
|
||
class="filters"
|
||
onsubmit={(event) => {
|
||
event.preventDefault();
|
||
void navigate({
|
||
q: filters.q,
|
||
game: filters.game,
|
||
guid: filters.guid,
|
||
caseStatus: filters.caseStatus ?? null,
|
||
priority: filters.priority ?? null,
|
||
assignedTo: filters.assignedTo ?? null,
|
||
review: filters.review ?? null,
|
||
offset: null
|
||
});
|
||
}}
|
||
>
|
||
<Field label="Search moderation" class="search"
|
||
><Input
|
||
name="q"
|
||
type="search"
|
||
bind:value={filters.q}
|
||
placeholder="Name, alias, GUID, or reason"
|
||
/></Field
|
||
>
|
||
<Field label="Game"
|
||
><Select
|
||
name="game"
|
||
bind:value={filters.game}
|
||
options={[
|
||
{ value: '', label: 'All games' },
|
||
{ value: 't5', label: 'Black Ops I' },
|
||
{ value: 't6', label: 'Black Ops II' }
|
||
]}
|
||
/></Field
|
||
>
|
||
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
||
{#if report.query.view === 'cases'}
|
||
<Field label="Review queue"
|
||
><Select
|
||
name="review"
|
||
bind:value={filters.review}
|
||
options={[
|
||
{ value: '', label: 'All cases' },
|
||
{ value: 'due', label: 'Overdue reviews' },
|
||
{ value: 'scheduled', label: 'Scheduled reviews' },
|
||
{ value: 'updates', label: 'Your unread updates' }
|
||
]}
|
||
/></Field
|
||
>
|
||
<Field label="Case status"
|
||
><Select
|
||
name="caseStatus"
|
||
bind:value={filters.caseStatus}
|
||
options={[
|
||
{ value: '', label: 'All statuses' },
|
||
{ value: 'active', label: 'Active cases' },
|
||
{ value: 'closed', label: 'Closed cases' },
|
||
...moderationCaseStatuses.map((status) => ({
|
||
value: status,
|
||
label: moderationCaseStatusLabels[status]
|
||
}))
|
||
]}
|
||
/></Field
|
||
>
|
||
<Field label="Priority"
|
||
><Select
|
||
name="priority"
|
||
bind:value={filters.priority}
|
||
options={[
|
||
{ value: '', label: 'All priorities' },
|
||
{ value: 'urgent', label: 'Urgent' },
|
||
{ value: 'high', label: 'High' },
|
||
{ value: 'normal', label: 'Normal' },
|
||
{ value: 'low', label: 'Low' }
|
||
]}
|
||
/></Field
|
||
>
|
||
<Field label="Assignment"
|
||
><Select
|
||
name="assignedTo"
|
||
bind:value={filters.assignedTo}
|
||
options={[
|
||
{ value: '', label: 'Anyone' },
|
||
{ value: 'me', label: 'Assigned to me' },
|
||
{ value: 'unassigned', label: 'Unassigned' }
|
||
]}
|
||
/></Field
|
||
>
|
||
{/if}
|
||
<Button type="submit" disabled={busy}>Apply</Button>
|
||
<Button
|
||
disabled={busy}
|
||
onclick={() =>
|
||
navigate({
|
||
q: null,
|
||
game: null,
|
||
guid: null,
|
||
offset: null,
|
||
server: null,
|
||
message: null,
|
||
matchId: null,
|
||
caseStatus: null,
|
||
priority: null,
|
||
assignedTo: null,
|
||
review: null
|
||
})}>Reset</Button
|
||
>
|
||
</form>
|
||
{#if $page.url.searchParams.get('matchId') && $page.url.searchParams.get('message')}<p
|
||
class="notice"
|
||
>
|
||
A chat message is selected as case evidence. Review a player with a verified GUID, then
|
||
choose Open case. The original message must match that player in the archive.
|
||
</p>{/if}
|
||
{#if report.query.guid}
|
||
<section class="selected-player" aria-label="Selected player">
|
||
<div>
|
||
<strong>Selected player</strong><CopyValue
|
||
value={report.query.guid}
|
||
label="Player GUID"
|
||
/><a href={contextHref(playerProfileHref(report.query.guid), $page.url)}
|
||
>View player profile</a
|
||
>
|
||
</div>
|
||
{#if permitted}<div class="row-actions">
|
||
<Button size="sm" onclick={() => openDialog('case')}>Open case</Button><Button
|
||
size="sm"
|
||
onclick={() => openDialog('note')}>Add private note</Button
|
||
>
|
||
</div>{/if}
|
||
</section>
|
||
{/if}
|
||
|
||
<div
|
||
id="moderation-results"
|
||
role="tabpanel"
|
||
aria-labelledby={`moderation-tab-${report.query.view}`}
|
||
aria-busy={busy}
|
||
>
|
||
{#if report.query.view === 'players'}
|
||
{#if report.players.length}<div class="table-scroll">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th>Player</th><th>Aliases</th><th>Last seen</th><th>Servers</th
|
||
><th>Restrictions</th><th>Actions</th></tr
|
||
></thead
|
||
><tbody>
|
||
{#each report.players as player (`${player.game}:${player.guid}`)}<tr>
|
||
<th
|
||
><a
|
||
href={contextHref(
|
||
playerProfileHref(player.guid),
|
||
$page.url
|
||
)}>{player.name}</a
|
||
><small>{moderationGameName(player.game)}</small><CopyValue
|
||
value={player.guid}
|
||
label="Player GUID"
|
||
/></th
|
||
>
|
||
<td>{player.aliases.join(', ') || 'None recorded'}</td>
|
||
<td
|
||
><RelativeTime
|
||
value={player.lastSeen}
|
||
missing="Not recorded"
|
||
/></td
|
||
>
|
||
<td
|
||
>{#each player.servers as server}<div>
|
||
<ServerIdentity name={server} />
|
||
</div>{:else}Not currently observed{/each}</td
|
||
>
|
||
<td>{player.restrictions}</td>
|
||
<td
|
||
><div class="row-actions">
|
||
<Button
|
||
size="sm"
|
||
onclick={() =>
|
||
navigate({
|
||
game: player.game,
|
||
guid: player.guid,
|
||
q: null,
|
||
offset: null,
|
||
server: null,
|
||
message:
|
||
$page.url.searchParams.get('message'),
|
||
matchId:
|
||
$page.url.searchParams.get('matchId')
|
||
})}>Review player</Button
|
||
>{#if permitted}<Button
|
||
size="sm"
|
||
onclick={() =>
|
||
openDialog('restriction', {
|
||
game: player.game,
|
||
guid: player.guid,
|
||
server: player.servers[0] ?? ''
|
||
})}>Restrict</Button
|
||
>{/if}
|
||
</div></td
|
||
>
|
||
</tr>{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>{:else}<EmptyState
|
||
title="No players found"
|
||
description="Search another name or GUID. You can restrict a recorded identity even when the player is offline."
|
||
/>{/if}
|
||
{:else if report.query.view === 'restrictions'}
|
||
{#if report.sanctions.length}<div class="record-list">
|
||
{#each report.sanctions as sanction (sanction.id)}<article class="record">
|
||
<div class="record-heading">
|
||
<h2>
|
||
{sanction.kind === 'ban' ? 'Ban' : 'Mute'} ·
|
||
<a
|
||
href={contextHref(
|
||
playerProfileHref(sanction.guid),
|
||
$page.url
|
||
)}>{sanction.guid}</a
|
||
>
|
||
</h2>
|
||
<StatusBadge
|
||
state={sanction.revokedAt
|
||
? 'moderation-revoked'
|
||
: sanction.active
|
||
? 'moderation-active'
|
||
: 'expired'}
|
||
/>
|
||
</div>
|
||
<p>{sanction.reason}</p>
|
||
<dl>
|
||
<div>
|
||
<dt>Scope</dt>
|
||
<dd>
|
||
{#if sanction.server}<ServerIdentity
|
||
name={sanction.server}
|
||
/>{:else}{sanction.scope === 'fleet'
|
||
? 'Entire fleet'
|
||
: moderationGameName(sanction.game)}{/if}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Expires</dt>
|
||
<dd>
|
||
{sanction.expiresAt
|
||
? $timeFormat.dateTime(sanction.expiresAt)
|
||
: 'Permanent'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Created by</dt>
|
||
<dd>
|
||
{sanction.actor} · <RelativeTime
|
||
value={sanction.createdAt}
|
||
/>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
|
||
>View linked case</a
|
||
>{/if}
|
||
{#if sanction.note}<p class="private-note">
|
||
<strong>Private note</strong>
|
||
{sanction.note}
|
||
</p>{/if}
|
||
<div class="record-heading">
|
||
<CopyValue
|
||
value={sanction.id}
|
||
label="Restriction ID"
|
||
/>{#if sanction.active && canRevokeModerationRestriction($page.data?.user, sanction)}<Button
|
||
size="sm"
|
||
onclick={() =>
|
||
openDialog('revoke', {
|
||
id: sanction.id,
|
||
revision: sanction.revision
|
||
})}>Revoke restriction</Button
|
||
>{/if}
|
||
</div>
|
||
</article>{/each}
|
||
</div>{:else}<EmptyState
|
||
title="No restrictions found"
|
||
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
||
/>{/if}
|
||
{:else if report.query.view === 'cases'}
|
||
{#if report.notifications?.length}
|
||
<section class="record" aria-label="Your case notifications">
|
||
<h2>Your case updates ({report.unreadCount ?? report.notifications.length})</h2>
|
||
<ul class="notification-list">
|
||
{#each report.notifications as notification (notification.id)}
|
||
<li>
|
||
<a href={moderationCaseHref(notification.caseId)}
|
||
>{notification.caseTitle}</a
|
||
>
|
||
· {notification.kind === 'evidence'
|
||
? 'Evidence or note added'
|
||
: 'Assignment changed'} · <RelativeTime
|
||
value={notification.createdAt}
|
||
/>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
<p class="hint">Open a case to review and mark its updates read.</p>
|
||
</section>
|
||
{/if}
|
||
<div class="view-actions">
|
||
<Button href="/moderation?view=cases&review=due">Overdue reviews</Button><Button
|
||
href="/moderation?view=cases&review=updates">Your unread updates</Button
|
||
>
|
||
</div>
|
||
{#if permitted}<div class="view-actions">
|
||
<Button onclick={() => openDialog('case')}>Open case</Button>
|
||
</div>{/if}
|
||
{#if report.cases.length}<div class="record-list">
|
||
{#each report.cases as item (item.id)}<article class="record">
|
||
<div class="record-heading">
|
||
<h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
|
||
<StatusBadge state={`moderation-${item.status}`} />
|
||
</div>
|
||
<p>
|
||
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
|
||
>{item.guid}</a
|
||
>
|
||
· {moderationGameName(item.game)}
|
||
</p>
|
||
{#if item.assessment}
|
||
<p class="assessment-summary">{item.assessment.summary}</p>
|
||
<div class="record-heading" aria-label="Assessment findings">
|
||
{#each item.assessment.findings as finding}
|
||
<Badge
|
||
variant={finding.finding === 'substantiated'
|
||
? 'warning'
|
||
: 'neutral'}
|
||
>
|
||
{moderationConcernLabels[finding.category]} · {moderationFindingLabels[
|
||
finding.finding
|
||
]}
|
||
</Badge>
|
||
{/each}
|
||
</div>
|
||
{:else}<p class="hint">Not assessed</p>{/if}
|
||
{#if item.reviewAt}<div class="record-heading">
|
||
<Badge
|
||
variant={Date.parse(item.reviewAt) <=
|
||
Date.parse(report.checkedAt)
|
||
? 'warning'
|
||
: 'info'}
|
||
>{Date.parse(item.reviewAt) <= Date.parse(report.checkedAt)
|
||
? 'Overdue review'
|
||
: 'Review scheduled'}</Badge
|
||
>
|
||
<time datetime={item.reviewAt}
|
||
>{$timeFormat.dateTime(item.reviewAt)}</time
|
||
>
|
||
</div>{/if}
|
||
{#if item.unreadUpdates}<p>
|
||
<Badge variant="info"
|
||
>{item.unreadUpdates} unread {item.unreadUpdates === 1
|
||
? 'update'
|
||
: 'updates'}</Badge
|
||
>
|
||
</p>{/if}
|
||
<dl>
|
||
<div>
|
||
<dt>Priority</dt>
|
||
<dd>
|
||
{item.priority.charAt(0).toUpperCase() +
|
||
item.priority.slice(1)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Assigned to</dt>
|
||
<dd>{item.assignee ?? 'Unassigned'}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Updated</dt>
|
||
<dd><RelativeTime value={item.updatedAt} /></dd>
|
||
</div>
|
||
<div>
|
||
<dt>Opened by</dt>
|
||
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
|
||
</div>
|
||
</dl>
|
||
<div class="record-heading">
|
||
<span class="hint"
|
||
>{item.evidence.length} evidence and staff note {item.evidence
|
||
.length === 1
|
||
? 'entry'
|
||
: 'entries'}</span
|
||
>
|
||
<Button href={moderationCaseHref(item.id)} size="sm"
|
||
>View case</Button
|
||
>
|
||
</div>
|
||
</article>{/each}
|
||
</div>{:else}<EmptyState
|
||
title="No cases found"
|
||
description="Open a case to preserve evidence, record an appeal, and document a staff decision."
|
||
/>{/if}
|
||
{:else}
|
||
<h2>Recent live actions</h2>
|
||
<p class="hint">
|
||
Queued and delivered actions are awaiting confirmation. An unknown outcome requires
|
||
review before issuing another action.
|
||
</p>
|
||
{#if report.actions.length}<div class="table-scroll">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th>Action</th><th>Target</th><th>Result</th><th
|
||
>Reason / message</th
|
||
><th>Moderator</th><th>Requested</th></tr
|
||
></thead
|
||
><tbody>
|
||
{#each report.actions as action (action.id)}<tr
|
||
><th>{moderationActionLabels[action.kind]}</th><td
|
||
>{action.guid ?? 'All players'}
|
||
<div><ServerIdentity name={action.server} /></div></td
|
||
><td
|
||
><StatusBadge
|
||
state={`moderation-${action.state}`}
|
||
/>{#if action.detail}<small>{action.detail}</small>{/if}</td
|
||
><td>{action.reason}</td><td>{action.actor}</td><td
|
||
><RelativeTime value={action.createdAt} /></td
|
||
></tr
|
||
>{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>{:else}<p class="hint">No recent live actions match these filters.</p>{/if}
|
||
<h2>Audit history</h2>
|
||
{#if report.events.length}<ol class="record-list audit-list">
|
||
{#each report.events as event (event.id)}<li class="record">
|
||
<div class="record-heading">
|
||
<strong
|
||
>{moderationActionLabels[event.action] ?? event.action}</strong
|
||
><RelativeTime value={event.createdAt} />
|
||
</div>
|
||
<p>{moderationEventDetail(event.detail, event.action)}</p>
|
||
<small
|
||
>{event.actor}{event.guid
|
||
? ` · Player ${event.guid}`
|
||
: ''}{event.game
|
||
? ` · ${moderationGameName(event.game)}`
|
||
: ''}</small
|
||
>{#if event.subjectId}
|
||
{#if event.action.startsWith('case.')}<a
|
||
href={moderationCaseHref(event.subjectId)}>View case</a
|
||
>{/if}
|
||
<CopyValue value={event.subjectId} label="Record ID" />
|
||
{/if}
|
||
</li>{/each}
|
||
</ol>{:else}<EmptyState
|
||
title="No history found"
|
||
description="Moderation decisions and private notes matching these filters will appear here."
|
||
/>{/if}
|
||
{/if}
|
||
<nav class="pagination" aria-label="Moderation results pagination">
|
||
<span role="status"
|
||
>{report.total ? report.query.offset + 1 : 0}–{Math.min(
|
||
report.query.offset + report.limit,
|
||
report.total
|
||
)} of {report.total.toLocaleString()} results</span
|
||
>
|
||
{#if report.total > report.limit || report.query.offset > 0}<div class="row-actions">
|
||
<Button
|
||
size="sm"
|
||
disabled={busy || report.query.offset === 0}
|
||
onclick={() =>
|
||
navigate({ offset: Math.max(0, report.query.offset - report.limit) })}
|
||
>Previous</Button
|
||
><Button
|
||
size="sm"
|
||
disabled={busy || report.query.offset + report.limit >= report.total}
|
||
onclick={() => navigate({ offset: report.query.offset + report.limit })}
|
||
>Next</Button
|
||
>
|
||
</div>{/if}
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
{#if permitted}{#key dialogKey}<ModerationDialog
|
||
bind:open={dialogOpen}
|
||
{mode}
|
||
{seed}
|
||
{admin}
|
||
coverage={report.coverage}
|
||
onsubmitted={submitted}
|
||
/>{/key}{/if}
|
||
|
||
<style>
|
||
.moderation-page,
|
||
.record-list,
|
||
.record {
|
||
display: grid;
|
||
gap: var(--space-5);
|
||
min-width: 0;
|
||
}
|
||
.filters,
|
||
.row-actions,
|
||
.record-heading,
|
||
.selected-player,
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-3);
|
||
}
|
||
.filters {
|
||
align-items: flex-end;
|
||
}
|
||
.filters :global(.search) {
|
||
flex: 1 1 240px;
|
||
}
|
||
.record-heading,
|
||
.selected-player,
|
||
.pagination {
|
||
justify-content: space-between;
|
||
}
|
||
.coverage,
|
||
.selected-player,
|
||
.record {
|
||
padding: var(--space-4);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface);
|
||
}
|
||
.record {
|
||
gap: var(--space-3);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.table-scroll {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
text-align: left;
|
||
font-size: var(--text-sm);
|
||
}
|
||
th,
|
||
td {
|
||
padding: var(--space-3);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
vertical-align: top;
|
||
}
|
||
thead th {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
tbody th {
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
small,
|
||
.hint,
|
||
.notice,
|
||
.pagination {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
td small,
|
||
th small {
|
||
display: block;
|
||
margin-top: var(--space-2);
|
||
}
|
||
h2 {
|
||
margin: 0;
|
||
font-size: var(--text-lg);
|
||
}
|
||
p {
|
||
margin: 0;
|
||
line-height: var(--leading-body);
|
||
white-space: pre-wrap;
|
||
}
|
||
.hint {
|
||
margin-block: var(--space-3);
|
||
}
|
||
.notice {
|
||
padding: var(--space-3);
|
||
border-left: 3px solid var(--border-strong);
|
||
}
|
||
dl {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-3) var(--space-6);
|
||
margin: 0;
|
||
font-size: var(--text-sm);
|
||
}
|
||
dt {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
margin-bottom: var(--space-1);
|
||
}
|
||
dd {
|
||
margin: 0;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.private-note {
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audit-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
.view-actions {
|
||
margin-bottom: var(--space-4);
|
||
}
|
||
.pagination {
|
||
margin-top: var(--space-4);
|
||
}
|
||
#moderation-results > h2 {
|
||
margin-top: var(--space-5);
|
||
}
|
||
@media (max-width: 640px) {
|
||
.filters {
|
||
align-items: stretch;
|
||
}
|
||
.filters :global(.field) {
|
||
flex: 1 1 100%;
|
||
}
|
||
}
|
||
.assessment-summary {
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 3;
|
||
line-clamp: 3;
|
||
overflow: hidden;
|
||
}
|
||
.notification-list {
|
||
margin: 0;
|
||
padding-left: var(--space-5);
|
||
display: grid;
|
||
gap: var(--space-2);
|
||
}
|
||
</style>
|