This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadModeration } from '$lib/auth.js';
|
||||
import { moderationHref } from '$ui/moderation.js';
|
||||
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
|
||||
@@ -10,16 +13,19 @@
|
||||
|
||||
let {
|
||||
server,
|
||||
game,
|
||||
history,
|
||||
hostOffline = false,
|
||||
hostName
|
||||
}: {
|
||||
server?: string;
|
||||
game?: string;
|
||||
history?: ContextualChatEntry[];
|
||||
hostOffline?: boolean;
|
||||
hostName?: string;
|
||||
} = $props();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
const moderationGame = $derived(game ?? server?.split('_')[0]);
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
const entries = $derived(history ?? liveEntries);
|
||||
let timeline = $derived(groupChatTimeline(entries));
|
||||
@@ -242,6 +248,18 @@
|
||||
<div class="message-head">
|
||||
<strong>{item.entry.message.player}</strong
|
||||
>{@render chatContext(item.entry.context)}
|
||||
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
|
||||
class="moderation-link"
|
||||
href={moderationHref({
|
||||
game: moderationGame,
|
||||
q: item.entry.message.guid,
|
||||
server,
|
||||
matchId: item.entry.matchId,
|
||||
message: item.entry.message.message
|
||||
})}
|
||||
aria-label={`Review ${item.entry.message.player} in moderation`}
|
||||
>Moderation</a
|
||||
>{/if}
|
||||
</div>
|
||||
<p>{item.entry.message.message}</p>
|
||||
</div>
|
||||
@@ -371,6 +389,9 @@
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.moderation-link {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.match-events {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
@@ -463,5 +484,9 @@
|
||||
grid-row: 3;
|
||||
margin-top: 0;
|
||||
}
|
||||
.moderation-link {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadModeration } from '$lib/auth.js';
|
||||
import { moderationHref } from '$ui/moderation.js';
|
||||
import type { GameTelemetry } from '$lib/telemetry.js';
|
||||
import { freshTelemetry } from '$lib/server-observation.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
@@ -164,6 +167,14 @@
|
||||
<a class="player-name" href={playerProfileHref(player.guid)}
|
||||
>{player.name}</a
|
||||
>
|
||||
{#if canReadModeration($page.data?.user?.role)}<a
|
||||
class="player-details"
|
||||
href={moderationHref({
|
||||
game: report.server.split('_')[0],
|
||||
guid: player.guid,
|
||||
server: report.server
|
||||
})}>Moderation</a
|
||||
>{/if}
|
||||
<button
|
||||
class="player-details"
|
||||
aria-label={`Details for ${player.name}`}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { ModerationCoverage, ModerationGame, ModerationScope } 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';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
mode,
|
||||
coverage,
|
||||
admin,
|
||||
seed = {},
|
||||
onsubmitted
|
||||
}: {
|
||||
open?: boolean;
|
||||
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
|
||||
coverage: ModerationCoverage[];
|
||||
admin: boolean;
|
||||
seed?: {
|
||||
game?: ModerationGame;
|
||||
guid?: string;
|
||||
server?: string;
|
||||
id?: string;
|
||||
revision?: number;
|
||||
matchId?: number;
|
||||
message?: string;
|
||||
};
|
||||
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => 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('');
|
||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||
let resolution = $state('resolved');
|
||||
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',
|
||||
resolve: 'Review case'
|
||||
}[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)
|
||||
};
|
||||
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, text: note.trim() };
|
||||
if (mode === 'revoke')
|
||||
return {
|
||||
op: 'revoke-sanction',
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
reason: reason.trim()
|
||||
};
|
||||
return {
|
||||
op: 'resolve-case',
|
||||
id: seed.id,
|
||||
revision: seed.revision,
|
||||
status: resolution,
|
||||
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
|
||||
});
|
||||
} 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
|
||||
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 /></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 mode === 'case'}
|
||||
<Field label="Case title" required
|
||||
><Input bind:value={title} required maxlength={160} /></Field
|
||||
>
|
||||
{#if 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 === 'resolve'}<Field label="Decision"
|
||||
><Select
|
||||
bind:value={resolution}
|
||||
options={[
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'dismissed', label: 'Dismissed' }
|
||||
]}
|
||||
/></Field
|
||||
>{/if}
|
||||
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
|
||||
<Field
|
||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||
? 'Message'
|
||||
: mode === 'restriction' || mode === 'action'
|
||||
? 'Player-visible 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}
|
||||
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>
|
||||
@@ -11,6 +11,7 @@
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
maxlength?: number;
|
||||
mono?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
@@ -25,6 +26,7 @@
|
||||
required = false,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
maxlength,
|
||||
mono = false,
|
||||
oninput,
|
||||
ariaLabel
|
||||
@@ -41,6 +43,7 @@
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{maxlength}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ModerationGame } from '$lib/moderation.js';
|
||||
import { serverTitle } from './server-labels.js';
|
||||
|
||||
export function moderationHref(context: {
|
||||
game?: string;
|
||||
guid?: string;
|
||||
q?: string;
|
||||
server?: string;
|
||||
matchId?: number;
|
||||
message?: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (context.game === 't5' || context.game === 't6') params.set('game', context.game);
|
||||
if (context.guid) params.set('guid', context.guid);
|
||||
if (context.q) params.set('q', context.q);
|
||||
if (context.server) params.set('server', context.server);
|
||||
if (context.matchId !== undefined && context.message) {
|
||||
params.set('matchId', String(context.matchId));
|
||||
params.set('message', context.message);
|
||||
}
|
||||
return `/moderation?${params}`;
|
||||
}
|
||||
|
||||
export function moderationGameName(game: ModerationGame): string {
|
||||
return game === 't5' ? 'Black Ops I' : 'Black Ops II';
|
||||
}
|
||||
|
||||
export const moderationActionLabels: Record<string, string> = {
|
||||
warn: 'Warning',
|
||||
message: 'Private message',
|
||||
kick: 'Kick',
|
||||
announce: 'Announcement',
|
||||
'create-sanction': 'Restriction created',
|
||||
'revoke-sanction': 'Restriction revoked',
|
||||
'create-case': 'Case opened',
|
||||
'case-note': 'Case note added',
|
||||
'resolve-case': 'Case reviewed',
|
||||
note: 'Private note',
|
||||
'restriction.created': 'Restriction created',
|
||||
'restriction.revoked': 'Restriction revoked',
|
||||
'action.queued': 'Live action requested',
|
||||
'action.confirmed': 'Live action confirmed',
|
||||
'action.failed': 'Live action failed',
|
||||
'action.expired': 'Live action expired',
|
||||
'action.unknown': 'Live action outcome unknown',
|
||||
'case.opened': 'Case opened',
|
||||
'case.note': 'Case note added',
|
||||
'case.resolved': 'Case resolved',
|
||||
'case.dismissed': 'Case dismissed',
|
||||
'player.note': 'Private player note',
|
||||
'runtime.configure': 'Integration configuration requested'
|
||||
};
|
||||
|
||||
export function moderationEventDetail(detail: string, action: string): string {
|
||||
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
|
||||
try {
|
||||
const value: unknown = JSON.parse(detail);
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('reason' in value) ||
|
||||
typeof value.reason !== 'string'
|
||||
)
|
||||
return detail;
|
||||
const record = value as Record<string, unknown>;
|
||||
const context = [
|
||||
typeof record.kind === 'string'
|
||||
? (moderationActionLabels[record.kind] ??
|
||||
(record.kind === 'ban' ? 'Ban' : record.kind === 'mute' ? 'Mute' : ''))
|
||||
: '',
|
||||
typeof record.server === 'string'
|
||||
? serverTitle(record.server)
|
||||
: record.scope === 'fleet'
|
||||
? 'Entire fleet'
|
||||
: record.scope === 'game'
|
||||
? 'Game scope'
|
||||
: ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return [
|
||||
context,
|
||||
record.reason,
|
||||
typeof record.note === 'string' && record.note ? `Private note: ${record.note}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} catch {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,21 @@ export const statusShapes: Record<StatusTone, StatusShape> = {
|
||||
};
|
||||
|
||||
const states: Record<string, [string, StatusTone]> = {
|
||||
'moderation-current': ['Policy current', 'online'],
|
||||
'moderation-pending': ['Policy pending', 'warning'],
|
||||
'moderation-offline': ['Offline', 'danger'],
|
||||
'moderation-unavailable': ['Unavailable', 'warning'],
|
||||
'moderation-active': ['Active', 'warning'],
|
||||
'moderation-revoked': ['Revoked', 'neutral'],
|
||||
'moderation-open': ['Open', 'info'],
|
||||
'moderation-resolved': ['Resolved', 'online'],
|
||||
'moderation-dismissed': ['Dismissed', 'neutral'],
|
||||
'moderation-queued': ['Queued', 'neutral'],
|
||||
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
|
||||
'moderation-confirmed': ['Confirmed', 'online'],
|
||||
'moderation-failed': ['Failed', 'danger'],
|
||||
'moderation-expired': ['Expired', 'neutral'],
|
||||
'moderation-unknown': ['Outcome unknown', 'warning'],
|
||||
provisioning: ['Provisioning', 'info'],
|
||||
online: ['Online', 'online'],
|
||||
offline: ['Offline', 'danger'],
|
||||
|
||||
Reference in New Issue
Block a user