Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s

This commit is contained in:
2026-10-15 21:33:50 -04:00
parent c91d49c298
commit 8b372fa2c1
50 changed files with 4744 additions and 354 deletions
+34
View File
@@ -0,0 +1,34 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import { mutateModeration } from '$lib/server/moderation/actions.js';
import { configureModerationServer } from '$lib/server/moderation/configure.js';
import { moderationMutation, textField } from '$lib/server/moderation/core.js';
const headers = { 'Cache-Control': 'no-store' };
export const GET: RequestHandler = ({ locals, url }) => {
try {
return json(
moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams)),
{ headers }
);
} catch (cause) {
return authError(cause);
}
};
export const POST: RequestHandler = async ({ locals, request }) => {
try {
const actor = requireUser(locals.user);
const body = await authBody(request, 16 * 1024);
if (body.op === 'configure')
return json(
moderationMutation(actor.id, body, () =>
configureModerationServer(actor.id, textField(body.server, 'Server', 255))
),
{ headers }
);
return json(mutateModeration(actor.id, body), { headers });
} catch (cause) {
return authError(cause);
}
};
@@ -0,0 +1,20 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError } from '$lib/server/auth/http.js';
import { authenticateModerationRuntime } from '$lib/server/moderation/credentials.js';
import { syncModerationRuntime } from '$lib/server/moderation/runtime.js';
export const POST: RequestHandler = async ({ request }) => {
const identity = authenticateModerationRuntime(request);
if (!identity)
return json(
{ error: 'Unauthorized moderation runtime.' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } }
);
try {
return json(syncModerationRuntime(identity, await authBody(request, 256 * 1024)), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return authError(cause);
}
};
@@ -7,6 +7,8 @@
import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import { contextHref } from '$lib/navigation-context.js';
import {
leaderboardMetrics,
@@ -151,7 +153,17 @@
fallback={data.returnTo}
items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]}
/>{/snippet}
{#snippet actions()}<Freshness
{#snippet actions()}{#if canReadModeration($page.data?.user?.role)}<Button
href={moderationHref({
guid: profile.guid,
game:
scope.game ||
(profile.maps.length &&
profile.maps.every((map) => map.game === profile.maps[0].game)
? profile.maps[0].game
: undefined)
})}>Moderation</Button
>{/if}<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
+1 -1
View File
@@ -183,7 +183,7 @@
{/if}
</section>
<Card padding="lg"><ChatPanel history={match.chat} /></Card>
<Card padding="lg"><ChatPanel history={match.chat} game={match.game} /></Card>
<details class="note">
<summary>About these results</summary>
+7
View File
@@ -0,0 +1,7 @@
import { requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, url }) => ({
moderation: moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams))
});
+821
View File
@@ -0,0 +1,821 @@
<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,
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
} 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 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' | 'case-note' | 'resolve'
>('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 }) {
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>
{#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, 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>
<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
})}>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.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 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>{item.title}</h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
</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}<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);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.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%;
}
}
</style>