forked from yvv/decision
Chaque section du lecteur finit par l'ordre de ses articles (inertie 2) : ses articles numérotés, son vote déplié, « Proposer un autre ordre » — une liste où l'on clique un article puis sa place, ses flèches, ou le clavier. Le titre du document (inertie 4) se vote et se propose sous le titre, « traduction à revoir » quand il a changé. Les deux sont dans la liste des propositions, le panier et la chaîne Ğ1 ; un nouvel article se propose dans une section, à sa fin. La liste du groupe compte les propositions ouvertes des modules. Restes mineurs : la participation ramenée se dit en deux temps (la mesure, puis ce que le groupe demande), les lignes « habituelle » des réglages resserrées, le sélecteur d'inertie se parcourt aux flèches (le cran actuel atteignable, jamais choisi), la marque « seed a minima » porte le vrai nombre de votes. Contrôles : C25 (ordre, dont la chaîne et le téléphone), C26 (titre), C27 (participation ramenée) ; C3, C17 (41 modules), C20 mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
16 KiB
Svelte
440 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import ChainSign from '$lib/components/ChainSign.svelte';
|
|
import DeepList from '$lib/components/DeepList.svelte';
|
|
import DocPanel from '$lib/components/DocPanel.svelte';
|
|
import Guard from '$lib/components/Guard.svelte';
|
|
import Icon from '$lib/components/Icon.svelte';
|
|
import OrderModule from '$lib/components/OrderModule.svelte';
|
|
import TitleBar from '$lib/components/TitleBar.svelte';
|
|
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
|
import VoteFold from '$lib/components/VoteFold.svelte';
|
|
import WaveRule from '$lib/components/WaveRule.svelte';
|
|
import { sections } from '$lib/basket';
|
|
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
|
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
|
import { BasketState } from '$lib/picks.svelte';
|
|
import type { Lang, Proposal, Unit } from '$lib/types';
|
|
import type { Line } from '$lib/vote';
|
|
import { articles, type Filter } from '$lib/versions';
|
|
|
|
const doc = new DocState();
|
|
const basket = new BasketState();
|
|
const group = $derived(page.params.group ?? '');
|
|
const slug = $derived(page.params.doc ?? '');
|
|
$effect(() => {
|
|
void doc.load(group, slug);
|
|
});
|
|
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
|
$effect(() => {
|
|
const v = doc.view;
|
|
if (!v) return;
|
|
const known = new Set(sections(v.units, v.modules).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
|
basket.load(v.doc.id, known);
|
|
});
|
|
|
|
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
|
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
|
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
|
let prefill = $state<PanelState['prefill']>(null);
|
|
const panel = $derived.by((): PanelState | null => {
|
|
const q = page.url.searchParams;
|
|
const tab = TABS[q.get('onglet') ?? ''];
|
|
if (!tab) return null;
|
|
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
|
|
});
|
|
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
|
const vote = $derived.by(() => {
|
|
const q = page.url.searchParams;
|
|
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
|
|
});
|
|
|
|
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
|
prefill = nextPanel?.prefill ?? null;
|
|
const q = new URLSearchParams();
|
|
if (nextVote) q.set('vote', nextVote);
|
|
if (nextPanel) {
|
|
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
|
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
|
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
|
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
|
if (nextPanel.section) q.set('section', nextPanel.section);
|
|
}
|
|
const search = q.toString();
|
|
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
|
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
|
noScroll: true,
|
|
keepFocus: true,
|
|
});
|
|
}
|
|
const openPanel = (next: PanelState | null) => go(next, vote);
|
|
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
|
|
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
|
|
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
|
|
|
|
// On the Ğ1 chain: one signing at a time, over the page.
|
|
let signing = $state<Line[] | null>(null);
|
|
const sign = (lines: Line[]) => (signing = lines);
|
|
// While a ballot waits for the chain, the page looks again every ten seconds.
|
|
const waitingChain = $derived(
|
|
doc.ordered.some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
|
|
);
|
|
$effect(() => {
|
|
if (!waitingChain) return;
|
|
const every = setInterval(() => void doc.reload(), 10_000);
|
|
return () => clearInterval(every);
|
|
});
|
|
|
|
let filter = $state<Filter>('all');
|
|
const view = $derived(doc.view);
|
|
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
|
|
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
|
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
|
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
|
|
// One line per article; an article stays when one of its versions passes the filter.
|
|
const deep = $derived(articles(doc.open, filter));
|
|
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
|
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
|
|
// The title of the document, a module (ADR 0009): its gestures under the title.
|
|
const titleUnit = $derived(view?.modules.title ?? null);
|
|
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
|
|
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
|
|
|
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
|
if (action === 'vote') {
|
|
if (version) toggleVote(version);
|
|
return;
|
|
}
|
|
openPanel({ unitId: u.id, proposalId: null, tab: action });
|
|
}
|
|
// The text shows the vote of its own versions; the list below, that of the others.
|
|
// The modules in force are read in place too: the title at the head, each order at the end of its section.
|
|
const textIds = $derived(
|
|
new Set([
|
|
titleUnit?.inForce?.id,
|
|
...(doc.mode === 'inforce' || !doc.preview
|
|
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
|
|
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id)),
|
|
]),
|
|
);
|
|
const inText = $derived(!!vote && textIds.has(vote));
|
|
const canVote = $derived(!!view?.vote.open);
|
|
</script>
|
|
|
|
{#snippet foldOf(unit: Unit, p: Proposal)}
|
|
<VoteFold
|
|
{doc}
|
|
proposal={p}
|
|
{unit}
|
|
onopen={(id) => go(panel, id)}
|
|
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
|
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
|
onsign={sign}
|
|
/>
|
|
{/snippet}
|
|
|
|
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
|
|
|
{#if doc.error}
|
|
<Guard code={doc.error} onretry={() => doc.load(group, slug)} />
|
|
{:else if view}
|
|
<div class="reader" class:with-panel={!!panel}>
|
|
<header class="head">
|
|
<p class="crumbs eyebrow">
|
|
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
|
|
</p>
|
|
<h1>{title}</h1>
|
|
{#if titleUnit?.inForce}
|
|
{@const tp = titleUnit.inForce}
|
|
<TitleBar
|
|
unit={titleUnit}
|
|
stale={titleStale}
|
|
selected={panel?.unitId === titleUnit.id}
|
|
voteOpen={inText && vote === tp.id}
|
|
onaction={(action) => onUnit(titleUnit, action)}
|
|
>
|
|
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
|
|
</TitleBar>
|
|
{/if}
|
|
<p class="summary">{summary}</p>
|
|
<p class="facts faint">
|
|
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
|
|
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
|
|
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
|
|
</p>
|
|
|
|
{#if canVote}
|
|
<div class="basket-row">
|
|
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
|
|
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<div class="controls">
|
|
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
|
|
{#each VIEWS as v (v)}
|
|
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
|
|
{/each}
|
|
</div>
|
|
<div class="seg small" role="radiogroup" aria-label={t('doc.lang.label')}>
|
|
{#each LANGS as l (l)}
|
|
<button type="button" role="radio" aria-checked={lang === l} class:on={lang === l} onclick={() => { doc.original = l === source && i18n.lang !== source; if (l !== source) i18n.set(l); }}>{l.toUpperCase()}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{#if lang !== source}
|
|
<p class="note faint">{t('doc.lang.translation')} <button class="linkish" type="button" onclick={() => (doc.original = true)}>{t('doc.lang.original')}</button></p>
|
|
{/if}
|
|
{#if doc.mode !== 'inforce'}<p class="note preview">{t(`doc.view.${doc.mode}.note`)}</p>{/if}
|
|
</header>
|
|
|
|
<section class="text" aria-label={t('doc.view.inforce')}>
|
|
{#if doc.mode === 'inforce' || !doc.preview}
|
|
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
|
|
{#each s.units as u (u.id)}
|
|
{#if u.inForce}
|
|
{@const p = u.inForce}
|
|
<UnitBlock
|
|
text={shown(p, lang, source)}
|
|
seedMinimal={p.seedMinimal}
|
|
enteredAt={p.enteredAt}
|
|
votes={p.tally.for + p.tally.against}
|
|
rivals={rivalsOf(u)}
|
|
selected={panel?.unitId === u.id}
|
|
voteFor={p.id}
|
|
voteOpen={inText && vote === p.id}
|
|
onaction={(action) => onUnit(u, action)}
|
|
>
|
|
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
|
</UnitBlock>
|
|
{/if}
|
|
{/each}
|
|
{#if s.order?.inForce}
|
|
{@const o = s.order}
|
|
{@const op = s.order.inForce}
|
|
<OrderModule
|
|
unit={o}
|
|
{nameOf}
|
|
selected={panel?.unitId === o.id}
|
|
voteOpen={inText && vote === op.id}
|
|
onaction={(action) => onUnit(o, action)}
|
|
>
|
|
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
|
|
</OrderModule>
|
|
{/if}
|
|
{:else}
|
|
<p class="muted">{t('doc.empty')}</p>
|
|
{/each}
|
|
{:else}
|
|
{#each doc.preview as pu (pu.unitId)}
|
|
{@const u = doc.unit(pu.unitId)}
|
|
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
|
{@const p = doc.proposal(id)}
|
|
<UnitBlock
|
|
text={shown(pu, lang, source)}
|
|
mark={pu.mark}
|
|
selected={panel?.unitId === pu.unitId}
|
|
voteFor={id}
|
|
voteOpen={inText && !!id && vote === id}
|
|
onaction={(action) => u && onUnit(u, action, id)}
|
|
>
|
|
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
|
|
</UnitBlock>
|
|
{/each}
|
|
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<WaveRule />
|
|
|
|
<section class="deep" aria-labelledby="deep-title">
|
|
{#if doc.drafts.length}
|
|
<div class="drafts">
|
|
<h2 class="eyebrow">{t('deep.drafts')}</h2>
|
|
<p class="faint small">{t('deep.drafts.note')}</p>
|
|
<ul>
|
|
{#each doc.drafts as { unit, proposal } (proposal.id)}
|
|
<li><button type="button" class="linkish" onclick={() => openPanel({ unitId: unit.inForce ? unit.id : null, proposalId: null, tab: 'propose', draftId: proposal.id })}>{proposal.title}</button></li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="deep-head">
|
|
<h2 id="deep-title">{t('deep.title')}</h2>
|
|
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</p>
|
|
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
|
|
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
|
|
<button type="button" role="radio" aria-checked={filter === f} class:on={filter === f} onclick={() => (filter = f)}>{t(`deep.filter.${f}`)}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<DeepList
|
|
articles={deep}
|
|
vote={inText ? null : vote}
|
|
ontoggle={(_, p) => toggleVote(p.id)}
|
|
fold={foldOf}
|
|
{nameOf}
|
|
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
|
/>
|
|
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
|
</section>
|
|
</div>
|
|
|
|
{#if panel}
|
|
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
|
|
{/if}
|
|
{#if signing && view.vote.chain}
|
|
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
|
{/if}
|
|
{/if}
|
|
|
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
|
|
|
|
<style>
|
|
.reader {
|
|
max-width: calc(var(--measure) + 2.6rem);
|
|
margin: 0 auto;
|
|
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 6rem;
|
|
transition: padding 0.2s var(--ease);
|
|
}
|
|
@media (min-width: 1180px) {
|
|
.reader.with-panel {
|
|
margin-right: calc(var(--panel) + 2.5rem);
|
|
}
|
|
}
|
|
.crumbs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.crumbs a {
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
.crumbs a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
h1 {
|
|
margin-top: 0.8rem;
|
|
font-family: var(--font-text);
|
|
font-weight: 500;
|
|
font-size: clamp(2rem, 1.4rem + 2.4vw, 2.9rem);
|
|
line-height: 1.1;
|
|
letter-spacing: -0.012em;
|
|
font-variation-settings: 'opsz' 60;
|
|
}
|
|
.summary {
|
|
margin-top: 0.9rem;
|
|
font-family: var(--font-text);
|
|
font-style: italic;
|
|
color: var(--ink-soft);
|
|
font-size: 1.06rem;
|
|
line-height: 1.6;
|
|
}
|
|
.facts {
|
|
margin-top: 0.8rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
justify-content: space-between;
|
|
margin-top: 1.6rem;
|
|
}
|
|
.basket-row {
|
|
margin-top: 1.4rem;
|
|
}
|
|
.basket-row + .controls {
|
|
margin-top: 0.8rem;
|
|
}
|
|
.basket-btn {
|
|
background: var(--water);
|
|
color: var(--on-water);
|
|
}
|
|
.seg {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
background: var(--surface-2);
|
|
padding: 3px;
|
|
border-radius: var(--r-pill);
|
|
}
|
|
.seg button {
|
|
border: 0;
|
|
background: transparent;
|
|
border-radius: var(--r-pill);
|
|
padding: 0.35rem 0.85rem;
|
|
min-height: 2.25rem;
|
|
font-weight: 750;
|
|
font-size: 0.86rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
.seg.small button {
|
|
padding-inline: 0.65rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.seg button.on {
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.note {
|
|
margin-top: 0.8rem;
|
|
font-size: 0.86rem;
|
|
}
|
|
.note.preview {
|
|
color: var(--moss);
|
|
font-weight: 700;
|
|
}
|
|
.linkish {
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
color: var(--water);
|
|
font-weight: 750;
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.text {
|
|
margin-top: 2.2rem;
|
|
}
|
|
.deep {
|
|
padding-top: 2rem;
|
|
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
|
|
margin: 0 -1.3rem;
|
|
padding-inline: 1.3rem;
|
|
border-radius: 0 0 var(--r-card) var(--r-card);
|
|
}
|
|
.deep-head {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.deep-head h2 {
|
|
font-family: var(--font-text);
|
|
font-weight: 500;
|
|
font-size: 1.55rem;
|
|
}
|
|
.deep-head .seg {
|
|
justify-self: start;
|
|
}
|
|
.drafts {
|
|
display: grid;
|
|
gap: 0.3rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
.drafts ul {
|
|
margin: 0.3rem 0 0;
|
|
padding-left: 1.1rem;
|
|
}
|
|
.small {
|
|
font-size: 0.84rem;
|
|
}
|
|
.new {
|
|
margin-top: 1.4rem;
|
|
}
|
|
</style>
|