forked from yvv/decision
Fusion du lot B : le vote déplié sous l'article, le panier, le vote par transaction Ğ1 (bulletin scellé)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// 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 »). One chooses
|
||||
// article by article; « tout pour » or « tout contre » fills a section or the
|
||||
// whole document — but never an article whose versions compete: there, one
|
||||
// chooses one's version. Then one gesture sends it all: one write on the
|
||||
// server, or one transaction on the Ğ1 chain.
|
||||
|
||||
import type { Choice, Proposal, Unit } from './types';
|
||||
|
||||
/** A choice in the basket: for, against, or withdrawing one's vote. Absent: nothing. */
|
||||
export type Picks = Record<string, Choice>;
|
||||
|
||||
/** An article as the basket shows it. */
|
||||
export interface Slot {
|
||||
unit: Unit;
|
||||
/** The versions in competition: the text in force, the open texts and removals. */
|
||||
versions: Proposal[];
|
||||
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
||||
inertia: Proposal[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
/** The section of the seed (introduction, fondamental…), or null. */
|
||||
key: string | null;
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
/** The articles of the document with something to vote, by section, in document order. */
|
||||
export function sections(units: Unit[]): Section[] {
|
||||
const out: Section[] = [];
|
||||
for (const unit of units) {
|
||||
const open = unit.proposals.filter((p) => p.status === 'open');
|
||||
const versions = [
|
||||
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
||||
...open.filter((p) => p.kind !== 'inertia'),
|
||||
];
|
||||
const inertia = open.filter((p) => p.kind === 'inertia');
|
||||
if (!versions.length && !inertia.length) continue;
|
||||
const last = out.at(-1);
|
||||
const slot = { unit, versions, inertia };
|
||||
if (last && last.key === unit.section) last.slots.push(slot);
|
||||
else out.push({ key: unit.section, slots: [slot] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */
|
||||
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
|
||||
const next = { ...picks };
|
||||
for (const s of slots) {
|
||||
const [only] = s.versions;
|
||||
if (only && s.versions.length === 1) next[only.id] = choice;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Articles of several versions where no version is chosen yet: the interface shows them. */
|
||||
export function waiting(slots: Slot[], picks: Picks): Slot[] {
|
||||
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
|
||||
}
|
||||
|
||||
/** Empties the basket of these articles only. */
|
||||
export function clearScope(slots: Slot[], picks: Picks): Picks {
|
||||
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)));
|
||||
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
|
||||
}
|
||||
|
||||
/** What is sent: one line per version still in the document; withdrawing is a null choice. */
|
||||
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] {
|
||||
return Object.entries(picks)
|
||||
.filter(([id]) => known.has(id))
|
||||
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
|
||||
}
|
||||
|
||||
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
|
||||
const all = Object.values(picks);
|
||||
return {
|
||||
for: all.filter((c) => c === 'for').length,
|
||||
against: all.filter((c) => c === 'against').length,
|
||||
none: all.filter((c) => c === 'none').length,
|
||||
total: all.length,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,421 @@
|
||||
<script lang="ts">
|
||||
// A vote on the Ğ1 chain, in one gesture (Yvv, 2026-10-30): the server seals
|
||||
// the ballot, the person's wallet signs one transaction — the amount to the
|
||||
// vote account and the seal of the ballot —, the page waits for the chain to
|
||||
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import type { Ballot, ChainSettings } from '$lib/types';
|
||||
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
|
||||
import { geckoLink, qrPath } from '$lib/wallet/gecko';
|
||||
import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
|
||||
|
||||
let {
|
||||
doc,
|
||||
chain,
|
||||
lines,
|
||||
onsent,
|
||||
onclose,
|
||||
}: {
|
||||
doc: DocState;
|
||||
chain: ChainSettings;
|
||||
lines: Line[];
|
||||
/** The ballot is on its way (or confirmed): the basket may be emptied. */
|
||||
onsent: () => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
type Step =
|
||||
| { kind: 'start' }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'wallets'; list: Wallet[] }
|
||||
| { kind: 'accounts'; wallet: Wallet; list: WalletAccount[] }
|
||||
| { kind: 'opening' }
|
||||
| { kind: 'signing' }
|
||||
| { kind: 'waiting' }
|
||||
| { kind: 'gecko'; link: string; qr: { size: number; d: string } }
|
||||
| { kind: 'confirmed'; voter: string | null }
|
||||
| { kind: 'refused'; reason: Ballot['reason'] }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'failed'; text: string };
|
||||
|
||||
const REMEMBER = 'ld:wallet';
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let step = $state<Step>({ kind: 'start' });
|
||||
let ballot = $state<Ballot | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let closed = false;
|
||||
|
||||
const amount = $derived(g1Amount(chain.amount));
|
||||
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
|
||||
/** Ğecko is offered where nothing is being signed nor waited for. */
|
||||
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
|
||||
|
||||
onMount(() => {
|
||||
dialog?.showModal();
|
||||
void start();
|
||||
});
|
||||
onDestroy(stop);
|
||||
|
||||
function stop() {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
stop();
|
||||
dialog?.close();
|
||||
onclose();
|
||||
}
|
||||
|
||||
function remembered(): { id: string; address: string } | null {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(REMEMBER) ?? 'null');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(wallet: Wallet | null, account?: WalletAccount) {
|
||||
try {
|
||||
if (wallet && account) localStorage.setItem(REMEMBER, JSON.stringify({ id: wallet.id, address: account.address }));
|
||||
else localStorage.removeItem(REMEMBER);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
stop();
|
||||
ballot = null;
|
||||
const list = wallets(chain);
|
||||
if (!list.length) {
|
||||
step = { kind: 'none' };
|
||||
return;
|
||||
}
|
||||
const known = remembered();
|
||||
const usual = list.find((w) => w.id === known?.id);
|
||||
if (usual) return choose(usual, known?.address);
|
||||
const [only] = list;
|
||||
if (only && list.length === 1) return choose(only);
|
||||
step = { kind: 'wallets', list };
|
||||
}
|
||||
|
||||
async function choose(wallet: Wallet, address?: string) {
|
||||
step = { kind: 'opening' };
|
||||
let accounts: WalletAccount[];
|
||||
try {
|
||||
accounts = await wallet.accounts();
|
||||
} catch (e) {
|
||||
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
|
||||
return;
|
||||
}
|
||||
const usual = accounts.find((a) => a.address === address);
|
||||
if (usual) return go(wallet, usual);
|
||||
const [only] = accounts;
|
||||
if (only && accounts.length === 1) return go(wallet, only);
|
||||
if (!only) {
|
||||
step = { kind: 'failed', text: t('sign.error.account') };
|
||||
return;
|
||||
}
|
||||
step = { kind: 'accounts', wallet, list: accounts };
|
||||
}
|
||||
|
||||
async function seal(author: string | null): Promise<Ballot | null> {
|
||||
try {
|
||||
const r = await api.post<{ ballot: Ballot }>(`/docs/${doc.view?.doc.id}/ballots`, { lines, author });
|
||||
return r.ballot;
|
||||
} catch (e) {
|
||||
step = { kind: 'failed', text: errorText(e) };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function go(wallet: Wallet, account: WalletAccount) {
|
||||
remember(wallet, account);
|
||||
step = { kind: 'opening' };
|
||||
const b = await seal(account.address);
|
||||
if (!b) return;
|
||||
ballot = b;
|
||||
let sent = false;
|
||||
try {
|
||||
step = { kind: 'signing' };
|
||||
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
|
||||
if (s === 'signing') step = { kind: 'signing' };
|
||||
else if (!sent) {
|
||||
sent = true;
|
||||
onsent();
|
||||
wait(b);
|
||||
}
|
||||
});
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
onsent();
|
||||
wait(b);
|
||||
}
|
||||
} catch (e) {
|
||||
const f: Failure = failure(e);
|
||||
// Once on its way, the chain may still take it: keep waiting.
|
||||
if (sent && f === 'node') return;
|
||||
if (nothingSent(f)) {
|
||||
if (f === 'account') remember(null);
|
||||
await api.del(`/ballots/${b.id}`).catch(() => undefined);
|
||||
}
|
||||
stop();
|
||||
step = { kind: 'failed', text: t(`sign.error.${f}`) };
|
||||
}
|
||||
}
|
||||
|
||||
async function gecko() {
|
||||
step = { kind: 'opening' };
|
||||
const b = await seal(null);
|
||||
if (!b) return;
|
||||
ballot = b;
|
||||
const link = geckoLink(b.account, b.amount, b.comment);
|
||||
step = { kind: 'gecko', link, qr: await qrPath(link) };
|
||||
wait(b);
|
||||
}
|
||||
|
||||
function wait(b: Ballot) {
|
||||
if (step.kind !== 'gecko') step = { kind: 'waiting' };
|
||||
// Under the article, the vote now shows as waiting for the chain.
|
||||
void doc.reload();
|
||||
poll(b);
|
||||
}
|
||||
|
||||
function poll(b: Ballot) {
|
||||
stop();
|
||||
timer = setInterval(async () => {
|
||||
let now: Ballot;
|
||||
try {
|
||||
now = (await api.get<{ ballot: Ballot }>(`/ballots/${b.id}`)).ballot;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (now.status === 'pending' || closed) return;
|
||||
stop();
|
||||
ballot = now;
|
||||
await doc.reload();
|
||||
if (now.status === 'confirmed') {
|
||||
onsent();
|
||||
step = { kind: 'confirmed', voter: now.voter };
|
||||
announce(doc, now.entered);
|
||||
} else if (now.status === 'refused') {
|
||||
step = { kind: 'refused', reason: now.reason };
|
||||
} else {
|
||||
step = { kind: 'expired' };
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class="sign card" aria-labelledby="sign-title" onclose={() => !closed && close()}>
|
||||
<h2 id="sign-title">{t('sign.title')}</h2>
|
||||
<p class="summary">{i18n.plural('sign.summary', lines.length, { amount })}</p>
|
||||
|
||||
<div class="step" aria-live="polite">
|
||||
{#if step.kind === 'start' || step.kind === 'opening'}
|
||||
<p class="muted">{t('sign.opening')}</p>
|
||||
{:else if step.kind === 'none'}
|
||||
<p>{t('sign.none')}</p>
|
||||
{:else if step.kind === 'wallets'}
|
||||
<p class="label">{t('sign.wallet')}</p>
|
||||
<ul class="choices">
|
||||
{#each step.list as w (w.id)}
|
||||
<li><button class="btn" type="button" onclick={() => choose(w)}>{w.id}</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if step.kind === 'accounts'}
|
||||
{@const wallet = step.wallet}
|
||||
<p class="label">{t('sign.account')}</p>
|
||||
<ul class="choices">
|
||||
{#each step.list as a (a.address)}
|
||||
<li>
|
||||
<button class="btn account" type="button" onclick={() => go(wallet, a)}>
|
||||
{#if a.name}<span>{a.name}</span>{/if}<code>{short(a.address)}</code>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if step.kind === 'signing'}
|
||||
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.signing')}</p>
|
||||
{:else if step.kind === 'waiting'}
|
||||
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
|
||||
<p class="faint small">{t('sign.later')}</p>
|
||||
{:else if step.kind === 'gecko' && ballot}
|
||||
<p>{t('sign.gecko.scan')}</p>
|
||||
<div class="gecko">
|
||||
<svg class="qr" viewBox="0 0 {step.qr.size} {step.qr.size}" role="img" aria-label={t('sign.gecko.qr')} shape-rendering="crispEdges">
|
||||
<path d={step.qr.d} fill="currentColor" />
|
||||
</svg>
|
||||
<dl>
|
||||
<dt>{t('sign.gecko.amount')}</dt>
|
||||
<dd>{amount} Ğ1</dd>
|
||||
<dt>{t('sign.gecko.comment')}</dt>
|
||||
<dd><code class="comment">{ballot.comment}</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<a class="btn primary" href={step.link}>{t('sign.gecko.open')}</a>
|
||||
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
|
||||
<p class="faint small">{t('sign.later')}</p>
|
||||
{:else if step.kind === 'confirmed'}
|
||||
<p class="ok">{t('sign.confirmed')}</p>
|
||||
{#if step.voter}<p class="muted">{t('sign.confirmed.as', { name: step.voter })}</p>{/if}
|
||||
{:else if step.kind === 'refused'}
|
||||
<p class="warn" role="alert">{t('sign.refused', { reason: t(`ballot.reason.${step.reason ?? 'transfer'}`) })}</p>
|
||||
{:else if step.kind === 'expired'}
|
||||
<p class="warn" role="alert">{t('sign.expired')}</p>
|
||||
{:else if step.kind === 'failed'}
|
||||
<p class="warn" role="alert">{step.text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if geckoOffered}
|
||||
<button class="btn quiet" type="button" onclick={gecko}>{t('sign.gecko')}</button>
|
||||
{/if}
|
||||
{#if step.kind === 'failed' || step.kind === 'expired' || step.kind === 'refused'}
|
||||
<button class="btn" type="button" onclick={start}>{t('sign.retry')}</button>
|
||||
{/if}
|
||||
<button class="btn {step.kind === 'confirmed' ? 'primary' : 'quiet'}" type="button" onclick={close}>{t('sign.close')}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.sign {
|
||||
border: 0;
|
||||
padding: 1.5rem;
|
||||
width: min(94vw, 30rem);
|
||||
max-height: 92dvh;
|
||||
overflow-y: auto;
|
||||
color: var(--ink);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.sign:not([open]) {
|
||||
display: none;
|
||||
}
|
||||
.sign::backdrop {
|
||||
background: var(--veil);
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.summary {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.choices {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.choices .btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.account {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
code {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
.gecko {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 12rem) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
/* A code to scan reads dark on light, whatever the mood. */
|
||||
.qr {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--r-input);
|
||||
background: #fff;
|
||||
color: #111;
|
||||
}
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
dt {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
dd {
|
||||
margin: 0 0 0.5rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.comment {
|
||||
word-break: break-all;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
font-weight: 800;
|
||||
color: var(--moss);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.gecko {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
.qr {
|
||||
max-width: 13rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
// One choice of the basket: for, against, or withdraw one's vote (when there is
|
||||
// one). Pressing the chosen one again takes the version out of the basket.
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import type { Choice } from '$lib/types';
|
||||
|
||||
let {
|
||||
value,
|
||||
voted,
|
||||
label,
|
||||
disabled = false,
|
||||
onpick,
|
||||
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props();
|
||||
</script>
|
||||
|
||||
<span class="picker" role="group" aria-label={label}>
|
||||
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
|
||||
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
|
||||
{#if voted || value === 'none'}
|
||||
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
background: var(--surface-2);
|
||||
padding: 3px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 0.3rem 0.75rem;
|
||||
min-height: 2.25rem;
|
||||
min-width: 2.75rem;
|
||||
font-weight: 750;
|
||||
font-size: 0.84rem;
|
||||
color: var(--ink-soft);
|
||||
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
.for[aria-pressed='true'],
|
||||
.for[aria-pressed='true']:hover {
|
||||
background: var(--moss);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.against[aria-pressed='true'],
|
||||
.against[aria-pressed='true']:hover {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.none[aria-pressed='true'],
|
||||
.none[aria-pressed='true']:hover {
|
||||
background: var(--ink-soft);
|
||||
color: var(--surface);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
|
||||
// single version opens its vote; one with several shows the best of them and
|
||||
// unfolds the others, each with its label, its water level and its marks — and
|
||||
// each opens its own vote.
|
||||
// single version unfolds its vote below it; one with several shows the best of
|
||||
// them and unfolds the others, each with its label, its water level and its
|
||||
// marks — and each unfolds its own vote.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { plain } from '$lib/markdown';
|
||||
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
@@ -13,16 +14,26 @@
|
||||
|
||||
let {
|
||||
articles,
|
||||
selected,
|
||||
onopen,
|
||||
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
vote,
|
||||
ontoggle,
|
||||
fold,
|
||||
onsection,
|
||||
}: {
|
||||
articles: Article[];
|
||||
/** The version whose vote is unfolded. */
|
||||
vote: string | null;
|
||||
ontoggle: (unit: Unit, p: Proposal) => void;
|
||||
fold: Snippet<[Unit, Proposal]>;
|
||||
/** Opens the basket on a section; absent when this session does not vote here. */
|
||||
onsection?: (section: 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);
|
||||
|
||||
// Unfolded by hand, or because the version open in the panel is inside.
|
||||
// Unfolded by hand, or because the version whose vote is unfolded is inside.
|
||||
let unfolded = $state<Record<string, boolean>>({});
|
||||
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
|
||||
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
|
||||
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
|
||||
|
||||
/** The title of the article: its best text version, or what the best proposal is. */
|
||||
@@ -61,11 +72,17 @@
|
||||
{@const unit = a.unit}
|
||||
{@const best = a.versions[0]}
|
||||
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
|
||||
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
|
||||
<li class="section">
|
||||
<span class="eyebrow">{sectionLabel(unit.section)}</span>
|
||||
{#if onsection && unit.section}
|
||||
{@const key = unit.section}
|
||||
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{#if best && a.versions.length === 1}
|
||||
<li class="article">
|
||||
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
|
||||
<li class="article" class:open={vote === best.id}>
|
||||
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(best, unit)}</span>
|
||||
@@ -78,6 +95,7 @@
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
|
||||
</li>
|
||||
{:else if best}
|
||||
{@const open = isOpen(a)}
|
||||
@@ -105,7 +123,7 @@
|
||||
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
||||
{#each a.versions as p (p.id)}
|
||||
<li>
|
||||
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||
@@ -116,6 +134,7 @@
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if vote === p.id}{@render fold(unit, p)}{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -234,9 +253,30 @@
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem 1rem;
|
||||
margin: 1.6rem 0 0.3rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.vote-section {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
font-size: 0.84rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.vote-section:hover {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.fold {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
.section:first-child {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
<script lang="ts">
|
||||
// The side panel: the basket of the vote, the versions of an article, a new
|
||||
// proposal, the details of a version, the settings of the formula. The vote of
|
||||
// one version unfolds under its article, not here.
|
||||
import type { DocState, PanelState } from '$lib/doc.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import type { BasketState } from '$lib/picks.svelte';
|
||||
import type { Lang } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import BallotPanel from './BallotPanel.svelte';
|
||||
import FormulaSettings from './FormulaSettings.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import ProposeView from './ProposeView.svelte';
|
||||
import VoteView from './VoteView.svelte';
|
||||
import VersionDetails from './VersionDetails.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
panel,
|
||||
lang,
|
||||
basket,
|
||||
vote,
|
||||
onchange,
|
||||
onclose,
|
||||
}: { doc: DocState; panel: PanelState; lang: Lang; onchange: (p: PanelState) => void; onclose: () => void } = $props();
|
||||
onvote,
|
||||
onsign,
|
||||
}: {
|
||||
doc: DocState;
|
||||
panel: PanelState;
|
||||
lang: Lang;
|
||||
basket: BasketState;
|
||||
/** The version whose vote is unfolded under its article. */
|
||||
vote: string | null;
|
||||
onchange: (p: PanelState) => void;
|
||||
onclose: () => void;
|
||||
/** Unfolds the vote of a version under its article; `close` closes the panel. */
|
||||
onvote: (proposalId: string, close: boolean) => void;
|
||||
onsign: (lines: Line[]) => void;
|
||||
} = $props();
|
||||
|
||||
const unit = $derived(doc.unit(panel.unitId));
|
||||
const versions = $derived(
|
||||
@@ -25,9 +47,11 @@
|
||||
const tabs = $derived(
|
||||
(
|
||||
[
|
||||
['vote', t('panel.vote'), !!voteId],
|
||||
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
|
||||
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
|
||||
['propose', t('panel.propose'), true],
|
||||
// A version: reached from its vote, or from the list of versions.
|
||||
['details', t('panel.details'), !!panel.proposalId],
|
||||
// Reached from the head of the document or from a vote; shown while it is open.
|
||||
['settings', t('panel.settings'), panel.tab === 'settings'],
|
||||
] as const
|
||||
@@ -49,7 +73,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="panel card" aria-label={t('panel.vote')}>
|
||||
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
|
||||
<div class="bar">
|
||||
<div class="tabs" role="tablist" bind:this={tablist}>
|
||||
{#each tabs as [key, label] (key)}
|
||||
@@ -60,25 +84,26 @@
|
||||
</div>
|
||||
|
||||
<div class="body" bind:this={body}>
|
||||
{#if panel.tab === 'vote' && voteId}
|
||||
<VoteView
|
||||
{#if panel.tab === 'basket'}
|
||||
<BallotPanel {doc} {basket} section={panel.section ?? null} {onsign} onread={(id) => onvote(id, false)} />
|
||||
{:else if panel.tab === 'details' && voteId}
|
||||
<VersionDetails
|
||||
{doc}
|
||||
proposalId={voteId}
|
||||
{lang}
|
||||
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
|
||||
onvote={(id) => onvote(id, true)}
|
||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
||||
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
|
||||
/>
|
||||
{:else if panel.tab === 'proposals' && unit}
|
||||
<ul class="versions">
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button type="button" class="version" class:current={v.id === voteId} onclick={() => onchange({ ...panel, proposalId: v.id, tab: 'vote' })}>
|
||||
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
|
||||
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
||||
<span class="text">
|
||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<span class="title">{titleOf(v, unit)}</span>
|
||||
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
|
||||
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {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>
|
||||
</button>
|
||||
</li>
|
||||
@@ -91,7 +116,10 @@
|
||||
unitId={panel.unitId}
|
||||
draftId={panel.draftId ?? null}
|
||||
prefill={panel.prefill ?? null}
|
||||
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
|
||||
ondone={(r) =>
|
||||
r.proposalId && r.submitted
|
||||
? onvote(r.proposalId, true)
|
||||
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
|
||||
/>
|
||||
{:else if panel.tab === 'settings'}
|
||||
<FormulaSettings {doc} proposalId={voteId} />
|
||||
|
||||
@@ -31,6 +31,17 @@
|
||||
/** A tool is highlighted only when one is in it. */
|
||||
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
|
||||
|
||||
// A radio group: one stop for Tab, the arrows move to the next mood and choose it.
|
||||
function onMoodKey(e: KeyboardEvent) {
|
||||
const step = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
const next = MOODS[(MOODS.indexOf(mood.current) + step + MOODS.length) % MOODS.length];
|
||||
if (!next) return;
|
||||
mood.set(next);
|
||||
(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-mood="${next}"]`)?.focus();
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await session.logout();
|
||||
groups.reset();
|
||||
@@ -85,7 +96,7 @@
|
||||
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
|
||||
<div class="moods" role="radiogroup" aria-label={t('nav.mood')} tabindex="-1" onkeydown={onMoodKey}>
|
||||
{#each MOODS as m (m)}
|
||||
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
|
||||
<button
|
||||
@@ -96,6 +107,7 @@
|
||||
aria-checked={mood.current === m}
|
||||
aria-label={t(`mood.${m}`)}
|
||||
title={t(`mood.${m}`)}
|
||||
tabindex={mood.current === m ? 0 : -1}
|
||||
onclick={() => mood.set(m)}
|
||||
></button>
|
||||
{/each}
|
||||
@@ -242,7 +254,15 @@
|
||||
.moods {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.6rem;
|
||||
padding-inline: 0.2rem;
|
||||
/* Taken here, in the mood of the page, before each swatch carries its own:
|
||||
the rings read against the page whatever the colour of the swatch. */
|
||||
--page-ink: var(--ink);
|
||||
--page-ring: var(--water);
|
||||
}
|
||||
.moods:focus {
|
||||
outline: none;
|
||||
}
|
||||
.swatch {
|
||||
position: relative;
|
||||
@@ -262,10 +282,18 @@
|
||||
border-radius: 50%;
|
||||
background: var(--cle);
|
||||
}
|
||||
/* Chosen: a ring of the page's ink, close to the swatch. */
|
||||
.swatch[aria-checked='true'] {
|
||||
/* currentColor is the ink of the page, inherited. */
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent),
|
||||
0 0 0 2px var(--page-ink);
|
||||
}
|
||||
/* Focused: further out, in the page's accent (3:1 or more on the page in every
|
||||
mood), and dashed: never mistaken for the chosen one. */
|
||||
.swatch:focus-visible {
|
||||
outline: 2px dashed var(--page-ring);
|
||||
outline-offset: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.who {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Shown } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
@@ -11,6 +12,9 @@
|
||||
rivals = 0,
|
||||
selected = false,
|
||||
actions = true,
|
||||
voteFor = null,
|
||||
voteOpen = false,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
text: Shown;
|
||||
@@ -20,11 +24,16 @@
|
||||
rivals?: number;
|
||||
selected?: boolean;
|
||||
actions?: boolean;
|
||||
onaction: (tab: 'vote' | 'proposals' | 'propose') => void;
|
||||
/** The version « Le vote » unfolds, and whether it is unfolded. */
|
||||
voteFor?: string | null;
|
||||
voteOpen?: boolean;
|
||||
/** The vote, unfolded under the text. */
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="unit {mark}" class:selected>
|
||||
<article class="unit {mark}" class:selected class:unfolded={voteOpen}>
|
||||
<header>
|
||||
<h2 class="eyebrow">{text.title}</h2>
|
||||
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
|
||||
@@ -38,12 +47,13 @@
|
||||
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||
</span>
|
||||
<span class="links">
|
||||
<button type="button" onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
{#if voteOpen && fold}{@render fold()}{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
@@ -59,7 +69,8 @@
|
||||
}
|
||||
.unit:hover,
|
||||
.unit:focus-within,
|
||||
.unit.selected {
|
||||
.unit.selected,
|
||||
.unit.unfolded {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -98,7 +109,8 @@
|
||||
}
|
||||
.unit:hover .links,
|
||||
.unit:focus-within .links,
|
||||
.unit.selected .links {
|
||||
.unit.selected .links,
|
||||
.unit.unfolded .links {
|
||||
opacity: 1;
|
||||
}
|
||||
.links button {
|
||||
@@ -110,7 +122,8 @@
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links button:hover {
|
||||
.links button:hover,
|
||||
.links button.on {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
@media (hover: none) {
|
||||
|
||||
+28
-110
@@ -1,30 +1,30 @@
|
||||
<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 { inertiaBadge, titleOf } from '$lib/labels';
|
||||
import { titleOf } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types';
|
||||
import { announce } from '$lib/vote';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
||||
import DiffText from './DiffText.svelte';
|
||||
import Formula from './Formula.svelte';
|
||||
import InertiaPicker from './InertiaPicker.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
proposalId,
|
||||
lang,
|
||||
onopen,
|
||||
onvote,
|
||||
onpropose,
|
||||
onsettings,
|
||||
}: {
|
||||
doc: DocState;
|
||||
proposalId: string;
|
||||
lang: Lang;
|
||||
onopen: (proposalId: string) => void;
|
||||
/** Unfolds the vote of this version under its article. */
|
||||
onvote: (proposalId: string) => void;
|
||||
onpropose: (prefill: { title: string; body: string }) => void;
|
||||
onsettings?: () => void;
|
||||
} = $props();
|
||||
|
||||
interface Detail {
|
||||
@@ -37,9 +37,6 @@
|
||||
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;
|
||||
}[];
|
||||
electorate: number;
|
||||
voteFloor: number;
|
||||
levels: LevelInfo[];
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
@@ -101,29 +98,6 @@
|
||||
await load();
|
||||
}
|
||||
|
||||
function announce(entered: Entered[]) {
|
||||
for (const e of entered) {
|
||||
const u = doc.unit(e.unitId);
|
||||
const title = u?.inForce?.title ?? u?.code ?? '';
|
||||
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
|
||||
toasts.show(t(key, { title }), 'rise', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
async function vote(choice: 'for' | 'against' | null) {
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
|
||||
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
||||
await refresh();
|
||||
announce(r.entered);
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTypo(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
@@ -179,7 +153,7 @@
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
||||
await refresh();
|
||||
announce(r.entered);
|
||||
announce(doc, r.entered);
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
@@ -202,7 +176,7 @@
|
||||
</script>
|
||||
|
||||
{#if detail && p && text && view}
|
||||
<article class="vote">
|
||||
<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>
|
||||
@@ -228,39 +202,12 @@
|
||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||
{/if}
|
||||
|
||||
<section class="box">
|
||||
<div class="level">
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
||||
<div class="numbers">
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
||||
{#if p.evaluation}
|
||||
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||
{/if}
|
||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||
|
||||
{#if votable && !view?.canVote}
|
||||
<p class="muted">{t('vote.wotOnly')}</p>
|
||||
{:else if votable}
|
||||
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
|
||||
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
||||
<span class="label">{t('vote.yours')}</span>
|
||||
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
|
||||
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
|
||||
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
|
||||
</div>
|
||||
<p class="faint small">{t('vote.private')}</p>
|
||||
{:else}
|
||||
<p class="muted">{t('vote.closed')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/if}
|
||||
|
||||
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} levels={detail.levels} floor={detail.voteFloor} base={view.doc.formula.base} {onsettings} />
|
||||
{#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">
|
||||
@@ -351,7 +298,7 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.vote {
|
||||
.details {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
@@ -382,48 +329,19 @@
|
||||
.removed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.box {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.level {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.numbers {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.tally {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state {
|
||||
font-weight: 700;
|
||||
}
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
.btn.on {
|
||||
background: var(--moss);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.btn.on-against {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
// The vote, unfolded under the article (Yvv, 2026-10-30: « il faut voir son
|
||||
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
|
||||
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
|
||||
// never who voted what —, how far the threshold is, your vote and where it
|
||||
// stands, then the inertia and the formula. The rest of a version (typos,
|
||||
// translations, flags, history) lives in the panel, tab « Détails ».
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, titleOf } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { LevelInfo, Proposal, Unit } from '$lib/types';
|
||||
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
import Formula from './Formula.svelte';
|
||||
import InertiaPicker from './InertiaPicker.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
proposal: p,
|
||||
unit,
|
||||
onopen,
|
||||
onsettings,
|
||||
ondetails,
|
||||
onsign,
|
||||
}: {
|
||||
doc: DocState;
|
||||
proposal: Proposal;
|
||||
unit: Unit;
|
||||
/** Unfolds the vote of another version (a change of inertia). */
|
||||
onopen: (proposalId: string) => void;
|
||||
onsettings: (proposalId: string) => void;
|
||||
ondetails: (proposalId: string) => void;
|
||||
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
|
||||
onsign: (lines: Line[]) => void;
|
||||
} = $props();
|
||||
|
||||
interface Detail {
|
||||
electorate: number;
|
||||
voteFloor: number;
|
||||
levels: LevelInfo[];
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let root: HTMLElement | undefined = $state();
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const access = $derived(view?.vote);
|
||||
const chain = $derived(access?.way === 'chain');
|
||||
const votable = $derived(p.status === 'open' || p.status === 'in_force');
|
||||
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
|
||||
const voted = $derived(p.myVote !== null && !p.myVoteStale);
|
||||
const ballot = $derived(p.myBallot);
|
||||
|
||||
$effect(() => {
|
||||
const id = p.id;
|
||||
void p.tally.for;
|
||||
void p.tally.against;
|
||||
api
|
||||
.get<Detail>(`/proposals/${id}`)
|
||||
.then((d) => {
|
||||
if (p.id === id) detail = d;
|
||||
})
|
||||
.catch(() => (detail = null));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Opened by a link: brought into sight, without a jump if it already is.
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
|
||||
async function cast(choice: 'for' | 'against' | null) {
|
||||
error = '';
|
||||
const lines: Line[] = [{ proposalId: p.id, choice }];
|
||||
if (chain) {
|
||||
onsign(lines);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await voteDirect(doc, lines);
|
||||
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="fold" id="vote-{p.id}" aria-label={t('vote.fold', { title })} bind:this={root}>
|
||||
<div class="level">
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
||||
<div class="numbers">
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
||||
{#if p.evaluation}
|
||||
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||
{/if}
|
||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||
|
||||
<div class="mine" aria-live="polite">
|
||||
{#if ballot?.status === 'pending'}
|
||||
<p class="pending"><span class="dot" aria-hidden="true"></span>{t('vote.mine.pending', { choice: t(`vote.choice.${ballot.choice}`) })}</p>
|
||||
{/if}
|
||||
{#if p.myVoteStale}
|
||||
<p class="warn">{t('vote.stale')}</p>
|
||||
{:else if p.myVote}
|
||||
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
|
||||
{:else if ballot?.status !== 'pending'}
|
||||
<p class="muted">{t('vote.mine.none')}</p>
|
||||
{/if}
|
||||
{#if ballot?.status === 'refused'}
|
||||
<p class="warn">{t('vote.mine.refused', { reason: t(`ballot.reason.${ballot.reason ?? 'transfer'}`) })}</p>
|
||||
{:else if ballot?.status === 'expired'}
|
||||
<p class="warn">{t('vote.mine.expired')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !votable}
|
||||
<p class="muted">{t('vote.closed')}</p>
|
||||
{:else if access && !access.open}
|
||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||
{:else if access}
|
||||
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
||||
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
|
||||
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
|
||||
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
|
||||
</div>
|
||||
{#if chain && access.chain}
|
||||
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
|
||||
<p class="faint small">{t('vote.chain.counts')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<p class="faint small">{t('vote.private')}</p>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="more">
|
||||
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
|
||||
{#if detail && view}
|
||||
<Formula
|
||||
electorate={detail.electorate}
|
||||
source={view.doc.electorateSource}
|
||||
tally={p.tally}
|
||||
evaluation={p.evaluation}
|
||||
inertia={p.votedAtInertia}
|
||||
levels={detail.levels}
|
||||
floor={detail.voteFloor}
|
||||
base={view.doc.formula.base}
|
||||
onsettings={() => onsettings(p.id)}
|
||||
/>
|
||||
{/if}
|
||||
<button class="linkish" type="button" onclick={() => ondetails(p.id)}>{t('vote.details')}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.fold {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.8rem;
|
||||
padding: 1.1rem 1.1rem 1.2rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--water-wash);
|
||||
animation: unfold 0.22s var(--ease);
|
||||
scroll-margin: 1.5rem;
|
||||
}
|
||||
.level {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.numbers {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.tally {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state {
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.mine {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.voted {
|
||||
font-weight: 800;
|
||||
}
|
||||
.voted.for {
|
||||
color: var(--moss);
|
||||
}
|
||||
.voted.against {
|
||||
color: var(--ochre);
|
||||
}
|
||||
.how {
|
||||
font-weight: 650;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.btn.for.on {
|
||||
background: var(--moss);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.btn.against.on {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.closed {
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.more {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.linkish {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.2rem 0;
|
||||
min-height: 2.25rem;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
@keyframes unfold {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fold,
|
||||
.dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,23 @@
|
||||
// State of the reader: the document, the view (in force / previews), the
|
||||
// language the text is read in, and the panel (which unit, which version,
|
||||
// which tab) — the panel lives in the URL so a link reopens it.
|
||||
// which tab) — the panel lives in the URL so a link reopens it, as does the
|
||||
// vote unfolded under an article (`?vote=`).
|
||||
|
||||
import { api, ApiError } from './api';
|
||||
import type { GroupView } from './group';
|
||||
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
||||
|
||||
export type View = 'inforce' | 'wellrated' | 'all';
|
||||
export type Tab = 'vote' | 'proposals' | 'propose' | 'settings';
|
||||
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
|
||||
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
|
||||
|
||||
export interface PanelState {
|
||||
unitId: string | null;
|
||||
proposalId: string | null;
|
||||
tab: Tab;
|
||||
draftId?: string | null;
|
||||
/** The basket, opened on one section. */
|
||||
section?: string | null;
|
||||
prefill?: { title: string; body: string } | null;
|
||||
}
|
||||
|
||||
|
||||
+83
-2
@@ -122,6 +122,8 @@ export const en: Messages = {
|
||||
'doc.action.vote': 'Voting',
|
||||
'doc.action.proposals': 'Proposals',
|
||||
'doc.action.propose': 'Propose a change',
|
||||
'doc.basket': 'Vote in bulk',
|
||||
'doc.basket.count': 'Vote in bulk · {n}',
|
||||
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
|
||||
'doc.published': 'Published to the text repository',
|
||||
'doc.publish.pending': 'Publication pending',
|
||||
@@ -176,6 +178,8 @@ export const en: Messages = {
|
||||
'panel.proposals': 'Proposals',
|
||||
'panel.propose': 'Propose',
|
||||
'panel.settings': 'Settings',
|
||||
'panel.basket': 'Basket',
|
||||
'panel.details': 'Details',
|
||||
'panel.close': 'Close panel',
|
||||
'panel.inforce': 'In force',
|
||||
'panel.proposal': 'Proposal',
|
||||
@@ -199,7 +203,76 @@ export const en: Messages = {
|
||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||
'vote.closed': 'This version is no longer open for voting.',
|
||||
'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.',
|
||||
'vote.fold': 'The vote on “{title}”',
|
||||
'vote.mine.none': 'You have not voted on this version.',
|
||||
'vote.mine.for': 'Your vote: for',
|
||||
'vote.mine.against': 'Your vote: against',
|
||||
'vote.mine.chain': 'recorded on the Ğ1 chain',
|
||||
'vote.mine.saved': 'saved',
|
||||
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
||||
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
||||
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
||||
'vote.choice.for': 'for',
|
||||
'vote.choice.against': 'against',
|
||||
'vote.choice.none': 'withdrawing your vote',
|
||||
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
|
||||
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
|
||||
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
|
||||
'vote.members': 'Only members of this group vote here.',
|
||||
'vote.details': 'Typos, translations, flags, history',
|
||||
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
||||
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
||||
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
||||
'basket.title': 'Voting basket',
|
||||
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
|
||||
'basket.doc': 'The whole document',
|
||||
'basket.for': 'All for',
|
||||
'basket.against': 'All against',
|
||||
'basket.clear': 'Empty',
|
||||
'basket.pick': 'Your choice for “{title}”',
|
||||
'basket.pick.retract': 'Withdraw',
|
||||
'basket.versions': 'Several versions: choose yours.',
|
||||
'basket.waiting.one': '{n} article has several versions: a grouped gesture does not fill it, choose your version.',
|
||||
'basket.waiting.other': '{n} articles have several versions: a grouped gesture does not fill them, choose your version.',
|
||||
'basket.inforce': 'in force',
|
||||
'basket.count.one': '{n} choice',
|
||||
'basket.count.other': '{n} choices',
|
||||
'basket.send.server': 'Vote ({n})',
|
||||
'basket.send.chain': 'Sign and send ({n})',
|
||||
'basket.note.server': 'Your votes count as soon as they are sent, all at once.',
|
||||
'basket.note.chain': 'A single transaction of {amount} Ğ1, signed by your wallet, for all your choices.',
|
||||
'basket.empty': 'Nothing in the basket yet.',
|
||||
'basket.sent.one': '{n} vote saved.',
|
||||
'basket.sent.other': '{n} votes saved.',
|
||||
'basket.section': 'Vote on this section',
|
||||
'basket.mine': 'your vote: {choice}',
|
||||
'sign.title': 'Vote with your Ğ1 wallet',
|
||||
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||
'sign.wallet': 'With which wallet?',
|
||||
'sign.account': 'With which account?',
|
||||
'sign.opening': 'Your wallet is opening…',
|
||||
'sign.signing': 'Confirm in your wallet.',
|
||||
'sign.waiting': 'The Ğ1 chain is confirming your vote, usually in less than a minute.',
|
||||
'sign.confirmed': 'Your vote counts: it is recorded on the Ğ1 chain.',
|
||||
'sign.confirmed.as': 'Counted for the Ğ1 identity “{name}”.',
|
||||
'sign.refused': 'Your vote was not counted: {reason}',
|
||||
'sign.expired': 'Your vote was not seen on the Ğ1 chain in time: it does not count. Start again whenever you like.',
|
||||
'sign.error.cancelled': 'You declined to sign: nothing was sent.',
|
||||
'sign.error.funds': 'Your wallet does not have enough Ğ1: at least 1 Ğ1 must remain after the vote.',
|
||||
'sign.error.node': 'The Ğ1 chain is not answering. Try again in a moment.',
|
||||
'sign.error.account': 'This account is no longer in your wallet.',
|
||||
'sign.error.wallet': 'Your wallet could not sign. Try again, or use Ğecko.',
|
||||
'sign.none': 'No wallet is connected to this browser (duniter-connect, for example). On your phone, Ğecko does the job.',
|
||||
'sign.gecko': 'Use Ğecko',
|
||||
'sign.gecko.scan': 'Scan this code with Ğecko, or tap the link on your phone. Check the amount and the comment, then confirm.',
|
||||
'sign.gecko.open': 'Open in Ğecko',
|
||||
'sign.gecko.amount': 'Amount',
|
||||
'sign.gecko.comment': 'Comment',
|
||||
'sign.gecko.qr': 'Code to scan with Ğecko',
|
||||
'sign.close': 'Close',
|
||||
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
|
||||
'sign.retry': 'Try again',
|
||||
|
||||
'inertia.title': 'Inertia',
|
||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||
@@ -395,13 +468,21 @@ export const en: Messages = {
|
||||
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
|
||||
'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.',
|
||||
'error.changed': 'The text has changed in the meantime: reload it, then try again.',
|
||||
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
|
||||
'error.label': 'The name is empty or too long (60 characters at most).',
|
||||
'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.',
|
||||
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
|
||||
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
|
||||
'error.groups': 'Choose at least one group.',
|
||||
'error.publication_off': 'Publication is not configured on this server.',
|
||||
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
|
||||
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
|
||||
'error.server_vote': 'Here, voting is direct, without a wallet.',
|
||||
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
|
||||
'error.lines': 'This vote no longer holds: reload the page, then start again.',
|
||||
'error.choice': 'This choice does not exist.',
|
||||
'error.author': 'This account is not a Ğ1 address.',
|
||||
'error.not_votable': 'This version is no longer open for voting.',
|
||||
'error.members_only': 'Only members of this group vote here.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
+83
-2
@@ -122,6 +122,8 @@ export const es: Messages = {
|
||||
'doc.action.vote': 'Votación',
|
||||
'doc.action.proposals': 'Propuestas',
|
||||
'doc.action.propose': 'Proponer una modificación',
|
||||
'doc.basket': 'Votar en bloque',
|
||||
'doc.basket.count': 'Votar en bloque · {n}',
|
||||
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
|
||||
'doc.published': 'Publicado en el repositorio de textos',
|
||||
'doc.publish.pending': 'Publicación pendiente',
|
||||
@@ -176,6 +178,8 @@ export const es: Messages = {
|
||||
'panel.proposals': 'Propuestas',
|
||||
'panel.propose': 'Proponer',
|
||||
'panel.settings': 'Ajustes',
|
||||
'panel.basket': 'Cesta',
|
||||
'panel.details': 'Detalles',
|
||||
'panel.close': 'Cerrar el panel',
|
||||
'panel.inforce': 'En vigor',
|
||||
'panel.proposal': 'Propuesta',
|
||||
@@ -199,7 +203,76 @@ export const es: Messages = {
|
||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||
'vote.closed': 'Esta versión ya no está en votación.',
|
||||
'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.',
|
||||
'vote.fold': 'La votación de «{title}»',
|
||||
'vote.mine.none': 'No has votado esta versión.',
|
||||
'vote.mine.for': 'Tu voto: a favor',
|
||||
'vote.mine.against': 'Tu voto: en contra',
|
||||
'vote.mine.chain': 'inscrito en la cadena Ğ1',
|
||||
'vote.mine.saved': 'registrado',
|
||||
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
||||
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
||||
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
||||
'vote.choice.for': 'a favor',
|
||||
'vote.choice.against': 'en contra',
|
||||
'vote.choice.none': 'retirar tu voto',
|
||||
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
|
||||
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
|
||||
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
|
||||
'vote.members': 'Aquí solo votan los miembros de este grupo.',
|
||||
'vote.details': 'Erratas, traducciones, señalamientos, historial',
|
||||
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
||||
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
||||
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
||||
'basket.title': 'Cesta de votación',
|
||||
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
|
||||
'basket.doc': 'Todo el documento',
|
||||
'basket.for': 'Todo a favor',
|
||||
'basket.against': 'Todo en contra',
|
||||
'basket.clear': 'Vaciar',
|
||||
'basket.pick': 'Tu elección para «{title}»',
|
||||
'basket.pick.retract': 'Retirar',
|
||||
'basket.versions': 'Varias versiones: elige la tuya.',
|
||||
'basket.waiting.one': '{n} artículo tiene varias versiones: un gesto agrupado no lo rellena, elige tu versión.',
|
||||
'basket.waiting.other': '{n} artículos tienen varias versiones: un gesto agrupado no los rellena, elige tu versión.',
|
||||
'basket.inforce': 'en vigor',
|
||||
'basket.count.one': '{n} elección',
|
||||
'basket.count.other': '{n} elecciones',
|
||||
'basket.send.server': 'Votar ({n})',
|
||||
'basket.send.chain': 'Firmar y enviar ({n})',
|
||||
'basket.note.server': 'Tus votos cuentan en cuanto se envían, de una sola vez.',
|
||||
'basket.note.chain': 'Una sola transacción de {amount} Ğ1, firmada por tu monedero, para todas tus elecciones.',
|
||||
'basket.empty': 'Nada en la cesta por ahora.',
|
||||
'basket.sent.one': '{n} voto registrado.',
|
||||
'basket.sent.other': '{n} votos registrados.',
|
||||
'basket.section': 'Votar esta sección',
|
||||
'basket.mine': 'tu voto: {choice}',
|
||||
'sign.title': 'Votar con tu monedero Ğ1',
|
||||
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||
'sign.wallet': '¿Con qué monedero?',
|
||||
'sign.account': '¿Con qué cuenta?',
|
||||
'sign.opening': 'Tu monedero se abre…',
|
||||
'sign.signing': 'Confirma en tu monedero.',
|
||||
'sign.waiting': 'La cadena Ğ1 confirma tu voto, en general en menos de un minuto.',
|
||||
'sign.confirmed': 'Tu voto cuenta: está inscrito en la cadena Ğ1.',
|
||||
'sign.confirmed.as': 'Contado para la identidad Ğ1 «{name}».',
|
||||
'sign.refused': 'Tu voto no se contó: {reason}',
|
||||
'sign.expired': 'Tu voto no se vio a tiempo en la cadena Ğ1: no cuenta. Vuelve a empezar cuando quieras.',
|
||||
'sign.error.cancelled': 'Rechazaste la firma: no se envió nada.',
|
||||
'sign.error.funds': 'Tu monedero no tiene suficientes Ğ1: debe quedarle al menos 1 Ğ1 después del voto.',
|
||||
'sign.error.node': 'La cadena Ğ1 no responde. Vuelve a intentarlo en un momento.',
|
||||
'sign.error.account': 'Esta cuenta ya no está en tu monedero.',
|
||||
'sign.error.wallet': 'Tu monedero no pudo firmar. Vuelve a intentarlo, o usa Ğecko.',
|
||||
'sign.none': 'Ningún monedero está conectado a este navegador (duniter-connect, por ejemplo). En tu teléfono, Ğecko sirve.',
|
||||
'sign.gecko': 'Usar Ğecko',
|
||||
'sign.gecko.scan': 'Escanea este código con Ğecko, o toca el enlace en tu teléfono. Comprueba el importe y el comentario, y luego valida.',
|
||||
'sign.gecko.open': 'Abrir en Ğecko',
|
||||
'sign.gecko.amount': 'Importe',
|
||||
'sign.gecko.comment': 'Comentario',
|
||||
'sign.gecko.qr': 'Código para escanear con Ğecko',
|
||||
'sign.close': 'Cerrar',
|
||||
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
|
||||
'sign.retry': 'Reintentar',
|
||||
|
||||
'inertia.title': 'Inercia',
|
||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
||||
@@ -395,13 +468,21 @@ export const es: Messages = {
|
||||
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
|
||||
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.',
|
||||
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
|
||||
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
|
||||
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
|
||||
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.',
|
||||
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
|
||||
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
|
||||
'error.groups': 'Elige al menos un grupo.',
|
||||
'error.publication_off': 'La publicación no está configurada en este servidor.',
|
||||
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
|
||||
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
|
||||
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
|
||||
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
|
||||
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
|
||||
'error.choice': 'Esta elección no existe.',
|
||||
'error.author': 'Esta cuenta no es una dirección Ğ1.',
|
||||
'error.not_votable': 'Esta versión ya no está en votación.',
|
||||
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
|
||||
+83
-2
@@ -125,6 +125,8 @@ export const fr = {
|
||||
'doc.action.vote': 'Le vote',
|
||||
'doc.action.proposals': 'Propositions',
|
||||
'doc.action.propose': 'Proposer une modification',
|
||||
'doc.basket': 'Voter en lot',
|
||||
'doc.basket.count': 'Voter en lot · {n}',
|
||||
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
|
||||
'doc.published': 'Publié sur le dépôt des textes',
|
||||
'doc.publish.pending': 'Publication en attente',
|
||||
@@ -179,6 +181,8 @@ export const fr = {
|
||||
'panel.proposals': 'Propositions',
|
||||
'panel.propose': 'Proposer',
|
||||
'panel.settings': 'Réglages',
|
||||
'panel.basket': 'Panier',
|
||||
'panel.details': 'Détails',
|
||||
'panel.close': 'Fermer le panneau',
|
||||
'panel.inforce': 'En vigueur',
|
||||
'panel.proposal': 'Proposition',
|
||||
@@ -202,7 +206,76 @@ export const fr = {
|
||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||
'vote.closed': 'Cette version ne se vote plus.',
|
||||
'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.',
|
||||
'vote.fold': 'Le vote de « {title} »',
|
||||
'vote.mine.none': "Tu n'as pas voté sur cette version.",
|
||||
'vote.mine.for': 'Ton vote : pour',
|
||||
'vote.mine.against': 'Ton vote : contre',
|
||||
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
|
||||
'vote.mine.saved': 'enregistré',
|
||||
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
||||
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
||||
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
||||
'vote.choice.for': 'pour',
|
||||
'vote.choice.against': 'contre',
|
||||
'vote.choice.none': 'retrait de ton vote',
|
||||
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
|
||||
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
|
||||
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
|
||||
'vote.members': 'Seuls les membres de ce groupe votent ici.',
|
||||
'vote.details': 'Coquilles, traductions, signalements, historique',
|
||||
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
||||
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
||||
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
||||
'basket.title': 'Panier de vote',
|
||||
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
|
||||
'basket.doc': 'Tout le document',
|
||||
'basket.for': 'Tout pour',
|
||||
'basket.against': 'Tout contre',
|
||||
'basket.clear': 'Vider',
|
||||
'basket.pick': 'Ton choix pour « {title} »',
|
||||
'basket.pick.retract': 'Retirer',
|
||||
'basket.versions': 'Plusieurs versions : choisis la tienne.',
|
||||
'basket.waiting.one': '{n} article a plusieurs versions : un geste groupé ne le remplit pas, choisis ta version.',
|
||||
'basket.waiting.other': '{n} articles ont plusieurs versions : un geste groupé ne les remplit pas, choisis ta version.',
|
||||
'basket.inforce': 'en vigueur',
|
||||
'basket.count.one': '{n} choix',
|
||||
'basket.count.other': '{n} choix',
|
||||
'basket.send.server': 'Voter ({n})',
|
||||
'basket.send.chain': 'Signer et envoyer ({n})',
|
||||
'basket.note.server': "Tes votes comptent dès l'envoi, en une seule fois.",
|
||||
'basket.note.chain': 'Une seule transaction de {amount} Ğ1, signée par ton portefeuille, pour tous tes choix.',
|
||||
'basket.empty': "Rien dans le panier pour l'instant.",
|
||||
'basket.sent.one': '{n} vote enregistré.',
|
||||
'basket.sent.other': '{n} votes enregistrés.',
|
||||
'basket.section': 'Voter cette section',
|
||||
'basket.mine': 'ton vote : {choice}',
|
||||
'sign.title': 'Voter avec ton portefeuille Ğ1',
|
||||
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||
'sign.wallet': 'Avec quel portefeuille ?',
|
||||
'sign.account': 'Avec quel compte ?',
|
||||
'sign.opening': "Ton portefeuille s'ouvre…",
|
||||
'sign.signing': 'Confirme dans ton portefeuille.',
|
||||
'sign.waiting': "La chaîne Ğ1 confirme ton vote, en général en moins d'une minute.",
|
||||
'sign.confirmed': 'Ton vote compte : il est inscrit sur la chaîne Ğ1.',
|
||||
'sign.confirmed.as': "Compté pour l'identité Ğ1 « {name} ».",
|
||||
'sign.refused': "Ton vote n'a pas été compté : {reason}",
|
||||
'sign.expired': "Ton vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas. Recommence quand tu veux.",
|
||||
'sign.error.cancelled': "Tu as refusé la signature : rien n'est parti.",
|
||||
'sign.error.funds': "Ton portefeuille n'a pas assez de Ğ1 : il doit lui rester au moins 1 Ğ1 après le vote.",
|
||||
'sign.error.node': 'La chaîne Ğ1 ne répond pas. Réessaie dans un moment.',
|
||||
'sign.error.account': "Ce compte n'est plus dans ton portefeuille.",
|
||||
'sign.error.wallet': "Ton portefeuille n'a pas pu signer. Réessaie, ou passe par Ğecko.",
|
||||
'sign.none': "Aucun portefeuille n'est branché à ce navigateur (duniter-connect, par exemple). Sur ton téléphone, Ğecko fait l'affaire.",
|
||||
'sign.gecko': 'Passer par Ğecko',
|
||||
'sign.gecko.scan': 'Scanne ce code avec Ğecko, ou touche le lien sur ton téléphone. Vérifie le montant et le commentaire, puis valide.',
|
||||
'sign.gecko.open': 'Ouvrir dans Ğecko',
|
||||
'sign.gecko.amount': 'Montant',
|
||||
'sign.gecko.comment': 'Commentaire',
|
||||
'sign.gecko.qr': 'Code à scanner avec Ğecko',
|
||||
'sign.close': 'Fermer',
|
||||
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
|
||||
'sign.retry': 'Réessayer',
|
||||
|
||||
'inertia.title': 'Inertie',
|
||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||
@@ -398,13 +471,21 @@ export const fr = {
|
||||
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
|
||||
'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.",
|
||||
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
|
||||
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
|
||||
'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
|
||||
'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.",
|
||||
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
|
||||
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
|
||||
'error.groups': 'Choisis au moins un groupe.',
|
||||
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
|
||||
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
|
||||
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
|
||||
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
|
||||
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
|
||||
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
|
||||
'error.choice': "Ce choix n'existe pas.",
|
||||
'error.author': "Ce compte n'est pas une adresse Ğ1.",
|
||||
'error.not_votable': 'Cette version ne se vote plus.',
|
||||
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The basket of the reader: the choices not sent yet, kept for the tab (a reload
|
||||
// or a look at another article does not lose them), one basket per document.
|
||||
|
||||
import type { Picks } from './basket';
|
||||
import type { Choice } from './types';
|
||||
|
||||
export class BasketState {
|
||||
picks = $state<Picks>({});
|
||||
#key = '';
|
||||
|
||||
/** Takes back the basket of this document, keeping only the versions still open to vote. */
|
||||
load(docId: string, known: Set<string>) {
|
||||
const key = `ld:basket:${docId}`;
|
||||
if (key === this.#key) {
|
||||
this.#keep(known);
|
||||
return;
|
||||
}
|
||||
this.#key = key;
|
||||
let saved: Picks = {};
|
||||
try {
|
||||
saved = JSON.parse(sessionStorage.getItem(key) ?? '{}') as Picks;
|
||||
} catch {
|
||||
saved = {};
|
||||
}
|
||||
this.picks = {};
|
||||
this.set(saved);
|
||||
this.#keep(known);
|
||||
}
|
||||
|
||||
#keep(known: Set<string>) {
|
||||
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id)));
|
||||
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept);
|
||||
}
|
||||
|
||||
set(picks: Picks) {
|
||||
const clean: Picks = {};
|
||||
for (const [id, c] of Object.entries(picks)) if (c === 'for' || c === 'against' || c === 'none') clean[id] = c;
|
||||
this.picks = clean;
|
||||
try {
|
||||
sessionStorage.setItem(this.#key, JSON.stringify(clean));
|
||||
} catch {
|
||||
/* private mode: the basket lives as long as the page */
|
||||
}
|
||||
}
|
||||
|
||||
/** Picks a choice; the same choice again takes it out of the basket. */
|
||||
pick(id: string, choice: Choice) {
|
||||
const next = { ...this.picks };
|
||||
if (next[id] === choice) delete next[id];
|
||||
else next[id] = choice;
|
||||
this.set(next);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set({});
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return Object.keys(this.picks).length;
|
||||
}
|
||||
}
|
||||
+52
-1
@@ -100,9 +100,59 @@ export interface Proposal {
|
||||
signals: number;
|
||||
myVote: 'for' | 'against' | null;
|
||||
myVoteStale: boolean;
|
||||
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
||||
myVoteChain: boolean;
|
||||
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
|
||||
myBallot: MyBallot | null;
|
||||
translations: Partial<Record<Lang, Translation>>;
|
||||
}
|
||||
|
||||
export type Choice = 'for' | 'against' | 'none';
|
||||
|
||||
export interface MyBallot {
|
||||
id: string;
|
||||
status: 'pending' | 'refused' | 'expired';
|
||||
reason: 'not_member' | 'no_identity' | 'transfer' | null;
|
||||
choice: Choice;
|
||||
}
|
||||
|
||||
/** The vote on the Ğ1 chain, as the server sets it (ADR 0008). */
|
||||
export interface ChainSettings {
|
||||
/** The vote account, as a Ğ1 address. */
|
||||
account: string;
|
||||
/** Cents of Ğ1 per vote. */
|
||||
amount: number;
|
||||
/** WebSocket endpoints of Ğ1 nodes. */
|
||||
nodes: string[];
|
||||
/** Tests only: the page sends to a fake node, without building a real transaction. */
|
||||
test: boolean;
|
||||
testNode: string | null;
|
||||
expiresHours: number;
|
||||
}
|
||||
|
||||
/** How this session votes in the group of the document, and whether it may now. */
|
||||
export interface VoteAccess {
|
||||
way: 'server' | 'chain';
|
||||
open: boolean;
|
||||
reason: 'members_only' | 'chain_off' | null;
|
||||
chain: ChainSettings | null;
|
||||
}
|
||||
|
||||
/** A ballot of the chain, as `GET /api/ballots/{id}` tells it to the session that asked. */
|
||||
export interface Ballot {
|
||||
id: string;
|
||||
status: 'pending' | 'confirmed' | 'refused' | 'expired';
|
||||
reason: MyBallot['reason'];
|
||||
comment: string;
|
||||
account: string;
|
||||
amount: number;
|
||||
author: string | null;
|
||||
block: number | null;
|
||||
voter: string | null;
|
||||
entered: Entered[];
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -124,8 +174,9 @@ export interface Publication {
|
||||
}
|
||||
|
||||
export interface DocView {
|
||||
/** trustinWallet: where the electorate is the web of trust, only its members vote. */
|
||||
/** This session may vote now (`vote.open`). */
|
||||
canVote: boolean;
|
||||
vote: VoteAccess;
|
||||
doc: {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// What the vote under an article and the basket share: one way to send votes
|
||||
// to the server (managed groups), and the news of what entered into force.
|
||||
|
||||
import { api, ApiError } from './api';
|
||||
import type { DocState } from './doc.svelte';
|
||||
import { i18n, t, type Key } from './i18n/index.svelte';
|
||||
import { toasts } from './toast.svelte';
|
||||
import type { Entered } from './types';
|
||||
|
||||
/** A line of a vote: one version, for or against, or null to withdraw one's vote. */
|
||||
export interface Line {
|
||||
proposalId: string;
|
||||
choice: 'for' | 'against' | null;
|
||||
}
|
||||
|
||||
/** Cents of Ğ1, as the person reads them: « 0,1 » in French. */
|
||||
export function g1Amount(cents: number): string {
|
||||
return i18n.number(cents / 100);
|
||||
}
|
||||
|
||||
export function errorText(e: unknown): string {
|
||||
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
||||
}
|
||||
|
||||
/** Tells, one by one, the texts that just entered into force. */
|
||||
export function announce(doc: DocState, entered: Entered[]) {
|
||||
for (const e of entered) {
|
||||
const u = doc.unit(e.unitId);
|
||||
const title = u?.inForce?.title ?? u?.code ?? '';
|
||||
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
|
||||
toasts.show(t(key, { title }), 'rise', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
/** In a managed group: every line in one write on the server. Returns how many were written. */
|
||||
export async function voteDirect(doc: DocState, lines: Line[]): Promise<number> {
|
||||
const id = doc.view?.doc.id;
|
||||
if (!id) return 0;
|
||||
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
|
||||
await doc.reload();
|
||||
announce(doc, r.entered);
|
||||
return r.count;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Ğecko, on a phone (or without a wallet in the browser): a link and a QR code
|
||||
// that open the transfer with its amount and its comment already written. Ğecko
|
||||
// does not hand back to the page: the page waits for the indexer to see it.
|
||||
|
||||
/** Cents of Ğ1 as Ğ1, with a dot: 10 → « 0.1 ». */
|
||||
export function amountText(cents: number): string {
|
||||
const whole = Math.trunc(cents / 100);
|
||||
const rest = cents % 100;
|
||||
if (!rest) return String(whole);
|
||||
return `${whole}.${String(rest).padStart(2, '0').replace(/0$/, '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `g1://<account>?amount=0.1&comment=ld1:<hex>` — the format Ğecko and Ğ1nkgo read.
|
||||
* The comment is plain ASCII (`ld1:` and hex digits): nothing to escape.
|
||||
*/
|
||||
export function geckoLink(account: string, cents: number, comment: string): string {
|
||||
return `g1://${account}?amount=${amountText(cents)}&comment=${comment}`;
|
||||
}
|
||||
|
||||
/** The dark modules of a QR code as one SVG path, `size` modules wide (border included). */
|
||||
export async function qrPath(text: string): Promise<{ size: number; d: string }> {
|
||||
const { encode } = await import('uqr');
|
||||
const qr = encode(text, { ecc: 'M', border: 2 });
|
||||
let d = '';
|
||||
qr.data.forEach((row, y) => {
|
||||
row.forEach((dark, x) => {
|
||||
if (dark) d += `M${x} ${y}h1v1h-1z`;
|
||||
});
|
||||
});
|
||||
return { size: qr.data.length, d };
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// The wallets of the page: every browser extension that offers the standard
|
||||
// interface of Substrate wallets (`window.injectedWeb3`, `signPayload`) —
|
||||
// duniter-connect today; Ğ1Companion and trustinWallet the day they offer it.
|
||||
// One adapter, one interface: the vote never knows which wallet signs.
|
||||
//
|
||||
// The transaction is one gesture (Yvv, 2026-10-30): `utility.batchAll` of the
|
||||
// transfer to the vote account and the remark that seals the ballot, built by
|
||||
// polkadot-api — loaded only now, at the moment of signing — signed by the
|
||||
// extension (its own confirmation window), sent to a Ğ1 node. The server then
|
||||
// finds it on the indexer.
|
||||
|
||||
import type { ChainSettings } from '../types';
|
||||
|
||||
export interface WalletAccount {
|
||||
address: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface VoteTransfer {
|
||||
/** The vote account. */
|
||||
dest: string;
|
||||
/** Cents of Ğ1. */
|
||||
amount: bigint;
|
||||
/** `ld1:` + the seal of the ballot. */
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
|
||||
export type SendStatus = 'signing' | 'sent' | 'included';
|
||||
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
|
||||
|
||||
export class WalletError extends Error {
|
||||
constructor(public code: Failure) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Wallet {
|
||||
/** The name the extension gives itself in `window.injectedWeb3`. */
|
||||
id: string;
|
||||
accounts(): Promise<WalletAccount[]>;
|
||||
send(account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void): Promise<void>;
|
||||
}
|
||||
|
||||
interface Enabled {
|
||||
accounts: { get: () => Promise<{ address: string; name?: string }[]> };
|
||||
signer: { signPayload?: (payload: Record<string, unknown>) => Promise<{ signature: string }> };
|
||||
}
|
||||
interface Injected {
|
||||
enable?: (dapp: string) => Promise<Enabled>;
|
||||
}
|
||||
type Page = { injectedWeb3?: Record<string, unknown> };
|
||||
|
||||
const DAPP = 'libreDecision';
|
||||
/** Wallets of the Ğ1 first, then the others, in the order they were found. */
|
||||
const PREFERRED = ['duniter-connect', 'g1companion', 'trustinwallet'];
|
||||
const rank = (name: string) => {
|
||||
const i = PREFERRED.indexOf(name.toLowerCase());
|
||||
return i < 0 ? PREFERRED.length : i;
|
||||
};
|
||||
|
||||
export function injectedNames(page: Page): string[] {
|
||||
return Object.entries(page.injectedWeb3 ?? {})
|
||||
.filter(([, v]) => typeof (v as Injected | null)?.enable === 'function')
|
||||
.map(([name]) => name)
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
/** What went wrong, in one of a few words the interface translates. */
|
||||
export function failure(e: unknown): Failure {
|
||||
if (e instanceof WalletError) return e.code;
|
||||
let text: string;
|
||||
if (e instanceof Error) text = e.message;
|
||||
else {
|
||||
try {
|
||||
text = JSON.stringify(e) ?? String(e);
|
||||
} catch {
|
||||
text = String(e);
|
||||
}
|
||||
}
|
||||
text = text.toLowerCase();
|
||||
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
|
||||
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
|
||||
return 'wallet';
|
||||
}
|
||||
|
||||
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
|
||||
export function nothingSent(f: Failure): boolean {
|
||||
return f === 'cancelled' || f === 'funds' || f === 'account';
|
||||
}
|
||||
|
||||
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
|
||||
export function testExtrinsic(fields: Record<string, string>): string {
|
||||
return `0x${hex(new TextEncoder().encode(JSON.stringify(fields)))}`;
|
||||
}
|
||||
|
||||
async function enable(name: string): Promise<Enabled> {
|
||||
const injected = (window as Page).injectedWeb3?.[name] as Injected | undefined;
|
||||
if (!injected?.enable) throw new WalletError('wallet');
|
||||
try {
|
||||
return await injected.enable(DAPP);
|
||||
} catch (e) {
|
||||
throw new WalletError(failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
|
||||
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
|
||||
const ext = await enable(name);
|
||||
if (!ext.signer.signPayload) throw new WalletError('wallet');
|
||||
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
|
||||
on('signing');
|
||||
let signature: string;
|
||||
try {
|
||||
({ signature } = await ext.signer.signPayload({ address: account.address, method: testExtrinsic(fields), version: 4 }));
|
||||
} catch (e) {
|
||||
throw new WalletError(failure(e));
|
||||
}
|
||||
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
|
||||
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!res?.ok) throw new WalletError('node');
|
||||
on('sent');
|
||||
}
|
||||
|
||||
/** The real thing: polkadot-api builds the transaction from the chain's own description. */
|
||||
async function sendChain(name: string, nodes: string[], account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
|
||||
const [{ createClient, Binary }, { getWsProvider }, { connectInjectedExtension }] = await Promise.all([
|
||||
import('polkadot-api'),
|
||||
import('polkadot-api/ws'),
|
||||
import('polkadot-api/pjs-signer'),
|
||||
]);
|
||||
const ext = await connectInjectedExtension(name, DAPP).catch((e: unknown) => {
|
||||
throw new WalletError(failure(e));
|
||||
});
|
||||
const client = createClient(getWsProvider(nodes));
|
||||
try {
|
||||
const signer = ext.getAccounts().find((a) => a.address === account.address);
|
||||
if (!signer) throw new WalletError('account');
|
||||
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
|
||||
const { tx: calls } = client.getUnsafeApi();
|
||||
const call = calls.Utility!.batch_all!({
|
||||
calls: [
|
||||
calls.Balances!.transfer_keep_alive!({ dest: { type: 'Id', value: tx.dest }, value: tx.amount }).decodedCall,
|
||||
calls.System!.remark_with_event!({ remark: Binary.fromText(tx.remark) }).decodedCall,
|
||||
],
|
||||
});
|
||||
on('signing');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
|
||||
next: (ev) => {
|
||||
if (ev.type === 'broadcasted') {
|
||||
on('sent');
|
||||
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
|
||||
timer ??= setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new WalletError('node'));
|
||||
}, 120_000);
|
||||
}
|
||||
if (ev.type === 'inBestBlock') {
|
||||
clearTimeout(timer);
|
||||
sub.unsubscribe();
|
||||
if (ev.ok) {
|
||||
on('included');
|
||||
resolve();
|
||||
} else reject(new WalletError(failure(ev.dispatchError)));
|
||||
}
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(new WalletError(failure(e)));
|
||||
},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
client.destroy();
|
||||
ext.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function injected(name: string, chain: ChainSettings): Wallet {
|
||||
return {
|
||||
id: name,
|
||||
async accounts() {
|
||||
const ext = await enable(name);
|
||||
const list = await ext.accounts.get().catch((e: unknown) => {
|
||||
throw new WalletError(failure(e));
|
||||
});
|
||||
return list.map((a) => ({ address: a.address, name: a.name ?? null }));
|
||||
},
|
||||
send(account, tx, on) {
|
||||
return chain.test && chain.testNode
|
||||
? sendTest(name, chain.testNode, account, tx, on)
|
||||
: sendChain(name, chain.nodes, account, tx, on);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The wallets this page can talk to, the ones for Ğ1 first. */
|
||||
export function wallets(chain: ChainSettings): Wallet[] {
|
||||
return injectedNames(window as Page).map((name) => injected(name, chain));
|
||||
}
|
||||
Reference in New Issue
Block a user