This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
|
||||
import {
|
||||
isModerationCaseOpen,
|
||||
moderationCaseOutcomes,
|
||||
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';
|
||||
@@ -17,12 +25,24 @@
|
||||
coverage,
|
||||
admin,
|
||||
seed = {},
|
||||
assignees = [],
|
||||
onsubmitted
|
||||
}: {
|
||||
open?: boolean;
|
||||
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
|
||||
mode:
|
||||
| 'restriction'
|
||||
| 'action'
|
||||
| 'case'
|
||||
| 'note'
|
||||
| 'revoke'
|
||||
| 'case-note'
|
||||
| 'resolve'
|
||||
| 'reopen'
|
||||
| 'edit-case'
|
||||
| 'evidence';
|
||||
coverage: ModerationCoverage[];
|
||||
admin: boolean;
|
||||
assignees?: { id: string; username: string }[];
|
||||
seed?: {
|
||||
game?: ModerationGame;
|
||||
guid?: string;
|
||||
@@ -31,8 +51,15 @@
|
||||
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 }) => void;
|
||||
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => void;
|
||||
} = $props();
|
||||
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
|
||||
let guid = $state(untrack(() => seed.guid ?? ''));
|
||||
@@ -43,7 +70,14 @@
|
||||
let duration = $state('60');
|
||||
let reason = $state('');
|
||||
let note = $state('');
|
||||
let title = $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 lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
||||
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
|
||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||
let resolution = $state('resolved');
|
||||
@@ -59,7 +93,10 @@
|
||||
note: 'Add private note',
|
||||
revoke: 'Revoke restriction',
|
||||
'case-note': 'Add case note',
|
||||
resolve: 'Review case'
|
||||
resolve: 'Review case',
|
||||
reopen: 'Reopen case',
|
||||
'edit-case': 'Edit case',
|
||||
evidence: 'Attach archived evidence'
|
||||
}[mode]
|
||||
);
|
||||
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
|
||||
@@ -101,7 +138,8 @@
|
||||
server: scope === 'server' ? server : undefined,
|
||||
reason: reason.trim(),
|
||||
note: note.trim(),
|
||||
durationMinutes: duration === 'permanent' ? null : Number(duration)
|
||||
durationMinutes: duration === 'permanent' ? null : Number(duration),
|
||||
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
|
||||
};
|
||||
if (mode === 'action')
|
||||
return {
|
||||
@@ -124,7 +162,35 @@
|
||||
: {})
|
||||
};
|
||||
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
||||
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
|
||||
if (mode === 'case-note')
|
||||
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
||||
if (mode === 'reopen')
|
||||
return {
|
||||
op: 'reopen-case',
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
reason: reason.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 }
|
||||
: {}),
|
||||
...(isModerationCaseOpen(status) ? { status } : {}),
|
||||
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',
|
||||
@@ -186,7 +252,8 @@
|
||||
open = false;
|
||||
onsubmitted({
|
||||
game: pending.game as ModerationGame | undefined,
|
||||
guid: pending.guid as string | null | undefined
|
||||
guid: pending.guid as string | null | undefined,
|
||||
id: typeof result.id === 'string' ? result.id : undefined
|
||||
});
|
||||
} catch (cause) {
|
||||
if (pending) uncertain = true;
|
||||
@@ -211,6 +278,7 @@
|
||||
><Select
|
||||
bind:value={game}
|
||||
required
|
||||
disabled={lockedTarget}
|
||||
onchange={() => (server = '')}
|
||||
options={[
|
||||
{ value: '', label: 'Select a game' },
|
||||
@@ -229,7 +297,13 @@
|
||||
label="Player GUID"
|
||||
required
|
||||
hint="Use the recorded player identity. Names can change."
|
||||
><Input name="target-guid" bind:value={guid} required mono /></Field
|
||||
><Input
|
||||
name="target-guid"
|
||||
bind:value={guid}
|
||||
required
|
||||
mono
|
||||
readonly={lockedTarget}
|
||||
/></Field
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -293,11 +367,16 @@
|
||||
required capability and current policy; review enforcement coverage after
|
||||
saving.
|
||||
</p>{/if}
|
||||
{#if mode === 'case'}
|
||||
{#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; record the outcome when your review is
|
||||
complete.
|
||||
</p>{/if}
|
||||
{#if mode === 'case' || mode === 'edit-case'}
|
||||
<Field label="Case title" required
|
||||
><Input bind:value={title} required maxlength={160} /></Field
|
||||
>
|
||||
{#if seed.matchId && seed.message}<div class="evidence">
|
||||
{#if mode === 'case' && seed.matchId && seed.message}<div class="evidence">
|
||||
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
|
||||
<p>{seed.message}</p>
|
||||
<small
|
||||
@@ -306,22 +385,102 @@
|
||||
>
|
||||
</div>{/if}
|
||||
{/if}
|
||||
{#if mode === 'resolve'}<Field label="Decision"
|
||||
{#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>
|
||||
{#if isModerationCaseOpen(seed.status ?? 'open')}
|
||||
<Field label="Workflow status"
|
||||
><Select
|
||||
bind:value={status}
|
||||
options={[
|
||||
{ value: 'open', label: moderationCaseStatusLabels.open },
|
||||
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
|
||||
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
{:else}<p class="hint">
|
||||
The recorded outcome is preserved. Reopen the case to resume review.
|
||||
</p>{/if}
|
||||
<Field
|
||||
label="Change reason"
|
||||
required={stateChanged}
|
||||
hint="Required when changing workflow status."
|
||||
>
|
||||
<Textarea bind:value={reason} required={stateChanged} 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 === 'resolve'}<Field label="Outcome"
|
||||
><Select
|
||||
bind:value={resolution}
|
||||
options={[
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'dismissed', label: 'Dismissed' }
|
||||
]}
|
||||
options={moderationCaseOutcomes.map((value) => ({
|
||||
value,
|
||||
label: moderationCaseStatusLabels[value]
|
||||
}))}
|
||||
/></Field
|
||||
>{/if}
|
||||
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
|
||||
{#if mode === 'resolve'}<p class="hint">
|
||||
This records the decision and closes the case. Apply or revoke restrictions
|
||||
separately, and send warnings through Live action. Choosing an outcome does not
|
||||
change enforcement or send a warning.
|
||||
</p>{/if}
|
||||
{#if mode === 'reopen'}<p class="hint">
|
||||
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
||||
earlier decisions.
|
||||
</p>{/if}
|
||||
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
|
||||
<Field
|
||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||
? 'Message'
|
||||
: mode === 'restriction' || mode === 'action'
|
||||
? 'Player-visible reason'
|
||||
: 'Review reason'}
|
||||
: mode === 'reopen'
|
||||
? 'Reopening reason'
|
||||
: 'Review reason'}
|
||||
hint={gameText
|
||||
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
||||
: undefined}
|
||||
|
||||
Reference in New Issue
Block a user