forked from yvv/decision
v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
(enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
inertie et formule. Le reste d'une version (coquilles, traductions,
signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
pour tout le document, jamais pour un article à plusieurs versions ; un seul
geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
// A version in the panel, tab « Détails »: its text and what changed, then
|
||||
// what is not the vote — typos, translations, flags of contradiction, history.
|
||||
// The vote itself unfolds under the article (VoteFold).
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { shown, type DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { titleOf } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import { announce } from '$lib/vote';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
||||
import DiffText from './DiffText.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
proposalId,
|
||||
lang,
|
||||
onvote,
|
||||
onpropose,
|
||||
}: {
|
||||
doc: DocState;
|
||||
proposalId: string;
|
||||
lang: Lang;
|
||||
/** Unfolds the vote of this version under its article. */
|
||||
onvote: (proposalId: string) => void;
|
||||
onpropose: (prefill: { title: string; body: string }) => void;
|
||||
} = $props();
|
||||
|
||||
interface Detail {
|
||||
proposal: Proposal & { author: string | null; authorInvited: boolean };
|
||||
unit: { id: string; code: string; inertia: Inertia };
|
||||
inForce: { id: string; title: string; body: string } | null;
|
||||
diff: DiffPart[] | null;
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
signals: {
|
||||
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
||||
other: { id: string; title: string; status: string; code: string | null } | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
let busy = $state(false);
|
||||
let showDiff = $state(false);
|
||||
let error = $state('');
|
||||
let typoOpen = $state(false);
|
||||
let typoTitle = $state('');
|
||||
let typoBody = $state('');
|
||||
let typoSubstantive = $state(false);
|
||||
let trTitle = $state('');
|
||||
let trBody = $state('');
|
||||
let signalTarget = $state('');
|
||||
let signalReason = $state('');
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
||||
const p = $derived(detail?.proposal);
|
||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||
const text = $derived(p ? shown(p, lang, source) : null);
|
||||
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
|
||||
const signalTargets = $derived(
|
||||
(view?.units ?? []).flatMap((u) =>
|
||||
[u.inForce, ...u.proposals]
|
||||
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
|
||||
.filter((x) => !(u.id === unit?.id))
|
||||
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
||||
),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
detail = await api.get<Detail>(`/proposals/${proposalId}`);
|
||||
error = '';
|
||||
const d = detail;
|
||||
typoTitle = d.proposal.title;
|
||||
typoBody = d.proposal.body;
|
||||
typoSubstantive = false;
|
||||
const tr = d.proposal.translations[lang];
|
||||
trTitle = tr?.title ?? d.proposal.title;
|
||||
trBody = tr?.body ?? d.proposal.body;
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void proposalId;
|
||||
void lang;
|
||||
void load();
|
||||
});
|
||||
|
||||
function message(e: unknown): string {
|
||||
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await doc.reload();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function saveTypo(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
typoSubstantive = false;
|
||||
try {
|
||||
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
|
||||
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
||||
typoOpen = false;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.code === 'substantive_in_force' || err.code === 'substantive_not_author')) {
|
||||
typoSubstantive = true;
|
||||
} else {
|
||||
error = message(err);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTranslation(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
||||
toasts.show(t('translate.saved'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSignal(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
|
||||
signalTarget = '';
|
||||
signalReason = '';
|
||||
toasts.show(t('signal.sent'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function lift(id: string) {
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
||||
await refresh();
|
||||
announce(doc, r.entered);
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw() {
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/proposals/${proposalId}/withdraw`);
|
||||
toasts.show(t('propose.withdrawn'));
|
||||
await doc.reload();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if detail && p && text && view}
|
||||
<article class="details">
|
||||
<header>
|
||||
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
|
||||
<p class="meta faint">
|
||||
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
||||
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if p.kind === 'text'}
|
||||
{#if detail.diff && showDiff}
|
||||
<DiffText parts={detail.diff} />
|
||||
{:else}
|
||||
<Prose text={text.body} />
|
||||
{/if}
|
||||
{#if detail.diff}
|
||||
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
|
||||
{/if}
|
||||
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
||||
{:else if p.kind === 'remove' && detail.inForce}
|
||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||
{/if}
|
||||
|
||||
{#if votable}
|
||||
<p class="tally faint">
|
||||
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
||||
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||
<section class="sub">
|
||||
<details bind:open={typoOpen}>
|
||||
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
||||
<form class="form" onsubmit={saveTypo}>
|
||||
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
|
||||
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
{#if typoSubstantive}
|
||||
<p class="warn">{t('typo.substantive')}</p>
|
||||
<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>
|
||||
{/if}
|
||||
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if lang !== source && p.kind === 'text'}
|
||||
<section class="sub">
|
||||
<details>
|
||||
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
|
||||
<form class="form" onsubmit={saveTranslation}>
|
||||
<p class="faint small">{t('translate.note')}</p>
|
||||
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.history.length > 1}
|
||||
<section class="sub">
|
||||
<h4>{t('history.title')}</h4>
|
||||
<ol class="history">
|
||||
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
||||
<li>
|
||||
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
|
||||
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if p.kind === 'text' && votable}
|
||||
<section class="sub">
|
||||
<h4>{t('signal.title')}</h4>
|
||||
{#each detail.signals as s (s.id)}
|
||||
<div class="signal" class:lifted={s.liftedAt}>
|
||||
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
|
||||
<p class="faint small">
|
||||
{t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
|
||||
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
|
||||
</p>
|
||||
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="faint small">{t('signal.none')}</p>
|
||||
{/each}
|
||||
<details>
|
||||
<summary>{t('signal.add')}</summary>
|
||||
<form class="form" onsubmit={sendSignal}>
|
||||
<p class="faint small">{t('signal.note')}</p>
|
||||
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
|
||||
<option value="" disabled>{t('signal.target')}</option>
|
||||
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
||||
</select>
|
||||
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if p.mine && p.status === 'open'}
|
||||
<button class="btn quiet small" type="button" disabled={busy} onclick={withdraw}>{t('propose.withdraw')}</button>
|
||||
{/if}
|
||||
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</article>
|
||||
{:else if error}
|
||||
<p class="warn" role="alert">{error}</p>
|
||||
{:else}
|
||||
<p class="faint">{t('common.loading')}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.details {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.note,
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.removed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tally {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sub {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
details > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.form .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.textarea.short {
|
||||
min-height: 5rem;
|
||||
}
|
||||
.history {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.signal {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--ochre-wash);
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.signal.lifted {
|
||||
background: var(--surface-2);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.signal .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user