540 lines
22 KiB
Svelte
540 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import {
|
|
moderationCaseStatuses,
|
|
moderationCaseStatusLabels,
|
|
type ModerationCoverage,
|
|
type ModerationGame,
|
|
type ModerationScope,
|
|
type ModerationCase
|
|
} from '$lib/moderation.js';
|
|
import { moderationGameName } from '$ui/moderation.js';
|
|
import { serverTitle } from '$ui/server-labels.js';
|
|
import { confirmAction } from '$ui/confirm.js';
|
|
import Dialog from '../primitives/Dialog.svelte';
|
|
import Field from '../primitives/Field.svelte';
|
|
import Input from '../primitives/Input.svelte';
|
|
import Select from '../primitives/Select.svelte';
|
|
import Textarea from '../primitives/Textarea.svelte';
|
|
import Button from '../primitives/Button.svelte';
|
|
import ExistingCaseNotice from './ExistingCaseNotice.svelte';
|
|
|
|
let {
|
|
open = $bindable(false),
|
|
mode,
|
|
coverage,
|
|
admin,
|
|
seed = {},
|
|
assignees = [],
|
|
onsubmitted
|
|
}: {
|
|
open?: boolean;
|
|
mode:
|
|
| 'restriction'
|
|
| 'action'
|
|
| 'case'
|
|
| 'note'
|
|
| 'revoke'
|
|
| 'case-note'
|
|
| 'case-status'
|
|
| 'edit-case'
|
|
| 'evidence';
|
|
coverage: ModerationCoverage[];
|
|
admin: boolean;
|
|
assignees?: { id: string; username: string }[];
|
|
seed?: {
|
|
game?: ModerationGame;
|
|
guid?: string;
|
|
server?: string;
|
|
id?: string;
|
|
revision?: number;
|
|
matchId?: number;
|
|
message?: string;
|
|
caseId?: string;
|
|
caseRevision?: number;
|
|
title?: string;
|
|
priority?: ModerationCase['priority'];
|
|
assigneeId?: string | null;
|
|
assignee?: string | null;
|
|
status?: ModerationCase['status'];
|
|
};
|
|
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => void;
|
|
} = $props();
|
|
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
|
|
let guid = $state(untrack(() => seed.guid ?? ''));
|
|
let server = $state(untrack(() => seed.server ?? ''));
|
|
let scope = $state<ModerationScope>(untrack(() => (seed.server ? 'server' : 'game')));
|
|
let kind = $state('ban');
|
|
let action = $state('warn');
|
|
let duration = $state('60');
|
|
let reason = $state('');
|
|
let note = $state('');
|
|
let title = $state(untrack(() => seed.title ?? ''));
|
|
let priority = $state(untrack(() => seed.priority ?? 'normal'));
|
|
let assigneeId = $state(untrack(() => seed.assigneeId ?? ''));
|
|
let status = $state(untrack(() => seed.status ?? 'open'));
|
|
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
|
let message = $state(untrack(() => seed.message ?? ''));
|
|
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
|
|
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
|
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
|
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
|
let busy = $state(false);
|
|
let error = $state('');
|
|
let uncertain = $state(false);
|
|
let pending: Record<string, unknown> | null = null;
|
|
const titleText = $derived(
|
|
{
|
|
restriction: 'Restrict player',
|
|
action: 'Live action',
|
|
case: 'Open moderation case',
|
|
note: 'Add private note',
|
|
revoke: 'Revoke restriction',
|
|
'case-note': 'Add case note',
|
|
'case-status': 'Change status',
|
|
'edit-case': 'Edit case',
|
|
evidence: 'Attach archived evidence'
|
|
}[mode]
|
|
);
|
|
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
|
|
const needsServer = $derived(
|
|
mode === 'action' || (mode === 'restriction' && scope === 'server')
|
|
);
|
|
const serverOptions = $derived([
|
|
{ value: '', label: 'Select a server' },
|
|
...coverage
|
|
.filter((item) => item.game === game)
|
|
.map((item) => ({ value: item.server, label: serverTitle(item.server) }))
|
|
]);
|
|
const selectedServer = $derived(
|
|
coverage.find((item) => item.server === server && item.game === game)
|
|
);
|
|
const actionUnavailable = $derived(
|
|
mode === 'action' &&
|
|
(!selectedServer ||
|
|
['offline', 'unavailable'].includes(selectedServer.state) ||
|
|
!selectedServer.capabilities.includes(action))
|
|
);
|
|
const actionOptions = $derived(
|
|
[
|
|
{ value: 'warn', label: 'Warn player' },
|
|
{ value: 'message', label: 'Private message' },
|
|
{ value: 'kick', label: 'Kick player' },
|
|
{ value: 'announce', label: 'Server announcement' }
|
|
].map((item) => ({ ...item, disabled: !selectedServer?.capabilities.includes(item.value) }))
|
|
);
|
|
|
|
function payload(): Record<string, unknown> {
|
|
if (mode === 'restriction')
|
|
return {
|
|
op: 'create-sanction',
|
|
game,
|
|
guid: guid.trim(),
|
|
kind,
|
|
scope,
|
|
server: scope === 'server' ? server : undefined,
|
|
reason: reason.trim(),
|
|
note: note.trim(),
|
|
durationMinutes: duration === 'permanent' ? null : Number(duration),
|
|
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
|
|
};
|
|
if (mode === 'action')
|
|
return {
|
|
op: 'action',
|
|
game,
|
|
guid: action === 'announce' ? null : guid.trim(),
|
|
server,
|
|
kind: action,
|
|
reason: reason.trim()
|
|
};
|
|
if (mode === 'case')
|
|
return {
|
|
op: 'create-case',
|
|
game,
|
|
guid: guid.trim(),
|
|
title: title.trim(),
|
|
note: note.trim(),
|
|
...(seed.matchId && seed.message
|
|
? { evidence: { matchId: seed.matchId, message: seed.message } }
|
|
: {})
|
|
};
|
|
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
|
if (mode === 'case-note')
|
|
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
|
if (mode === 'edit-case')
|
|
return {
|
|
op: 'update-case',
|
|
id: seed.id,
|
|
revision: seed.revision,
|
|
title: title.trim(),
|
|
priority,
|
|
...(assigneeId !== (seed.assigneeId ?? '')
|
|
? { assigneeId: assigneeId || null }
|
|
: {}),
|
|
reason: reason.trim()
|
|
};
|
|
if (mode === 'evidence')
|
|
return {
|
|
op: 'attach-evidence',
|
|
id: seed.id,
|
|
revision: seed.revision,
|
|
evidence: { matchId, message }
|
|
};
|
|
if (mode === 'revoke')
|
|
return {
|
|
op: 'revoke-sanction',
|
|
id: seed.id,
|
|
revision: seed.revision,
|
|
reason: reason.trim()
|
|
};
|
|
return {
|
|
op: 'update-case',
|
|
id: seed.id,
|
|
revision: seed.revision,
|
|
status,
|
|
reason: reason.trim()
|
|
};
|
|
}
|
|
|
|
async function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (busy) return;
|
|
if (!pending && gameText && reasonBytes > 240) {
|
|
error = 'The player-visible text is too long. Shorten it to 240 bytes or fewer.';
|
|
return;
|
|
}
|
|
if (!pending && targetRequired && !game) {
|
|
error = 'Choose the game for this player identity.';
|
|
return;
|
|
}
|
|
if (!pending && actionUnavailable) {
|
|
error = 'The selected server does not currently support this action.';
|
|
return;
|
|
}
|
|
busy = true;
|
|
error = '';
|
|
if (!pending && mode === 'restriction' && (scope === 'fleet' || duration === 'permanent')) {
|
|
const accepted = await confirmAction({
|
|
title: 'Confirm restriction',
|
|
message: `${kind === 'ban' ? 'Ban' : 'Mute'} player ${guid} ${scope === 'fleet' ? 'across the fleet' : scope === 'game' ? `in ${moderationGameName(game as ModerationGame)}` : `on ${serverTitle(server)}`} ${duration === 'permanent' ? 'permanently' : `for ${Number(duration).toLocaleString()} minutes`}. Reason: ${reason}`,
|
|
confirmLabel: 'Apply restriction',
|
|
danger: true
|
|
});
|
|
if (!accepted) {
|
|
busy = false;
|
|
return;
|
|
}
|
|
}
|
|
pending ??= { ...payload(), idempotencyKey: crypto.randomUUID() };
|
|
try {
|
|
const response = await fetch('/api/moderation', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(pending)
|
|
});
|
|
const result = await response.json().catch(() => null);
|
|
if (!response.ok || !result?.ok) {
|
|
uncertain = response.status >= 500 || response.ok;
|
|
if (!uncertain) pending = null;
|
|
throw new Error(result?.error || 'Could not save this moderation action.');
|
|
}
|
|
open = false;
|
|
onsubmitted({
|
|
game: pending.game as ModerationGame | undefined,
|
|
guid: pending.guid as string | null | undefined,
|
|
id: typeof result.id === 'string' ? result.id : undefined
|
|
});
|
|
} catch (cause) {
|
|
if (pending) uncertain = true;
|
|
error =
|
|
cause instanceof Error ? cause.message : 'Could not save this moderation action.';
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Dialog
|
|
bind:open
|
|
title={titleText}
|
|
{busy}
|
|
description="Every moderation change is recorded with your identity and reason."
|
|
>
|
|
<form onsubmit={submit} class="moderation-form">
|
|
<fieldset disabled={busy || uncertain}>
|
|
{#if targetRequired}
|
|
<Field label="Game" required
|
|
><Select
|
|
bind:value={game}
|
|
required
|
|
disabled={lockedTarget}
|
|
onchange={() => (server = '')}
|
|
options={[
|
|
{ value: '', label: 'Select a game' },
|
|
{ value: 't5', label: 'Black Ops I' },
|
|
{ value: 't6', label: 'Black Ops II' }
|
|
]}
|
|
/></Field
|
|
>
|
|
{#if mode === 'action'}
|
|
<Field label="Action"
|
|
><Select bind:value={action} options={actionOptions} /></Field
|
|
>
|
|
{/if}
|
|
{#if mode !== 'action' || action !== 'announce'}
|
|
<Field
|
|
label="Player GUID"
|
|
required
|
|
hint="Use the recorded player identity. Names can change."
|
|
><Input
|
|
name="target-guid"
|
|
bind:value={guid}
|
|
required
|
|
mono
|
|
readonly={lockedTarget}
|
|
/></Field
|
|
>
|
|
{/if}
|
|
{/if}
|
|
{#if mode === 'restriction'}
|
|
<div class="paired">
|
|
<Field label="Restriction"
|
|
><Select
|
|
bind:value={kind}
|
|
options={[
|
|
{ value: 'ban', label: 'Ban' },
|
|
{ value: 'mute', label: 'Mute' }
|
|
]}
|
|
/></Field
|
|
>
|
|
<Field label="Scope"
|
|
><Select
|
|
bind:value={scope}
|
|
options={[
|
|
{ value: 'server', label: 'One server' },
|
|
{ value: 'game', label: 'This game' },
|
|
...(admin ? [{ value: 'fleet', label: 'Entire fleet' }] : [])
|
|
]}
|
|
/></Field
|
|
>
|
|
</div>
|
|
<Field
|
|
label="Duration"
|
|
hint={admin
|
|
? undefined
|
|
: 'Moderators can restrict a player for up to seven days.'}
|
|
>
|
|
<Select
|
|
bind:value={duration}
|
|
options={[
|
|
{ value: '15', label: '15 minutes' },
|
|
{ value: '60', label: '1 hour' },
|
|
{ value: '1440', label: '1 day' },
|
|
{ value: '10080', label: '7 days' },
|
|
...(admin
|
|
? [
|
|
{ value: '43200', label: '30 days' },
|
|
{ value: 'permanent', label: 'Permanent' }
|
|
]
|
|
: [])
|
|
]}
|
|
/>
|
|
</Field>
|
|
{#if scope === 'fleet'}<p class="hint">
|
|
Fleet restrictions require a verified player identity across games.
|
|
</p>{/if}
|
|
{/if}
|
|
{#if needsServer}<Field label="Server" required
|
|
><Select bind:value={server} options={serverOptions} required /></Field
|
|
>{/if}
|
|
{#if actionUnavailable}<p class="hint">
|
|
Select a connected server with support for this action. Unavailable actions
|
|
cannot be sent.
|
|
</p>{/if}
|
|
{#if mode === 'restriction'}<p class="hint">
|
|
Restrictions are saved immediately. They take effect only on servers with the
|
|
required capability and current policy; review enforcement coverage after
|
|
saving.
|
|
</p>{/if}
|
|
{#if lockedTarget}<p class="hint">
|
|
This restriction will be linked to the case and its recorded player identity.
|
|
Applying it does not close the case; change its status when your work is
|
|
complete.
|
|
</p>{/if}
|
|
{#if mode === 'case' || mode === 'edit-case'}
|
|
{#if mode === 'case' && open && game && guid.trim()}<ExistingCaseNotice
|
|
{game}
|
|
{guid}
|
|
/>{/if}
|
|
<Field label="Case title" required
|
|
><Input bind:value={title} required maxlength={160} /></Field
|
|
>
|
|
{#if mode === 'case' && seed.matchId && seed.message}<div class="evidence">
|
|
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
|
|
<p>{seed.message}</p>
|
|
<small
|
|
>The original archived message and its context will be preserved when
|
|
the case is saved.</small
|
|
>
|
|
</div>{/if}
|
|
{/if}
|
|
{#if mode === 'edit-case'}
|
|
<div class="paired">
|
|
<Field label="Priority"
|
|
><Select
|
|
bind:value={priority}
|
|
options={[
|
|
{ value: 'low', label: 'Low' },
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'high', label: 'High' },
|
|
{ value: 'urgent', label: 'Urgent' }
|
|
]}
|
|
/></Field
|
|
>
|
|
<Field label="Assigned to"
|
|
><Select
|
|
bind:value={assigneeId}
|
|
options={[
|
|
{ value: '', label: 'Unassigned' },
|
|
...assignees.map((person) => ({
|
|
value: person.id,
|
|
label: person.username
|
|
})),
|
|
...(seed.assigneeId &&
|
|
!assignees.some((person) => person.id === seed.assigneeId)
|
|
? [
|
|
{
|
|
value: seed.assigneeId,
|
|
label: `${seed.assignee ?? 'Previous assignee'} (unavailable)`
|
|
}
|
|
]
|
|
: [])
|
|
]}
|
|
/></Field
|
|
>
|
|
</div>
|
|
<Field label="Change reason" hint="Optional note about these edits.">
|
|
<Textarea bind:value={reason} rows={3} />
|
|
</Field>
|
|
{/if}
|
|
{#if mode === 'evidence'}
|
|
<p class="hint">
|
|
Choose a match and copy the exact archived message from this player. The archive
|
|
must verify the case's game and player identity before evidence is attached.
|
|
</p>
|
|
<Field label="Match ID" required
|
|
><Input type="number" bind:value={matchId} min={1} step={1} required /></Field
|
|
>
|
|
<Field label="Exact archived message" required
|
|
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
|
>
|
|
{/if}
|
|
{#if mode === 'case-status'}
|
|
<Field label="Status"
|
|
><Select
|
|
bind:value={status}
|
|
options={moderationCaseStatuses.map((value) => ({
|
|
value,
|
|
label: moderationCaseStatusLabels[value]
|
|
}))}
|
|
/></Field
|
|
>
|
|
<p class="hint">
|
|
Track whether this case is open, in progress, or closed. Findings and recorded
|
|
actions stay in the case history.
|
|
</p>
|
|
{/if}
|
|
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
|
|
<Field
|
|
label={mode === 'action' && ['message', 'announce'].includes(action)
|
|
? 'Message'
|
|
: mode === 'restriction' || mode === 'action'
|
|
? 'Player-visible reason'
|
|
: mode === 'case-status'
|
|
? 'Change reason'
|
|
: 'Review reason'}
|
|
hint={gameText
|
|
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
|
: undefined}
|
|
required
|
|
><Textarea
|
|
bind:value={reason}
|
|
required
|
|
rows={3}
|
|
maxlength={gameText ? 240 : undefined}
|
|
/></Field
|
|
>
|
|
{/if}
|
|
{#if ['restriction', 'case', 'note', 'case-note'].includes(mode)}
|
|
<Field label="Private staff note" required={mode === 'note' || mode === 'case-note'}
|
|
><Textarea
|
|
bind:value={note}
|
|
required={mode === 'note' || mode === 'case-note'}
|
|
rows={4}
|
|
/></Field
|
|
>
|
|
{/if}
|
|
</fieldset>
|
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
|
{#if uncertain}<p class="hint" role="status">
|
|
The outcome is not yet known. Retry sends the same request safely; check History
|
|
before starting a different action.
|
|
</p>{/if}
|
|
<div class="actions">
|
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
|
type="submit"
|
|
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
|
|
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
|
>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<style>
|
|
.moderation-form,
|
|
fieldset {
|
|
display: grid;
|
|
gap: var(--space-4);
|
|
min-width: 0;
|
|
}
|
|
fieldset {
|
|
border: 0;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.paired {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: var(--space-3);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: var(--space-3);
|
|
}
|
|
.hint,
|
|
.evidence {
|
|
margin: 0;
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.evidence {
|
|
padding: var(--space-3);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.evidence p {
|
|
white-space: pre-wrap;
|
|
}
|
|
.evidence small {
|
|
font-size: var(--text-xs);
|
|
}
|
|
@media (max-width: 640px) {
|
|
.paired {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|