Files
electric-cherry/src/routes/moderation/+page.svelte
T

974 lines
42 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>