Files
decision/web/src/lib/doc.svelte.ts
T
YvvandClaude Opus 5.5 b260596cfd v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:47:26 +01:00

121 lines
4.0 KiB
TypeScript

// 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, 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';
/** 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;
}
export interface Shown {
title: string;
body: string;
/** The text is a translation (editorial). */
translated: boolean;
stale: boolean;
/** Asked for a translation that does not exist yet: the source is shown. */
missing: boolean;
}
export function shown(
v: { title: string; body: string; translations: Partial<Record<Lang, Translation>> },
lang: Lang,
source: Lang,
): Shown {
if (lang === source) return { title: v.title, body: v.body, translated: false, stale: false, missing: false };
const tr = v.translations[lang];
if (!tr) return { title: v.title, body: v.body, translated: false, stale: false, missing: true };
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
}
export class DocState {
view = $state<DocView | null>(null);
error = $state<string | null>(null);
mode = $state<View>('inforce');
preview = $state<PreviewUnit[] | null>(null);
original = $state(false);
async load(group: string, slug: string) {
try {
const g = await api.get<GroupView>(`/groups/${group}`);
const d = g.docs.find((x) => x.slug === slug);
if (!d) {
this.error = g.group.open ? 'not_found' : 'forbidden';
return;
}
this.view = await api.get<DocView>(`/docs/${d.id}`);
this.error = null;
if (this.mode !== 'inforce') await this.loadPreview();
} catch (e) {
this.error = e instanceof ApiError ? e.code : 'internal';
}
}
async reload() {
if (!this.view) return;
this.view = await api.get<DocView>(`/docs/${this.view.doc.id}`);
if (this.mode !== 'inforce') await this.loadPreview();
}
async setMode(mode: View) {
this.mode = mode;
if (mode === 'inforce') this.preview = null;
else await this.loadPreview();
}
async loadPreview() {
if (!this.view) return;
const r = await api.get<{ units: PreviewUnit[] }>(`/docs/${this.view.doc.id}/preview?mode=${this.mode}`);
this.preview = r.units;
}
unit(id: string | null): Unit | undefined {
return this.view?.units.find((u) => u.id === id);
}
proposal(id: string | null): Proposal | undefined {
if (!id || !this.view) return undefined;
for (const u of this.view.units) {
if (u.inForce?.id === id) return u.inForce;
const p = u.proposals.find((x) => x.id === id);
if (p) return p;
}
return undefined;
}
get inForce(): Unit[] {
return (this.view?.units ?? []).filter((u) => u.inForce);
}
/** Open proposals, in document order; drafts apart. */
get open(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.view?.units ?? []) {
for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal });
}
return out;
}
get drafts(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.view?.units ?? []) {
for (const proposal of unit.proposals) if (proposal.status === 'draft') out.push({ unit, proposal });
}
return out;
}
}