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:
@@ -1,47 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ChainSign from '$lib/components/ChainSign.svelte';
|
||||
import DeepList from '$lib/components/DeepList.svelte';
|
||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
import { sections } from '$lib/basket';
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { BasketState } from '$lib/picks.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
|
||||
const doc = new DocState();
|
||||
const basket = new BasketState();
|
||||
const group = $derived(page.params.group ?? '');
|
||||
const slug = $derived(page.params.doc ?? '');
|
||||
$effect(() => {
|
||||
void doc.load(group, slug);
|
||||
});
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
||||
$effect(() => {
|
||||
const v = doc.view;
|
||||
if (!v) return;
|
||||
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
basket.load(v.doc.id, known);
|
||||
});
|
||||
|
||||
// The panel lives in the URL: a link (or a feedback report) reopens it.
|
||||
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
|
||||
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
let prefill = $state<PanelState['prefill']>(null);
|
||||
const panel = $derived.by((): PanelState | null => {
|
||||
const q = page.url.searchParams;
|
||||
const tab = TABS[q.get('onglet') ?? ''];
|
||||
if (!tab) return null;
|
||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), prefill };
|
||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
|
||||
});
|
||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||
const vote = $derived.by(() => {
|
||||
const q = page.url.searchParams;
|
||||
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
|
||||
});
|
||||
|
||||
function openPanel(next: PanelState | null) {
|
||||
prefill = next?.prefill ?? null;
|
||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||
prefill = nextPanel?.prefill ?? null;
|
||||
const q = new URLSearchParams();
|
||||
if (next) {
|
||||
q.set('onglet', TAB_NAMES[next.tab]);
|
||||
if (next.unitId) q.set('article', next.unitId);
|
||||
if (next.proposalId) q.set('version', next.proposalId);
|
||||
if (next.draftId) q.set('brouillon', next.draftId);
|
||||
if (nextVote) q.set('vote', nextVote);
|
||||
if (nextPanel) {
|
||||
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
||||
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
||||
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
||||
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
||||
if (nextPanel.section) q.set('section', nextPanel.section);
|
||||
}
|
||||
const search = q.toString();
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
||||
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
}
|
||||
const openPanel = (next: PanelState | null) => go(next, vote);
|
||||
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
|
||||
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
|
||||
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
|
||||
|
||||
// On the Ğ1 chain: one signing at a time, over the page.
|
||||
let signing = $state<Line[] | null>(null);
|
||||
const sign = (lines: Line[]) => (signing = lines);
|
||||
// While a ballot waits for the chain, the page looks again every ten seconds.
|
||||
const waitingChain = $derived(
|
||||
(doc.view?.units ?? []).some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!waitingChain) return;
|
||||
const every = setInterval(() => void doc.reload(), 10_000);
|
||||
return () => clearInterval(every);
|
||||
});
|
||||
|
||||
let filter = $state<Filter>('all');
|
||||
const view = $derived(doc.view);
|
||||
@@ -54,14 +95,37 @@
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
function onUnit(u: Unit, tab: Tab) {
|
||||
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
|
||||
}
|
||||
function onProposal(u: Unit, p: Proposal) {
|
||||
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
|
||||
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
||||
if (action === 'vote') {
|
||||
if (version) toggleVote(version);
|
||||
return;
|
||||
}
|
||||
openPanel({ unitId: u.id, proposalId: null, tab: action });
|
||||
}
|
||||
// The text shows the vote of its own versions; the list below, that of the others.
|
||||
const textIds = $derived(
|
||||
new Set(
|
||||
doc.mode === 'inforce' || !doc.preview
|
||||
? doc.inForce.map((u) => u.inForce?.id)
|
||||
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
|
||||
),
|
||||
);
|
||||
const inText = $derived(!!vote && textIds.has(vote));
|
||||
const canVote = $derived(!!view?.vote.open);
|
||||
</script>
|
||||
|
||||
{#snippet foldOf(unit: Unit, p: Proposal)}
|
||||
<VoteFold
|
||||
{doc}
|
||||
proposal={p}
|
||||
{unit}
|
||||
onopen={(id) => go(panel, id)}
|
||||
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
||||
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
||||
onsign={sign}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
||||
|
||||
{#if doc.error}
|
||||
@@ -81,6 +145,11 @@
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
{#if canVote}
|
||||
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
|
||||
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
|
||||
{#each VIEWS as v (v)}
|
||||
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
|
||||
@@ -102,14 +171,19 @@
|
||||
{#if doc.mode === 'inforce' || !doc.preview}
|
||||
{#each doc.inForce as u (u.id)}
|
||||
{#if u.inForce}
|
||||
{@const p = u.inForce}
|
||||
<UnitBlock
|
||||
text={shown(u.inForce, lang, source)}
|
||||
seedMinimal={u.inForce.seedMinimal}
|
||||
enteredAt={u.inForce.enteredAt}
|
||||
text={shown(p, lang, source)}
|
||||
seedMinimal={p.seedMinimal}
|
||||
enteredAt={p.enteredAt}
|
||||
rivals={rivalsOf(u)}
|
||||
selected={panel?.unitId === u.id}
|
||||
onaction={(tab) => onUnit(u, tab)}
|
||||
/>
|
||||
voteFor={p.id}
|
||||
voteOpen={inText && vote === p.id}
|
||||
onaction={(action) => onUnit(u, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">{t('doc.empty')}</p>
|
||||
@@ -117,12 +191,18 @@
|
||||
{:else}
|
||||
{#each doc.preview as pu (pu.unitId)}
|
||||
{@const u = doc.unit(pu.unitId)}
|
||||
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
||||
{@const p = doc.proposal(id)}
|
||||
<UnitBlock
|
||||
text={shown(pu, lang, source)}
|
||||
mark={pu.mark}
|
||||
selected={panel?.unitId === pu.unitId}
|
||||
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
|
||||
/>
|
||||
voteFor={id}
|
||||
voteOpen={inText && !!id && vote === id}
|
||||
onaction={(action) => u && onUnit(u, action, id)}
|
||||
>
|
||||
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
|
||||
</UnitBlock>
|
||||
{/each}
|
||||
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
||||
{/if}
|
||||
@@ -152,17 +232,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<DeepList
|
||||
articles={deep}
|
||||
vote={inText ? null : vote}
|
||||
ontoggle={(_, p) => toggleVote(p.id)}
|
||||
fold={foldOf}
|
||||
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
||||
/>
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if panel}
|
||||
<DocPanel {doc} {panel} {lang} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
|
||||
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
|
||||
{/if}
|
||||
{#if signing && view.vote.chain}
|
||||
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
|
||||
|
||||
<style>
|
||||
.reader {
|
||||
@@ -217,6 +306,13 @@
|
||||
justify-content: space-between;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
.basket-btn {
|
||||
flex-basis: 100%;
|
||||
justify-self: start;
|
||||
max-width: max-content;
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user