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,306 @@
|
||||
<script lang="ts">
|
||||
// The basket of a vote (Yvv, 2026-10-30: « Je peux voter tout d'un coup, ou une
|
||||
// section entière, ou un article avec son titre, ou séparément »): choices
|
||||
// article by article, « tout pour » / « tout contre » for a section or the whole
|
||||
// document — never for an article whose versions compete —, then one gesture.
|
||||
import { onMount } from 'svelte';
|
||||
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import type { BasketState } from '$lib/picks.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Proposal } from '$lib/types';
|
||||
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
import ChoicePicker from './ChoicePicker.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
basket,
|
||||
section = null,
|
||||
onsign,
|
||||
onread,
|
||||
}: {
|
||||
doc: DocState;
|
||||
basket: BasketState;
|
||||
/** A section to bring into sight (« Voter cette section »). */
|
||||
section?: string | null;
|
||||
onsign: (lines: Line[]) => void;
|
||||
/** Unfolds the vote of a version under its article. */
|
||||
onread: (proposalId: string) => void;
|
||||
} = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const access = $derived(view?.vote);
|
||||
const all = $derived(sections(view?.units ?? []));
|
||||
const slots = $derived(all.flatMap((s) => s.slots));
|
||||
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
|
||||
const toSend = $derived(lines(basket.picks, known));
|
||||
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
|
||||
const left = $derived(waiting(slots, basket.picks));
|
||||
const open = $derived(!!access?.open);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let list: HTMLElement | undefined = $state();
|
||||
|
||||
onMount(() => {
|
||||
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
|
||||
});
|
||||
|
||||
function headline(s: Slot): string {
|
||||
const text = s.versions.find((p) => p.kind === 'text');
|
||||
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
|
||||
}
|
||||
|
||||
/** What tells a version from the others of its article. */
|
||||
function labelOf(p: Proposal, s: Slot): string {
|
||||
if (p.status === 'in_force') return t('basket.inforce');
|
||||
if (p.kind !== 'text') return titleOf(p, s.unit);
|
||||
if (p.label) return t('deep.variant', { label: p.label });
|
||||
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
||||
return relationOf(p, s.unit) || t('panel.proposal');
|
||||
}
|
||||
|
||||
const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? []));
|
||||
|
||||
async function send() {
|
||||
error = '';
|
||||
if (!toSend.length) return;
|
||||
if (access?.way === 'chain') {
|
||||
onsign(toSend);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const n = await voteDirect(doc, toSend);
|
||||
basket.clear();
|
||||
toasts.show(i18n.plural('basket.sent', n));
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet bulk(key: string | null, label: string)}
|
||||
<div class="bulk" role="group" aria-label={label}>
|
||||
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button>
|
||||
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button>
|
||||
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section class="basket" aria-labelledby="basket-title">
|
||||
<h3 id="basket-title">{t('basket.title')}</h3>
|
||||
<p class="muted">{t('basket.intro')}</p>
|
||||
{#if access && !access.open}
|
||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="whole">
|
||||
<span class="eyebrow">{t('basket.doc')}</span>
|
||||
{@render bulk('*', t('basket.doc'))}
|
||||
</div>
|
||||
{#if left.length}
|
||||
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
|
||||
{/if}
|
||||
|
||||
<div class="sections" bind:this={list}>
|
||||
{#each all as s, i (s.key ?? `none-${i}`)}
|
||||
{@const label = sectionLabel(s.key)}
|
||||
<section class="sec" data-section={s.key ?? ''} class:target={!!section && section === s.key} aria-label={label ?? t('basket.doc')}>
|
||||
{#if label}
|
||||
<header>
|
||||
<h4 class="eyebrow">{label}</h4>
|
||||
{@render bulk(s.key, label)}
|
||||
</header>
|
||||
{/if}
|
||||
<ul>
|
||||
{#each s.slots as slot (slot.unit.id)}
|
||||
{@const title = headline(slot)}
|
||||
{@const several = slot.versions.length > 1}
|
||||
{@const first = slot.versions[0] ?? slot.inertia[0]}
|
||||
<li class="slot" class:several class:waiting={left.includes(slot)}>
|
||||
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
|
||||
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
|
||||
{#each [...slot.versions, ...slot.inertia] as v (v.id)}
|
||||
<div class="version">
|
||||
<span class="about">
|
||||
<span class="label">{labelOf(v, slot)}</span>
|
||||
<span class="faint small">
|
||||
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
|
||||
{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}
|
||||
</span>
|
||||
</span>
|
||||
<ChoicePicker
|
||||
value={basket.picks[v.id]}
|
||||
voted={!!v.myVote}
|
||||
label={t('basket.pick', { title: several || slot.inertia.includes(v) ? `${title} — ${labelOf(v, slot)}` : title })}
|
||||
disabled={!open}
|
||||
onpick={(c) => basket.pick(v.id, c)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<footer class="send">
|
||||
<p class="count">
|
||||
{#if tally.total}{i18n.plural('basket.count', tally.total)}{:else}<span class="faint">{t('basket.empty')}</span>{/if}
|
||||
</p>
|
||||
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
|
||||
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
|
||||
</button>
|
||||
<p class="faint small note-send">
|
||||
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
|
||||
</p>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.basket {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.closed {
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.whole,
|
||||
.sec header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem 0.8rem;
|
||||
}
|
||||
.whole {
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.bulk {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--water);
|
||||
}
|
||||
.sections {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.sec {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
scroll-margin-top: 0.5rem;
|
||||
border-radius: var(--r-input);
|
||||
}
|
||||
.sec.target {
|
||||
box-shadow: 0 0 0 2px var(--water-wash);
|
||||
background: var(--water-wash);
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
}
|
||||
.sec h4 {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.slot {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.slot.several {
|
||||
background: color-mix(in srgb, var(--surface-2) 70%, var(--water-wash));
|
||||
}
|
||||
.slot.waiting {
|
||||
box-shadow: inset 3px 0 0 var(--water);
|
||||
}
|
||||
.title {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: 780;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
}
|
||||
.title:hover {
|
||||
color: var(--water);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.versions-note {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.version {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem 0.7rem;
|
||||
}
|
||||
.about {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.send {
|
||||
position: sticky;
|
||||
bottom: -1.6rem;
|
||||
margin: 0 -1.2rem -1.6rem;
|
||||
padding: 0.8rem 1.2rem 1.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.8rem;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 -10px 24px -16px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.count {
|
||||
font-weight: 800;
|
||||
}
|
||||
.note-send,
|
||||
.send .warn {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user