v3 : interface Svelte — accueil, groupes, lecteur du texte en vigueur, panneau contextuel, éditeur à assistant, trustinWallet

- Accueil sobre, menu complet à gauche (Décisions, Docs de référence, Mandats,
  Protocoles), intro demandée, quatre sections, connexion Ğ1.
- Lecteur : texte en vigueur en Literata, actions contextuelles par article
  (le vote, propositions, proposer), propositions en cours sous la « surface »
  avec leur niveau d'eau, aperçus « si les bien cotées passent » / « si tout
  passe », langue du texte fr/en/es (traductions éditoriales).
- Panneau : vote pour/contre, seuil et formule (MathML), inertie et son
  changement (voté en inertie +), coquille sans vote, traduction, signalement
  de contradiction, historique des corrections.
- trustinWallet (/acces) : groupes et membres, invitations à usage unique,
  administrateurs, retours d'usage.
- Contrôles : vitest (markdown, catalogues, anti-lexique), Playwright C1–C10 + M1.
- Sections du seed portées jusqu'à l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:58:34 +02:00
co-authored by Claude Opus 5.5
parent 67ddee28b2
commit 7a022dd35e
66 changed files with 7771 additions and 15 deletions
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import '../app.css';
import { onMount, type Snippet } from 'svelte';
import { afterNavigate } from '$app/navigation';
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
import Icon from '$lib/components/Icon.svelte';
import Seal from '$lib/components/Seal.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import Stamp from '$lib/components/Stamp.svelte';
import Toasts from '$lib/components/Toasts.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
let { children }: { children: Snippet } = $props();
let feedback: FeedbackDialog | undefined = $state();
let drawer = $state(false);
onMount(() => {
i18n.init();
mood.init();
void session.load();
});
afterNavigate(() => {
drawer = false;
});
</script>
<svelte:head>
<title>{t('app.name')}</title>
</svelte:head>
<a class="skip" href="#main">{t('app.skip')}</a>
<div class="shell">
<aside class="side">
<Sidebar onfeedback={() => feedback?.open()} />
</aside>
<header class="topbar">
<a class="brand" href="/"><Stamp size={2} /><span>libre<b>Decision</b></span></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
</button>
</header>
{#if drawer}
<div class="veil" role="presentation" onclick={() => (drawer = false)}></div>
<div class="drawer card" id="drawer" role="dialog" aria-modal="true" aria-label={t('nav.label')}>
<button class="close" type="button" onclick={() => (drawer = false)} aria-label={t('nav.close')}><Icon name="close" /></button>
<Sidebar onfeedback={() => { drawer = false; feedback?.open(); }} onnavigate={() => (drawer = false)} />
</div>
{/if}
<main id="main" tabindex="-1">
{#if session.ready}
{@render children()}
{/if}
</main>
</div>
<FeedbackDialog bind:this={feedback} />
<Toasts />
<Seal />
<svelte:window onkeydown={(e) => e.key === 'Escape' && (drawer = false)} />
<style>
.skip {
position: absolute;
left: -999px;
top: 0.5rem;
z-index: 100;
background: var(--surface);
padding: 0.5rem 1rem;
border-radius: var(--r-pill);
}
.skip:focus {
left: 0.5rem;
}
.shell {
min-height: 100dvh;
}
.side {
position: fixed;
inset: 0 auto 0 0;
width: var(--sidebar);
background: var(--surface);
box-shadow: var(--shadow);
z-index: 20;
overflow-y: auto;
}
main {
margin-left: var(--sidebar);
min-height: 100dvh;
outline: none;
}
.topbar {
display: none;
}
.veil {
position: fixed;
inset: 0;
background: var(--veil);
z-index: 40;
}
.drawer {
position: fixed;
inset: 0.6rem auto 0.6rem 0.6rem;
width: min(84vw, 18rem);
z-index: 41;
overflow-y: auto;
animation: slide 0.22s var(--ease);
}
.close {
position: absolute;
top: 1.2rem;
right: 0.8rem;
border: 0;
background: transparent;
color: var(--ink-soft);
width: 2.25rem;
height: 2.25rem;
border-radius: var(--r-icon);
}
@keyframes slide {
from {
transform: translateX(-12px);
opacity: 0;
}
}
@media (max-width: 899px) {
.side {
display: none;
}
main {
margin-left: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.9rem;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--ink);
text-decoration: none;
font-size: 1.02rem;
}
.brand b {
font-weight: 800;
}
.menu {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: var(--r-icon);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--ink);
}
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// SPA: the Rust server answers /api, the interface renders in the browser.
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'never';
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
const sections = $derived([
{ name: t('nav.decisions'), text: t('home.decisions'), open: false },
{ name: t('nav.docs'), text: t('home.docs'), open: true },
{ name: t('nav.mandates'), text: t('home.mandates'), open: false },
{ name: t('nav.protocols'), text: t('home.protocols'), open: false },
]);
</script>
<div class="home">
<header class="intro">
<h1>{t('home.title')}</h1>
<p class="sub">{t('home.subtitle')}</p>
</header>
<ul class="sections">
{#each sections as s (s.name)}
<li class="section" class:open={s.open}>
<div class="head">
<h2>{s.name}</h2>
<span class="tag" class:water={s.open}>{s.open ? t('home.open') : t('home.soon')}</span>
</div>
<p>{s.text}</p>
</li>
{/each}
</ul>
<div class="enter">
{#if session.me}
<a class="btn primary" href="/groupes">{t('home.continue')}</a>
{:else}
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
<p class="faint">{t('home.signin.note')}</p>
{/if}
</div>
</div>
<style>
.home {
max-width: 50rem;
margin: 0 auto;
padding: clamp(3rem, 10vh, 7rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
}
.intro h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2.1rem, 1.4rem + 3vw, 3.4rem);
letter-spacing: -0.015em;
line-height: 1.08;
font-variation-settings: 'opsz' 72;
max-width: 15ch;
text-wrap: balance;
}
.sub {
margin-top: 1.1rem;
font-family: var(--font-text);
font-style: italic;
font-size: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
color: var(--ink-soft);
max-width: 30ch;
}
.sections {
list-style: none;
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--surface-2);
border-radius: var(--r-card);
overflow: hidden;
box-shadow: var(--shadow);
}
.section {
background: var(--surface);
padding: 1.4rem 1.5rem 1.5rem;
display: grid;
gap: 0.55rem;
align-content: start;
}
.section:not(.open) {
color: var(--ink-soft);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
h2 {
font-size: 1.08rem;
font-weight: 800;
color: var(--ink);
}
.section:not(.open) h2 {
color: var(--ink-soft);
}
.section p {
font-size: 0.96rem;
line-height: 1.55;
}
.enter {
margin-top: 2.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.2rem;
}
.enter p {
font-size: 0.9rem;
max-width: 34ch;
}
@media (max-width: 640px) {
.sections {
grid-template-columns: 1fr;
}
}
</style>
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
interface Overview {
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
}
interface Feedback {
feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[];
}
const data = new Remote(() => api.get<Overview>('/access'));
const fb = new Remote(() => api.get<Feedback>('/access/feedback'));
onMount(() => {
void data.load();
void fb.load();
});
let choose = $state<Record<string, string>>({});
let label = $state('');
let groups = $state<string[]>([]);
let admin = $state(false);
let link = $state('');
let error = $state('');
const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id;
async function act(fn: () => Promise<unknown>) {
error = '';
try {
await fn();
await data.load();
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
}
async function invite(e: SubmitEvent) {
e.preventDefault();
await act(async () => {
const r = await api.post<{ link: string }>('/access/invitations', { label, groups, admin });
link = r.link;
label = '';
groups = [];
admin = false;
});
}
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
}
async function status(id: number, s: 'seen' | 'done') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
}
</script>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if !session.me.admin}
<div class="page"><p class="muted">{t('access.denied')}</p></div>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('access.title')}</h1>
<p class="muted">{t('access.intro')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<section>
<h2 class="eyebrow">{t('access.groups')}</h2>
{#each data.value.groups as g (g.slug)}
<div class="card block">
<h3>{g.name}</h3>
{#if g.policy === 'g1-members'}<p class="faint small">{t('access.members.wot')}</p>{/if}
<ul class="members">
{#each g.members as m (m.identityId)}
<li>
<span>{nameOf(m.identityId)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/groups/${g.slug}/members/${encodeURIComponent(m.identityId)}`))}>{t('access.revoke')}</button>
</li>
{:else}
<li class="faint small">{t('access.members.none')}</li>
{/each}
</ul>
<div class="row">
<select class="select" bind:value={choose[g.slug]} aria-label={t('access.choose')}>
<option value={undefined}>{t('access.choose')}</option>
{#each data.value.identities.filter((i) => !g.members.some((m) => m.identityId === i.id)) as i (i.id)}
<option value={i.id}>{i.displayName} · {t(`access.kind.${i.kind}`)}</option>
{/each}
</select>
<button class="btn small" type="button" disabled={!choose[g.slug]} onclick={() => act(() => api.post(`/access/groups/${g.slug}/members`, { identityId: choose[g.slug] }))}>{t('access.grant')}</button>
</div>
</div>
{/each}
</section>
<section>
<h2 class="eyebrow">{t('access.invitations')}</h2>
<form class="card block" onsubmit={invite}>
<label class="field"><span>{t('access.invite.label')}</span><input class="input" bind:value={label} maxlength="60" required /></label>
<fieldset>
<legend>{t('access.invite.groups')}</legend>
{#each data.value.groups as g (g.slug)}
<label class="check"><input type="checkbox" value={g.slug} bind:group={groups} /> {g.name}</label>
{/each}
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
</fieldset>
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
{#if link}
<div class="link">
<p class="small">{t('access.invite.link')}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
{#each data.value.invitations as inv (inv.id)}
<li>
<span><b>{inv.label}</b> · {t('access.invite.pending')} · {i18n.date(inv.expiresAt)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/invitations/${inv.id}`))}>{t('access.invite.cancel')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
{#each data.value.identities as i (i.id)}
<li>
<span>
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
{#if i.g1Member} · {t('access.member')}{/if}
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</li>
{/each}
</ul>
</section>
<section>
<h2 class="eyebrow">{t('access.feedback')}</h2>
{#each fb.value?.feedback ?? [] as f (f.id)}
<div class="card block fb" class:done={f.status === 'done'}>
<p class="small faint">{f.author ?? '—'} · {i18n.date(f.createdAt)} · <code>{f.route}</code></p>
<p class="body">{f.body}</p>
<div class="row">
{#if f.status === 'new'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'seen')}>{t('access.feedback.seen')}</button>{/if}
{#if f.status !== 'done'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'done')}>{t('access.feedback.done')}</button>{/if}
</div>
</div>
{:else}
<p class="faint">{t('access.feedback.none')}</p>
{/each}
</section>
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
h3 {
font-size: 1.05rem;
font-weight: 800;
}
section {
display: grid;
gap: 0.7rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.6rem;
}
.members,
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.members li,
.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.row {
display: flex;
gap: 0.5rem;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
legend {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
margin-bottom: 0.3rem;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
}
.block > .btn {
justify-self: start;
}
.link {
display: grid;
gap: 0.5rem;
padding: 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.link code {
word-break: break-all;
font-size: 0.85rem;
}
.link .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.fb .body {
white-space: pre-wrap;
}
.fb.done {
opacity: 0.6;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { t, type Key } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
const back = $derived(page.url.searchParams.get('return') ?? '/groupes');
const error = $derived(page.url.searchParams.get('erreur'));
let devName = $state('');
let devAdmin = $state(false);
let devError = $state('');
$effect(() => {
if (session.me) void goto(back, { replaceState: true });
});
async function dev(e: SubmitEvent) {
e.preventDefault();
try {
const r = await api.post<{ me: Me }>('/auth/dev', { name: devName, admin: devAdmin });
session.me = r.me;
} catch (err) {
devError = t(`error.${err instanceof ApiError ? err.code : 'internal'}` as Key);
}
}
</script>
<div class="page">
<h1>{t('signin.title')}</h1>
{#if error}
<p class="alert" role="alert">{t(`signin.error.${error}` as Key)}</p>
{/if}
<section class="card block">
{#if session.g1}
<a class="btn primary big" href="/api/auth/g1/start?return={encodeURIComponent(back)}">{t('signin.g1')}</a>
<p>{t('signin.g1.how')}</p>
<p class="faint">{t('signin.g1.members')}</p>
{:else}
<button class="btn primary big" type="button" disabled>{t('signin.g1')}</button>
<p>{t('signin.g1.pending')}</p>
{/if}
</section>
<p class="muted invite">{t('signin.invite')}</p>
{#if session.dev}
<form class="card block dev" onsubmit={dev}>
<h2 class="eyebrow">{t('signin.dev')}</h2>
<label class="field"><span>{t('signin.dev.name')}</span><input class="input" bind:value={devName} required maxlength="40" /></label>
<label class="check"><input type="checkbox" bind:checked={devAdmin} /> {t('signin.dev.admin')}</label>
{#if devError}<p class="alert">{devError}</p>{/if}
<button class="btn" type="submit">{t('signin.dev.go')}</button>
</form>
{/if}
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem 4rem;
display: grid;
gap: 1.2rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
.block {
padding: 1.5rem;
display: grid;
gap: 0.9rem;
}
.big {
min-height: 3rem;
font-size: 1.02rem;
justify-self: start;
}
.invite {
padding: 0 0.3rem;
}
.alert {
color: var(--ochre);
font-weight: 700;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
color: var(--ink-soft);
}
.dev .btn {
justify-self: start;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
$effect(() => {
void slug;
void data.load();
});
const labels: Record<string, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const texts: Record<string, Key> = { decisions: 'home.decisions', docs: 'home.docs', mandats: 'home.mandates', protocoles: 'home.protocols' };
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/groupes">{t('nav.groups')}</a>
<h1>{g.name}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<h2 class="eyebrow sub">{t('group.tools')}</h2>
<ul class="tools">
{#each data.value.tools as tool (tool.key)}
<li>
{#if tool.active}
<a class="tool card" href="/g/{g.slug}/docs">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}</span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</a>
{:else}
<div class="tool off" aria-disabled="true">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}<span class="tag">{t('home.soon')}</span></span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
}
.sub {
margin-top: 1.6rem;
color: var(--ink-soft);
}
.tools {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.tool {
display: grid;
gap: 0.45rem;
padding: 1.3rem 1.4rem;
height: 100%;
color: var(--ink);
text-decoration: none;
}
a.tool {
transition: transform 0.12s ease;
}
a.tool:hover {
transform: translateY(-3px);
}
.tool.off {
border-radius: var(--r-card);
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.name {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-weight: 800;
font-size: 1.05rem;
}
.text {
font-size: 0.93rem;
line-height: 1.5;
color: var(--ink-soft);
}
.off .text {
color: var(--ink-faint);
}
@media (max-width: 640px) {
.tools {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
import type { LangMap } from '$lib/types';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
$effect(() => {
void slug;
void data.load();
});
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
<h1>{t('docs.title')}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else if !data.value.docs.length}
<p class="muted">{t('group.docs.empty')}</p>
{:else}
<ul class="docs">
{#each data.value.docs as d (d.id)}
<li>
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
<span class="summary">{pick(d.summary)}</span>
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1rem;
}
.docs {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.doc {
display: grid;
gap: 0.5rem;
padding: 1.5rem 1.6rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.doc:hover {
transform: translateY(-3px);
}
.title {
font-family: var(--font-text);
font-size: 1.45rem;
font-weight: 500;
}
.summary {
color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,302 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
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 UnitBlock from '$lib/components/UnitBlock.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
const doc = new DocState();
const group = $derived(page.params.group ?? '');
const slug = $derived(page.params.doc ?? '');
$effect(() => {
void doc.load(group, slug);
});
// The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer' };
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'), prefill };
});
function openPanel(next: PanelState | null) {
prefill = next?.prefill ?? null;
const q = new URLSearchParams();
if (next) {
q.set('onglet', TAB_NAMES[next.tab]);
if (next.unitId) q.set('article', next.unitId);
if (next.proposalId) q.set('version', next.proposalId);
if (next.draftId) q.set('brouillon', next.draftId);
}
const search = q.toString();
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
}
let filter = $state<'all' | 'wellrated' | 'mine'>('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 ?? '') : '');
const deep = $derived(
doc.open.filter(({ proposal: p }) =>
filter === 'wellrated' ? p.evaluation?.wellRated : filter === 'mine' ? p.mine : true,
),
);
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, tab: Tab) {
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
}
function onProposal(u: Unit, p: Proposal) {
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
}
</script>
<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>
<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}
</p>
<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.inForce as u (u.id)}
{#if u.inForce}
<UnitBlock
text={shown(u.inForce, lang, source)}
seedMinimal={u.inForce.seedMinimal}
enteredAt={u.inForce.enteredAt}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
onaction={(tab) => onUnit(u, tab)}
/>
{/if}
{:else}
<p class="muted">{t('doc.empty')}</p>
{/each}
{:else}
{#each doc.preview as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
<UnitBlock
text={shown(pu, lang, source)}
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
/>
{/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 items={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
<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} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
{/if}
{/if}
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && 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;
}
.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>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupSummary } from '$lib/types';
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
onMount(() => void data.load());
const groups = $derived(data.value?.groups ?? []);
const root = $derived(groups.find((g) => g.slug === 'monnaie-libre'));
const sameKey = $derived(groups.filter((g) => g.parent === 'monnaie-libre'));
const others = $derived(groups.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('groups.title')}</h1>
{#if root}
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
<span class="eyebrow">Ğ1</span>
<span class="name">{root.name}</span>
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
</a>
{/if}
{#if sameKey.length}
<h2 class="eyebrow sub">{t('groups.same.key')}</h2>
<ul class="list">
{#each sameKey as g (g.slug)}
<li>
{#if g.open}
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
{:else}
<div class="row card locked" title={t('groups.locked.long')}>
<span class="name">{g.name}</span>
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
{/if}
{#if others.length}
<h2 class="eyebrow sub">{t('groups.test')}</h2>
<ul class="list">
{#each others as g (g.slug)}
<li>
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
</a>
</li>
{/each}
</ul>
{/if}
{#if !groups.length}<p class="muted">{t('groups.empty')}</p>{/if}
</div>
{/if}
<style>
.page {
max-width: 42rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.9rem, 1.5rem + 1.5vw, 2.5rem);
margin-bottom: 0.6rem;
}
.root {
display: grid;
gap: 0.3rem;
padding: 1.6rem 1.6rem 1.7rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
a.root:hover,
a.row:hover {
transform: translateY(-3px);
}
.root .name {
font-family: var(--font-text);
font-size: 1.7rem;
font-weight: 500;
}
.sub {
margin-top: 1.4rem;
color: var(--ink-soft);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.row .name {
font-weight: 750;
}
.locked {
background: transparent;
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.lock {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.82rem;
font-weight: 700;
}
.note {
font-size: 0.88rem;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
let state = $state<'working' | 'invalid' | 'missing'>('working');
onMount(async () => {
// The token travels in the fragment: it never reaches a server log.
const token = location.hash.slice(1).trim();
history.replaceState(null, '', '/invitation');
if (!/^[0-9a-f]{64}$/.test(token)) {
state = 'missing';
return;
}
try {
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
session.me = r.me;
await goto('/groupes', { replaceState: true });
} catch {
state = 'invalid';
}
});
</script>
<div class="page">
<h1>{t('invite.title')}</h1>
<p class="muted" role="status">{t(`invite.${state}`)}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>