Interface au strict nécessaire (le lot U refera la présentation) : la marque « Proposition seed » là où une version est montrée (liste des propositions, Détails, panneau Propositions), seedProposal?: boolean dans types.ts ; au-dessus des ordres, « Aucun article n'est en vigueur » quand c'est le cas. e2e : C3 (aucun texte en vigueur, 33 lignes, trois Propositions seed au cran 2), C9 et C18 (« si tout passe »), C20, C23, C24 et M1 par la liste des propositions dans Monnaie libre ; dans le groupe d'essai, I1, E1 et E2 entrent d'abord par le vote (seedsInForce : deux voix puis leur retrait, le texte reste en vigueur) pour les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24, C28 à C31). Boucle e2e : 35 / 35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
441 lines
16 KiB
Svelte
441 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, votableIds } from '$lib/basket';
|
|
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
|
|
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
|
import { answerPanel } from '$lib/objections';
|
|
import { BasketState } from '$lib/picks.svelte';
|
|
import { session } from '$lib/session.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, and so does a « for » on a version held back by an objection.
|
|
$effect(() => {
|
|
const v = doc.view;
|
|
if (!v) return;
|
|
basket.load(v.doc.id, votableIds(sections(v.units, v.modules)));
|
|
});
|
|
|
|
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
|
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,
|
|
objectionId: q.get('objection'),
|
|
edit: q.get('modifier') === '1',
|
|
};
|
|
});
|
|
/** 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 search = panelSearch(nextPanel, nextVote);
|
|
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' })}
|
|
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
|
|
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}
|
|
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
|
|
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
|
|
{#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}
|
|
{/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>
|