forked from yvv/decision
v3 : interface Svelte — accueil, groupes, lecteur du texte en vigueur, panneau contextuel, éditeur à assistant, trustinWallet
- 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>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user