forked from yvv/decision
polkadot-api lève InvalidTxError (JSON indenté) quand le nœud refuse la transaction : elle est désormais lue comme « la chaîne a dit non » (rien n'est parti, le bulletin est lâché), au lieu de rester en attente 24 h. La page /bulletins ne présente comme vérifiés que la version, la révision et le choix ; l'article, le titre et le bloc du reçu sont dits hors du sceau. ADR 0008 : trois affirmations corrigées (indexeur sans filtre, débit de la tâche de fond, lectures qui n'utilisent pas encore shown_tally). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
6.6 KiB
Svelte
217 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
|
// note promises everyone can check their own vote with its salt: the receipt is
|
|
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
|
// it is checked here, in the browser, without asking the server anything.
|
|
import { onMount } from 'svelte';
|
|
import { api } from '$lib/api';
|
|
import Guard from '$lib/components/Guard.svelte';
|
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
|
import { Remote } from '$lib/load.svelte';
|
|
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
|
import { session } from '$lib/session.svelte';
|
|
import type { BallotEntry, LangMap } from '$lib/types';
|
|
|
|
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
|
const MAX_FILE = 1_000_000;
|
|
|
|
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
|
onMount(() => {
|
|
if (session.me) void data.load();
|
|
});
|
|
|
|
let pasted = $state('');
|
|
let check = $state<ReceiptCheck | null>(null);
|
|
|
|
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
|
|
|
function standing(b: BallotEntry): string {
|
|
if (b.status === 'pending') return t('ballots.status.pending');
|
|
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
|
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
|
return t('ballots.status.expired');
|
|
}
|
|
|
|
async function fromFile(e: Event) {
|
|
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
pasted = '';
|
|
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
|
}
|
|
|
|
function fromText(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
check = checkReceipt(pasted);
|
|
}
|
|
</script>
|
|
|
|
<div class="page">
|
|
<h1>{t('ballots.title')}</h1>
|
|
<p class="lead">{t('ballots.intro')}</p>
|
|
|
|
<section aria-labelledby="ballots-mine">
|
|
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
|
{#if !session.me}
|
|
<p class="muted">
|
|
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
|
</p>
|
|
{:else if data.error}
|
|
<Guard code={data.error} onretry={() => data.load()} />
|
|
{:else if data.value}
|
|
<ul class="list">
|
|
{#each data.value.ballots as b (b.id)}
|
|
<li class="card block ballot">
|
|
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
|
<p class="state {b.status}">
|
|
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
|
</p>
|
|
<code class="comment">{b.comment}</code>
|
|
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
|
</li>
|
|
{:else}
|
|
<li class="faint">{t('ballots.none')}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
|
|
<section aria-labelledby="ballots-verify">
|
|
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
|
<div class="card block">
|
|
<p class="muted">{t('ballots.verify.intro')}</p>
|
|
<label class="field">
|
|
<span class="label">{t('ballots.verify.file')}</span>
|
|
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
|
</label>
|
|
<form class="paste" onsubmit={fromText}>
|
|
<label class="field">
|
|
<span class="label">{t('ballots.verify.paste')}</span>
|
|
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
|
</label>
|
|
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
|
</form>
|
|
<div class="result" aria-live="polite">
|
|
{#if check?.kind === 'ok'}
|
|
<p class="ok">{t('ballots.verify.ok')}</p>
|
|
<code class="comment">{check.comment}</code>
|
|
<ul class="lines">
|
|
{#each check.lines as l (l.proposalId)}
|
|
<li>
|
|
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
|
|
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<p class="faint small">
|
|
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
|
</p>
|
|
{:else if check?.kind === 'mismatch'}
|
|
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
|
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
|
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
|
{:else if check?.kind === 'unreadable'}
|
|
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<style>
|
|
.page {
|
|
max-width: 46rem;
|
|
margin: 0 auto;
|
|
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
|
|
display: grid;
|
|
gap: 1.4rem;
|
|
}
|
|
h1 {
|
|
font-family: var(--font-text);
|
|
font-weight: 500;
|
|
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
|
|
}
|
|
.lead {
|
|
color: var(--ink-soft);
|
|
}
|
|
section {
|
|
display: grid;
|
|
gap: 0.7rem;
|
|
}
|
|
.list,
|
|
.lines {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
}
|
|
.block {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
padding: 1rem 1.1rem;
|
|
}
|
|
.ballot {
|
|
justify-items: start;
|
|
}
|
|
.state {
|
|
font-weight: 750;
|
|
}
|
|
.state.confirmed {
|
|
color: var(--moss);
|
|
}
|
|
.state.pending {
|
|
color: var(--water);
|
|
}
|
|
.state.refused,
|
|
.state.expired {
|
|
color: var(--ochre);
|
|
}
|
|
.comment {
|
|
font-size: 0.8rem;
|
|
word-break: break-all;
|
|
}
|
|
.field {
|
|
display: grid;
|
|
gap: 0.3rem;
|
|
}
|
|
.label {
|
|
font-weight: 800;
|
|
font-size: 0.88rem;
|
|
}
|
|
textarea {
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 0.8rem;
|
|
}
|
|
.paste {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
justify-items: start;
|
|
}
|
|
.paste .field,
|
|
.paste textarea {
|
|
width: 100%;
|
|
}
|
|
.result {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
.lines {
|
|
gap: 0.25rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.unsealed {
|
|
display: block;
|
|
}
|
|
.ok {
|
|
font-weight: 800;
|
|
color: var(--moss);
|
|
}
|
|
.warn {
|
|
color: var(--ochre);
|
|
font-weight: 700;
|
|
}
|
|
.small {
|
|
font-size: 0.84rem;
|
|
}
|
|
</style>
|