forked from yvv/decision
- Accueil sobre, menu complet à gauche (Décisions, Docs de référence, Mandats, Protocoles), intro demandée, quatre sections, connexion Ğ1. - Lecteur : texte en vigueur en Literata, actions contextuelles par article (le vote, propositions, proposer), propositions en cours sous la « surface » avec leur niveau d'eau, aperçus « si les bien cotées passent » / « si tout passe », langue du texte fr/en/es (traductions éditoriales). - Panneau : vote pour/contre, seuil et formule (MathML), inertie et son changement (voté en inertie +), coquille sans vote, traduction, signalement de contradiction, historique des corrections. - trustinWallet (/acces) : groupes et membres, invitations à usage unique, administrateurs, retours d'usage. - Contrôles : vitest (markdown, catalogues, anti-lexique), Playwright C1–C10 + M1. - Sections du seed portées jusqu'à l'écran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
8.0 KiB
Svelte
266 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { api, ApiError } from '$lib/api';
|
|
import type { DocState } from '$lib/doc.svelte';
|
|
import { t, type Key } from '$lib/i18n/index.svelte';
|
|
import { toasts } from '$lib/toast.svelte';
|
|
|
|
type Mode = 'replace' | 'remove' | 'new';
|
|
type Hint =
|
|
| { kind: 'split'; parts: string[] }
|
|
| { kind: 'similar'; proposalId: string; unitId: string | null; title: string | null; inForce: boolean; score: number }
|
|
| { kind: 'long'; chars: number };
|
|
|
|
let {
|
|
doc,
|
|
unitId,
|
|
draftId = null,
|
|
prefill = null,
|
|
ondone,
|
|
}: {
|
|
doc: DocState;
|
|
unitId: string | null;
|
|
draftId?: string | null;
|
|
prefill?: { title: string; body: string } | null;
|
|
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
|
|
} = $props();
|
|
|
|
const draft = $derived(doc.proposal(draftId));
|
|
let target = $state<string | null>(null);
|
|
let mode = $state<Mode>('replace');
|
|
let after = $state<string>('');
|
|
let title = $state('');
|
|
let body = $state('');
|
|
let hints = $state<Hint[]>([]);
|
|
let busy = $state(false);
|
|
let error = $state('');
|
|
let savedId = $state<string | null>(null);
|
|
|
|
const unit = $derived(doc.unit(target));
|
|
const units = $derived(doc.view?.units ?? []);
|
|
|
|
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
|
// which would wipe what the person is typing.
|
|
$effect(() => {
|
|
const [u0, d0, pf] = [unitId, draftId, prefill];
|
|
untrack(() => {
|
|
const u = doc.unit(u0);
|
|
const d = doc.proposal(d0);
|
|
target = u0;
|
|
savedId = d0;
|
|
mode = u0 ? 'replace' : 'new';
|
|
after = doc.inForce.at(-1)?.id ?? '';
|
|
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
|
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
|
|
error = '';
|
|
});
|
|
});
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
$effect(() => {
|
|
const text = `${title}\n${body}`;
|
|
clearTimeout(timer);
|
|
if (mode === 'remove' || body.trim().length < 20 || !doc.view) {
|
|
hints = [];
|
|
return;
|
|
}
|
|
const id = doc.view.doc.id;
|
|
timer = setTimeout(async () => {
|
|
try {
|
|
const r = await api.post<{ hints: Hint[] }>(`/docs/${id}/assist`, { text, proposalId: savedId });
|
|
hints = r.hints.filter((h) => h.kind !== 'similar' || h.unitId !== target);
|
|
} catch {
|
|
hints = [];
|
|
}
|
|
}, 600);
|
|
});
|
|
|
|
function message(e: unknown): string {
|
|
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
|
}
|
|
|
|
/** Create the draft, or update it; returns its id. */
|
|
async function save(): Promise<string> {
|
|
if (!doc.view) throw new ApiError(0, 'internal');
|
|
if (savedId) {
|
|
await api.patch(`/proposals/${savedId}`, { title, body });
|
|
return savedId;
|
|
}
|
|
const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, {
|
|
kind: mode === 'remove' ? 'remove' : 'text',
|
|
unitId: mode === 'new' ? null : target,
|
|
afterUnitId: mode === 'new' ? after || null : null,
|
|
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
|
body: mode === 'remove' ? '' : body,
|
|
submit: false,
|
|
});
|
|
savedId = r.id;
|
|
return r.id;
|
|
}
|
|
|
|
async function keep() {
|
|
busy = true;
|
|
try {
|
|
const id = await save();
|
|
await doc.reload();
|
|
toasts.show(t('propose.drafted'));
|
|
ondone({ proposalId: id, submitted: false });
|
|
} catch (e) {
|
|
error = message(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
busy = true;
|
|
try {
|
|
const id = await save();
|
|
await api.post(`/proposals/${id}/submit`);
|
|
await doc.reload();
|
|
toasts.show(t('propose.submitted'));
|
|
ondone({ proposalId: id, submitted: true });
|
|
} catch (e) {
|
|
error = message(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function split(parts: string[]) {
|
|
busy = true;
|
|
try {
|
|
const id = await save();
|
|
await api.post(`/proposals/${id}/split`, { parts });
|
|
await doc.reload();
|
|
toasts.show(t('assist.split.done', { n: parts.length }));
|
|
ondone({ proposalId: null, submitted: false });
|
|
} catch (e) {
|
|
error = message(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function variantOf(h: Extract<Hint, { kind: 'similar' }>) {
|
|
if (!h.unitId || savedId) return;
|
|
target = h.unitId;
|
|
mode = 'replace';
|
|
}
|
|
|
|
const canSubmit = $derived(mode === 'remove' || (title.trim().length > 0 && body.trim().length > 0));
|
|
</script>
|
|
|
|
<div class="propose">
|
|
{#if !draft}
|
|
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
|
|
{#if unit}
|
|
<label class:on={mode === 'replace'}><input type="radio" bind:group={mode} value="replace" />{t('propose.mode.replace')}</label>
|
|
{#if unit.inForce}<label class:on={mode === 'remove'}><input type="radio" bind:group={mode} value="remove" />{t('propose.mode.remove')}</label>{/if}
|
|
{/if}
|
|
<label class:on={mode === 'new'}><input type="radio" bind:group={mode} value="new" />{t('propose.mode.new')}</label>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if mode === 'new'}
|
|
<label class="field">
|
|
<span>{t('propose.after')}</span>
|
|
<select class="select" bind:value={after}>
|
|
{#each units.filter((u) => u.inForce) as u (u.id)}<option value={u.id}>{u.inForce?.title}</option>{/each}
|
|
<option value="">{t('propose.after.end')}</option>
|
|
</select>
|
|
</label>
|
|
{/if}
|
|
|
|
{#if mode === 'remove'}
|
|
<p class="muted">{t('propose.remove.note')}</p>
|
|
{:else}
|
|
<label class="field"><span>{t('propose.title')}</span><input class="input" bind:value={title} maxlength="120" /></label>
|
|
<label class="field"><span>{t('propose.body')}</span><textarea class="textarea" bind:value={body} rows="9" maxlength="4000"></textarea></label>
|
|
|
|
<section class="assist" aria-live="polite">
|
|
<h4 class="eyebrow">{t('assist.title')}</h4>
|
|
{#each hints as h, i (i)}
|
|
{#if h.kind === 'split'}
|
|
<p>{t('assist.split', { n: h.parts.length })}</p>
|
|
<button class="btn small" type="button" disabled={busy} onclick={() => split(h.parts)}>{t('assist.split.do', { n: h.parts.length })}</button>
|
|
{:else if h.kind === 'similar'}
|
|
<p>{t('assist.similar', { title: h.title ?? '', where: h.inForce ? t('assist.similar.inForce') : '' })}</p>
|
|
{#if h.unitId && !savedId}<button class="btn small" type="button" onclick={() => variantOf(h)}>{t('assist.similar.variant')}</button>{/if}
|
|
{:else if h.kind === 'long'}
|
|
<p>{t('assist.long', { n: h.chars })}</p>
|
|
{/if}
|
|
{:else}
|
|
<p class="faint">{t('assist.quiet')}</p>
|
|
{/each}
|
|
</section>
|
|
{/if}
|
|
|
|
<p class="faint small">{t('propose.rule')}</p>
|
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
|
<div class="actions">
|
|
<button class="btn" type="button" disabled={busy || !canSubmit} onclick={keep}>{t('propose.draft')}</button>
|
|
<button class="btn primary" type="button" disabled={busy || !canSubmit} onclick={submit}>{t('propose.submit')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.propose {
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
.modes {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
.modes label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 2.25rem;
|
|
padding: 0.3rem 0.9rem;
|
|
border-radius: var(--r-pill);
|
|
background: var(--surface-2);
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.modes label.on {
|
|
background: var(--water);
|
|
color: var(--on-water);
|
|
}
|
|
.modes input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.modes label:has(input:focus-visible) {
|
|
outline: 2px solid var(--water);
|
|
outline-offset: 2px;
|
|
}
|
|
.assist {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
padding: 0.9rem 1rem;
|
|
border-radius: var(--r-card);
|
|
background: var(--water-wash);
|
|
font-size: 0.92rem;
|
|
}
|
|
.assist .btn {
|
|
justify-self: start;
|
|
}
|
|
.small {
|
|
font-size: 0.84rem;
|
|
}
|
|
.warn {
|
|
color: var(--ochre);
|
|
font-weight: 700;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
</style>
|