261 lines
9.7 KiB
Svelte
261 lines
9.7 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import {
|
|
moderationConcernCategories,
|
|
moderationConcernLabels,
|
|
moderationFindingOutcomes,
|
|
moderationFindingLabels,
|
|
type ModerationCaseAssessment,
|
|
type ModerationConcernCategory,
|
|
type ModerationFindingOutcome
|
|
} from '$lib/moderation.js';
|
|
import Dialog from '../primitives/Dialog.svelte';
|
|
import Field from '../primitives/Field.svelte';
|
|
import Select from '../primitives/Select.svelte';
|
|
import Textarea from '../primitives/Textarea.svelte';
|
|
import Button from '../primitives/Button.svelte';
|
|
|
|
interface DraftFinding {
|
|
category: ModerationConcernCategory | '';
|
|
finding: ModerationFindingOutcome | '';
|
|
detail: string;
|
|
key: number;
|
|
}
|
|
let {
|
|
open = $bindable(false),
|
|
caseId,
|
|
revision,
|
|
assessment,
|
|
onsubmitted
|
|
}: {
|
|
open?: boolean;
|
|
caseId: string;
|
|
revision: number;
|
|
assessment: ModerationCaseAssessment | null;
|
|
onsubmitted: () => void;
|
|
} = $props();
|
|
const openedRevision = untrack(() => revision);
|
|
let summary = $state(untrack(() => assessment?.summary ?? ''));
|
|
let rulesText = $state(untrack(() => assessment?.rules.join('\n') ?? ''));
|
|
let nextKey = 0;
|
|
let findings = $state<DraftFinding[]>(
|
|
untrack(
|
|
() =>
|
|
assessment?.findings.map((finding) => ({ ...finding, key: nextKey++ })) ?? [
|
|
{ category: '', finding: '', detail: '', key: nextKey++ }
|
|
]
|
|
)
|
|
);
|
|
let busy = $state(false);
|
|
let uncertain = $state(false);
|
|
let error = $state('');
|
|
let pending: Record<string, unknown> | null = null;
|
|
|
|
async function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (busy) return;
|
|
if (!pending) {
|
|
const rules = rulesText
|
|
.split('\n')
|
|
.map((rule) => rule.trim())
|
|
.filter(Boolean);
|
|
if (
|
|
!summary.trim() ||
|
|
!findings.length ||
|
|
findings.some((item) => !item.category || !item.finding || !item.detail.trim())
|
|
) {
|
|
error = 'Add a summary and complete each concern, finding, and explanation.';
|
|
return;
|
|
}
|
|
if (rules.length > 20 || rules.some((rule) => rule.length > 160)) {
|
|
error = 'Use up to 20 applicable rules, with no more than 160 characters per line.';
|
|
return;
|
|
}
|
|
pending = {
|
|
op: 'assess-case',
|
|
id: caseId,
|
|
revision: openedRevision,
|
|
summary: summary.trim(),
|
|
rules,
|
|
findings: findings.map(({ category, finding, detail }) => ({
|
|
category,
|
|
finding,
|
|
detail: detail.trim()
|
|
})),
|
|
idempotencyKey: crypto.randomUUID()
|
|
};
|
|
}
|
|
busy = true;
|
|
error = '';
|
|
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 the assessment.');
|
|
}
|
|
open = false;
|
|
onsubmitted();
|
|
} catch (cause) {
|
|
if (pending) uncertain = true;
|
|
error = cause instanceof Error ? cause.message : 'Could not save the assessment.';
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Dialog
|
|
bind:open
|
|
title={assessment ? 'Edit case assessment' : 'Assess case'}
|
|
description="Record the current evidence assessment. Each saved revision is preserved in the case history."
|
|
{busy}
|
|
wide
|
|
>
|
|
<form onsubmit={submit}>
|
|
<fieldset disabled={busy || uncertain}>
|
|
<Field label="Current summary" required
|
|
><Textarea bind:value={summary} required rows={3} maxlength={2000} /></Field
|
|
>
|
|
<Field
|
|
label="Applicable rules"
|
|
hint="One rule or policy reference per line. Up to 20 lines, 160 characters each."
|
|
><Textarea bind:value={rulesText} rows={3} maxlength={3220} /></Field
|
|
>
|
|
<div class="findings">
|
|
{#each findings as item, index (item.key)}
|
|
<section class="finding" aria-label={`Finding ${index + 1}`}>
|
|
<div class="finding-heading">
|
|
<h3>Finding {index + 1}</h3>
|
|
<Button
|
|
size="sm"
|
|
disabled={findings.length === 1}
|
|
ariaLabel={`Remove finding ${index + 1}`}
|
|
onclick={() =>
|
|
(findings = findings.filter(
|
|
(finding) => finding.key !== item.key
|
|
))}>Remove</Button
|
|
>
|
|
</div>
|
|
<div class="paired">
|
|
<Field label={`Concern ${index + 1}`} required
|
|
><Select
|
|
bind:value={item.category}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Select a concern' },
|
|
...moderationConcernCategories.map((category) => ({
|
|
value: category,
|
|
label: moderationConcernLabels[category],
|
|
disabled: findings.some(
|
|
(other) =>
|
|
other.key !== item.key &&
|
|
other.category === category
|
|
)
|
|
}))
|
|
]}
|
|
/></Field
|
|
>
|
|
<Field label={`Finding ${index + 1}`} required
|
|
><Select
|
|
bind:value={item.finding}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Select a finding' },
|
|
...moderationFindingOutcomes.map((finding) => ({
|
|
value: finding,
|
|
label: moderationFindingLabels[finding]
|
|
}))
|
|
]}
|
|
/></Field
|
|
>
|
|
</div>
|
|
<Field
|
|
label={`Explanation ${index + 1}`}
|
|
required
|
|
hint="Explain how the evidence supports this finding, including uncertainty."
|
|
><Textarea
|
|
bind:value={item.detail}
|
|
required
|
|
rows={3}
|
|
maxlength={1000}
|
|
/></Field
|
|
>
|
|
</section>
|
|
{/each}
|
|
<Button
|
|
size="sm"
|
|
disabled={findings.length >= moderationConcernCategories.length}
|
|
onclick={() =>
|
|
(findings = [
|
|
...findings,
|
|
{ category: '', finding: '', detail: '', key: nextKey++ }
|
|
])}>Add finding</Button
|
|
>
|
|
</div>
|
|
</fieldset>
|
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
|
{#if uncertain}<p class="hint">
|
|
The result is uncertain. Retry sends the same assessment safely.
|
|
</p>{/if}
|
|
<div class="actions">
|
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
|
type="submit"
|
|
variant="primary"
|
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save assessment'}</Button
|
|
>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<style>
|
|
form,
|
|
fieldset,
|
|
.findings,
|
|
.finding {
|
|
display: grid;
|
|
gap: var(--space-4);
|
|
min-width: 0;
|
|
}
|
|
fieldset {
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
.finding {
|
|
padding: var(--space-4);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.finding-heading,
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-3);
|
|
}
|
|
h3 {
|
|
margin: 0;
|
|
font-size: var(--text-base);
|
|
}
|
|
.paired {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: var(--space-3);
|
|
}
|
|
.actions {
|
|
justify-content: flex-end;
|
|
}
|
|
.hint {
|
|
font-size: var(--text-sm);
|
|
line-height: var(--leading-body);
|
|
color: var(--text-secondary);
|
|
}
|
|
</style>
|