448 lines
15 KiB
Svelte
448 lines
15 KiB
Svelte
<script lang="ts">
|
|
// The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
|
|
// coup d'œil »): each section folded to one line — its name, how many articles are
|
|
// proposed, a small water level for each, so one sees where support rises without opening
|
|
// anything. A gesture unfolds one, « Tout déplier » all of them; a filter, or a vote
|
|
// unfolded by the URL, opens the section that holds it. Inside, one line per article (Yvv,
|
|
// 2026-10-30): one with a single version unfolds its vote below it; one with several shows
|
|
// the best and unfolds the others. A section not read in the text above is closed here by
|
|
// its order.
|
|
import type { Snippet } from 'svelte';
|
|
import { plain } from '$lib/markdown';
|
|
import { needOf, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
|
import type { Proposal, Unit } from '$lib/types';
|
|
import { excerpts, type Article, type DeepSection } from '$lib/versions';
|
|
import Crans from './Crans.svelte';
|
|
import Icon from './Icon.svelte';
|
|
import WaterLevel from './WaterLevel.svelte';
|
|
|
|
let {
|
|
sections,
|
|
vote,
|
|
reordering = null,
|
|
all = false,
|
|
ontoggle,
|
|
fold,
|
|
order,
|
|
onsection,
|
|
}: {
|
|
/** The articles with open proposals, by section, each with its order when it closes here. */
|
|
sections: DeepSection[];
|
|
/** The version whose vote is unfolded. */
|
|
vote: string | null;
|
|
/** The order module being reordered: its section stays open. */
|
|
reordering?: string | null;
|
|
/** Every section open (a filter is on). */
|
|
all?: boolean;
|
|
ontoggle: (unit: Unit, p: Proposal) => void;
|
|
fold: Snippet<[Unit, Proposal]>;
|
|
/** The order line of a section, at its end. */
|
|
order: Snippet<[Unit]>;
|
|
/** Opens the basket on a section; absent when this session does not vote here. */
|
|
onsection?: (section: string) => void;
|
|
} = $props();
|
|
|
|
const keyOf = (s: DeepSection) => s.key ?? '';
|
|
// Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
|
|
let unfolded = $state<Record<string, boolean>>({});
|
|
let versionsOpen = $state<Record<string, boolean>>({});
|
|
const holds = (s: DeepSection) =>
|
|
s.articles.some((a) => a.versions.some((p) => p.id === vote)) ||
|
|
(!!s.order && (s.order.id === reordering || [s.order.inForce, ...s.order.proposals].some((p) => p?.id === vote)));
|
|
const isOpen = (s: DeepSection) => all || (unfolded[keyOf(s)] ?? holds(s));
|
|
const everyOpen = $derived(sections.every((s) => isOpen(s)));
|
|
const toggle = (s: DeepSection) => (unfolded[keyOf(s)] = !isOpen(s));
|
|
function setAll(open: boolean) {
|
|
unfolded = Object.fromEntries(sections.map((s) => [keyOf(s), open]));
|
|
}
|
|
|
|
const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
|
|
const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
|
|
|
|
/** The title of the article: its best text version, or what the best proposal is. */
|
|
function headline(a: Article): string {
|
|
const text = a.versions.find((p) => p.kind === 'text');
|
|
const best = a.versions[0];
|
|
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
|
|
}
|
|
|
|
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
|
|
function labelOf(p: Proposal, a: Article): string {
|
|
if (p.kind === 'inertia' || p.kind === 'remove') return titleOf(p, a.unit);
|
|
if (p.label) return t('deep.variant', { label: p.label });
|
|
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
|
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
|
|
return t('deep.version', { n: arrival + 1 });
|
|
}
|
|
|
|
/** The excerpt of each text version, from where it parts ways with its siblings. */
|
|
function toldApart(a: Article): Map<string, string> {
|
|
const textual = a.versions.filter((p) => p.kind === 'text');
|
|
const told = excerpts(textual.map((p) => plain(p.body)));
|
|
return new Map([
|
|
...a.versions.map((p): [string, string] => [p.id, p.kind === 'text' ? plain(p.body) : '']),
|
|
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
|
|
]);
|
|
}
|
|
</script>
|
|
|
|
{#snippet need(p: Proposal)}
|
|
{@const n = needOf(p)}
|
|
{#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
|
|
{/snippet}
|
|
|
|
{#snippet marks(p: Proposal)}
|
|
{#if p.seedProposal}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
|
|
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
|
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
|
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
|
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
|
{/snippet}
|
|
|
|
{#if sections.length && !all}
|
|
<div class="all">
|
|
<button type="button" class="linkish" onclick={() => setAll(!everyOpen)}>{t(everyOpen ? 'deep.fold' : 'deep.unfold')}</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<ul class="deep">
|
|
{#each sections as s, i (keyOf(s))}
|
|
{@const open = isOpen(s)}
|
|
{@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))}
|
|
<li class="section" class:open>
|
|
<div class="head">
|
|
<button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
|
|
<span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
|
|
<span class="name">{label}</span>
|
|
{#if s.articles.length}
|
|
<span class="count">{i18n.plural('deep.section.count', s.articles.length)}</span>
|
|
<span class="waters" aria-hidden="true">
|
|
{#each s.articles as a (a.unit.id)}
|
|
{@const best = a.versions[0]}
|
|
{#if best}<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} size="mini" />{/if}
|
|
{/each}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{#if onsection && s.key && s.articles.length}
|
|
{@const key = s.key}
|
|
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
|
|
{/if}
|
|
</div>
|
|
{#if open}
|
|
<div class="body" id="deep-sec-{i}">
|
|
{#if s.order?.id !== reordering}
|
|
<ul class="rows">
|
|
{#each s.articles as a (a.unit.id)}
|
|
{@const unit = a.unit}
|
|
{@const best = a.versions[0]}
|
|
{#if best && a.versions.length === 1}
|
|
<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>
|
|
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
|
<span class="meta">
|
|
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
|
{@render need(best)}
|
|
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
|
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
|
|
{@render marks(best)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
|
|
</li>
|
|
{:else if best}
|
|
{@const vopen = isVersionsOpen(a)}
|
|
{@const title = headline(a)}
|
|
{@const told = toldApart(a)}
|
|
<li class="article" class:open={vopen}>
|
|
<button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
|
|
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
|
<span class="main">
|
|
<span class="title">{title}</span>
|
|
{#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</span>{/if}
|
|
<span class="meta">
|
|
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
|
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
|
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
|
{@render need(best)}
|
|
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
|
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
|
|
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
|
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
|
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
|
|
</span>
|
|
</span>
|
|
<span class="chevron"><Icon name="chevron" size={18} /></span>
|
|
</button>
|
|
<ul class="versions" id="versions-{unit.id}" hidden={!vopen}>
|
|
{#each a.versions as p (p.id)}
|
|
<li>
|
|
<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>
|
|
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
|
<span class="meta">
|
|
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
|
{@render need(p)}
|
|
{@render marks(p)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{#if vote === p.id}{@render fold(unit, p)}{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</li>
|
|
{/if}
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
{#if s.order}{@render order(s.order)}{/if}
|
|
</div>
|
|
{/if}
|
|
</li>
|
|
{:else}
|
|
<li class="faint none">{t('deep.none')}</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<style>
|
|
.all {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.linkish {
|
|
border: 0;
|
|
background: none;
|
|
padding: 0.3rem 0;
|
|
min-height: 2.25rem;
|
|
color: var(--water);
|
|
font-weight: 750;
|
|
font-size: 0.86rem;
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.deep,
|
|
.rows {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
}
|
|
.section {
|
|
margin: 0 -1rem;
|
|
border-radius: var(--r-card);
|
|
}
|
|
.head {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.2rem 0.6rem;
|
|
}
|
|
.toggle {
|
|
flex: 1 1 16rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.25rem 0.7rem;
|
|
min-height: 3rem;
|
|
padding: 0.55rem 1rem;
|
|
border: 0;
|
|
border-radius: var(--r-card);
|
|
background: transparent;
|
|
text-align: left;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
.toggle:hover {
|
|
background: var(--surface);
|
|
}
|
|
.name {
|
|
font-family: var(--font-text);
|
|
font-style: italic;
|
|
font-size: 1.12rem;
|
|
color: var(--ink);
|
|
}
|
|
.count {
|
|
font-size: 0.8rem;
|
|
font-weight: 750;
|
|
color: var(--water);
|
|
}
|
|
.waters {
|
|
display: inline-flex;
|
|
align-items: flex-end;
|
|
gap: 3px;
|
|
}
|
|
.head .chevron {
|
|
display: inline-flex;
|
|
color: var(--ink-faint);
|
|
transition: transform 0.2s var(--ease);
|
|
}
|
|
.section.open .head .chevron {
|
|
transform: rotate(90deg);
|
|
}
|
|
.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;
|
|
margin-right: 0.6rem;
|
|
border-radius: var(--r-pill);
|
|
}
|
|
.vote-section:hover {
|
|
background: var(--water-wash);
|
|
}
|
|
.body {
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
padding: 0 0 0.9rem;
|
|
animation: unfold 0.2s var(--ease);
|
|
}
|
|
.body > :global(.order-line) {
|
|
margin: 0.3rem 0 0.4rem;
|
|
}
|
|
.article {
|
|
border-radius: var(--r-card);
|
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
.article.open {
|
|
background: var(--surface);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.row {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0.85rem 1rem;
|
|
border: 0;
|
|
border-radius: var(--r-card);
|
|
background: transparent;
|
|
text-align: left;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
.article:not(.open) .row:hover,
|
|
.row.current {
|
|
background: var(--surface);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.main {
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
min-width: 0;
|
|
}
|
|
.title {
|
|
font-weight: 780;
|
|
}
|
|
.excerpt {
|
|
font-family: var(--font-text);
|
|
color: var(--ink-soft);
|
|
font-size: 0.95rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem 0.8rem;
|
|
align-items: center;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
color: var(--ink-soft);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.need {
|
|
color: var(--ink);
|
|
}
|
|
.row .chevron {
|
|
flex: none;
|
|
margin-left: auto;
|
|
display: inline-flex;
|
|
color: var(--ink-faint);
|
|
transition: transform 0.2s var(--ease);
|
|
}
|
|
.article.open .row .chevron {
|
|
transform: rotate(90deg);
|
|
}
|
|
.versions {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0.6rem 0.7rem 1.6rem;
|
|
display: grid;
|
|
gap: 0.15rem;
|
|
}
|
|
.versions[hidden] {
|
|
display: none;
|
|
}
|
|
.version {
|
|
display: flex;
|
|
gap: 0.85rem;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0.6rem 0.8rem;
|
|
border: 0;
|
|
border-radius: var(--r-input);
|
|
background: transparent;
|
|
text-align: left;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
.version:hover,
|
|
.version.current {
|
|
background: var(--surface-2);
|
|
}
|
|
.label {
|
|
font-weight: 750;
|
|
font-size: 0.92rem;
|
|
}
|
|
.other {
|
|
font-weight: 600;
|
|
color: var(--ink-soft);
|
|
}
|
|
.fold {
|
|
padding: 0 1rem 1rem;
|
|
}
|
|
.none {
|
|
padding: 0.8rem 0;
|
|
}
|
|
@keyframes unfold {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
}
|
|
@media (max-width: 560px) {
|
|
.versions {
|
|
padding-left: 0.6rem;
|
|
}
|
|
}
|
|
@media (max-width: 899px) {
|
|
.vote-section {
|
|
min-height: 2.75rem;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.body {
|
|
animation: none;
|
|
}
|
|
.head .chevron,
|
|
.row .chevron,
|
|
.toggle,
|
|
.row,
|
|
.version,
|
|
.article {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|