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:
@@ -52,3 +52,4 @@ web/test-results/
|
||||
web/playwright-report/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
web/node_modules/
|
||||
|
||||
@@ -65,6 +65,7 @@ CREATE TABLE units (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
code TEXT NOT NULL,
|
||||
section TEXT, -- grouping key of the seed (introduction, technique…)
|
||||
position REAL NOT NULL,
|
||||
inertia TEXT NOT NULL, -- low | standard | high | max
|
||||
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
|
||||
|
||||
@@ -185,6 +185,7 @@ pub struct DocRow {
|
||||
pub struct UnitRow {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
pub section: Option<String>,
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub in_force_id: Option<String>,
|
||||
@@ -274,7 +275,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
let doc = load_doc(state, doc_id).await?;
|
||||
let group = load_group(state, &doc.group_slug).await?;
|
||||
let units = sqlx::query_as::<_, UnitRow>(
|
||||
"SELECT id, code, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -515,7 +516,7 @@ pub async fn document(
|
||||
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||
json!({
|
||||
"id": u.id, "code": u.code, "position": u.position, "inertia": u.inertia,
|
||||
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,14 +171,20 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
|
||||
.bind(&st.doc.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, 'standard', NULL, ?)")
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.bind(position_after(&st.units, after))
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// A new article belongs to the section of the one it follows.
|
||||
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.bind(section)
|
||||
.bind(position_after(&st.units, after))
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -783,7 +789,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(id: &str, position: f64) -> UnitRow {
|
||||
UnitRow { id: id.into(), code: id.into(), position, inertia: "standard".into(), in_force_id: None }
|
||||
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "standard".into(), in_force_id: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct SeedInstance {
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedUnit {
|
||||
pub code: String,
|
||||
pub section: Option<String>,
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub versions: Vec<SeedVersion>,
|
||||
@@ -136,10 +137,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
.await?;
|
||||
for unit in &seed.units {
|
||||
let unit_id = util::id();
|
||||
sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
|
||||
.bind(&unit_id)
|
||||
.bind(&doc_id)
|
||||
.bind(&unit.code)
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(&unit_id)
|
||||
.bind(&doc_id)
|
||||
.bind(&unit.code)
|
||||
.bind(&unit.section)
|
||||
.bind(unit.position)
|
||||
.bind(&unit.inertia)
|
||||
.bind(&now)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
|
||||
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
|
||||
|
||||
export async function signIn(page: Page, name: string, admin = false) {
|
||||
const r = await page.request.post('/api/auth/dev', {
|
||||
data: { name, admin },
|
||||
headers: { 'x-requested-with': 'libredecision' },
|
||||
});
|
||||
if (!r.ok()) throw new Error(`dev login ${r.status()}`);
|
||||
}
|
||||
|
||||
export async function person(browser: Browser, name: string, admin = false): Promise<Page> {
|
||||
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||||
const page = await ctx.newPage();
|
||||
await signIn(page, name, admin);
|
||||
return page;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DOC, signIn } from './helpers';
|
||||
|
||||
test('M1 — mobile : menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await page.getByRole('button', { name: 'Menu' }).click();
|
||||
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
|
||||
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
|
||||
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
|
||||
const first = page.locator('section.text article.unit').first();
|
||||
await first.getByRole('button', { name: 'Le vote' }).click();
|
||||
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DOC, person, signIn, TEST_DOC } from './helpers';
|
||||
|
||||
test('C1 — l’accueil : l’intro, les quatre sections, le menu complet', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
||||
await expect(page.getByText('en fonction du contexte, processus et modalités, boîte à outils')).toBeVisible();
|
||||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
||||
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
|
||||
}
|
||||
await expect(nav.locator('.tool.disabled')).toHaveCount(3);
|
||||
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto('/groupes');
|
||||
for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) {
|
||||
await expect(page.getByText(g, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C3 — le texte en vigueur : préambule et deux engagements, seed a minima', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||||
const units = page.locator('section.text article.unit');
|
||||
await expect(units).toHaveCount(3);
|
||||
await expect(units.nth(0).locator('h2')).toHaveText('Préambule');
|
||||
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
|
||||
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
|
||||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(32);
|
||||
});
|
||||
|
||||
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
const first = page.locator('section.text article.unit').first();
|
||||
await first.hover();
|
||||
await first.getByRole('button', { name: 'Le vote' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
|
||||
await panel.getByText('La formule du seuil').click();
|
||||
await expect(panel.getByText(/W — électorat/)).toBeVisible();
|
||||
// The panel lives in the URL.
|
||||
await expect(page).toHaveURL(/onglet=vote/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(panel).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
const chiara = await person(browser, 'Chiara');
|
||||
for (const p of [yvv, chiara]) {
|
||||
await p.goto(TEST_DOC);
|
||||
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
|
||||
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(p.getByText('Vote enregistré.')).toBeVisible();
|
||||
}
|
||||
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
|
||||
await chiara.keyboard.press('Escape');
|
||||
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
const preamble = page.locator('section.text article.unit').first();
|
||||
await preamble.hover();
|
||||
await preamble.getByRole('button', { name: 'Le vote' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replace('latransmission', 'la transmission'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible();
|
||||
await expect(preamble).toContainText('la transmission de ce document');
|
||||
// A change of meaning is turned away.
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
await body.fill('Tout autre chose, qui change le sens du préambule.');
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('C7 — l’assistant propose de découper deux décisions', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
await page.getByRole('button', { name: 'Proposer un nouvel article' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByLabel('Titre').fill('Joignabilité et vérification');
|
||||
await panel.getByLabel('Texte').fill(
|
||||
"Je m'engage à rester joignable par mes certifieurs.\n\nJe m'engage à vérifier la clé publique avec la personne.",
|
||||
);
|
||||
await expect(panel.getByText('Ce texte semble porter 2 décisions.', { exact: false })).toBeVisible();
|
||||
await panel.getByRole('button', { name: 'Découper en 2 brouillons' }).click();
|
||||
await expect(page.getByText('Découpé en 2 brouillons.')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Tes brouillons' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('C8 — multilingue : le texte et l’interface en anglais et en espagnol', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
|
||||
await expect(page.getByText('Open proposals', { exact: false }).first()).toBeVisible();
|
||||
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Acta de compromiso de Certificación');
|
||||
});
|
||||
|
||||
test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await page.getByRole('radio', { name: 'Si tout passe' }).click();
|
||||
await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible();
|
||||
await expect(page.locator('section.text article.unit')).toHaveCount(33);
|
||||
await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click();
|
||||
await expect(page.locator('section.text article.unit')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => {
|
||||
const chiara = await person(browser, 'Chiara');
|
||||
await chiara.goto('/acces');
|
||||
await expect(chiara.getByText('La gestion des accès est réservée aux administrateurs de trustinWallet.')).toBeVisible();
|
||||
const yvv = await person(browser, 'Yvv', true);
|
||||
await yvv.goto('/acces');
|
||||
await expect(yvv.getByRole('heading', { name: 'trustinWallet — accès' })).toBeVisible();
|
||||
await yvv.getByLabel('Pour qui').fill('Invitée');
|
||||
await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click();
|
||||
await expect(yvv.locator('.link code')).toContainText('/invitation#');
|
||||
});
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface.
|
||||
set -eu
|
||||
here=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
data=/tmp/ld-e2e
|
||||
rm -rf "$data"
|
||||
mkdir -p "$data"
|
||||
cd "$here"
|
||||
LD_DATA_DIR="$data" LD_SEED_DIR=seeds LD_WEB_DIR=web/build LD_BIND=127.0.0.1:8092 LD_DEV_LOGIN=1 \
|
||||
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS=http://127.0.0.1:9 \
|
||||
exec ./target/debug/ld-server
|
||||
Generated
+2195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "libredecision-web",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3002 --strictPort",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/literata": "^5.3.0",
|
||||
"@fontsource-variable/nunito": "^5.3.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.70.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@types/node": "^22.20.5",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// End-to-end controls — the usage tests of the binôme, automated once agreed.
|
||||
// The server is the real Rust binary on a throwaway database (e2e/serve.sh),
|
||||
// with the dev login; build it first: `cargo build -p ld-server` and `npm run build`.
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
use: { baseURL: 'http://127.0.0.1:8092', trace: 'retain-on-failure' },
|
||||
webServer: {
|
||||
command: 'sh e2e/serve.sh',
|
||||
url: 'http://127.0.0.1:8092/api/health',
|
||||
reuseExistingServer: false,
|
||||
timeout: 60_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1360, height: 900 }, locale: 'fr-FR' }, testIgnore: /mobile\.spec\.ts/ },
|
||||
{ name: 'mobile', use: { ...devices['Pixel 7'], locale: 'fr-FR' }, testMatch: /mobile\.spec\.ts/ },
|
||||
],
|
||||
});
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/* libreDecision v3 — tokens and base.
|
||||
Two moods: « pierre » (cool stone, light) and « nuit » (well at night, dark).
|
||||
Borderless: depth comes from background and shadow, never from strokes.
|
||||
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
|
||||
|
||||
@import '@fontsource-variable/nunito';
|
||||
@import '@fontsource-variable/literata/opsz.css';
|
||||
@import '@fontsource-variable/literata/opsz-italic.css';
|
||||
|
||||
:root,
|
||||
[data-mood='pierre'] {
|
||||
--bg: #e9eeec;
|
||||
--surface: #f6f8f7;
|
||||
--surface-2: #dfe6e3;
|
||||
--ink: #18282a;
|
||||
--ink-soft: #4e615f;
|
||||
--ink-faint: #7f908d;
|
||||
--water: #1f6f78;
|
||||
--water-light: #8cc3c4;
|
||||
--water-wash: rgb(31 111 120 / 0.08);
|
||||
--moss: #4c7a4b;
|
||||
--moss-wash: rgb(76 122 75 / 0.12);
|
||||
--ochre: #a8661e;
|
||||
--ochre-wash: rgb(168 102 30 / 0.12);
|
||||
--on-water: #f6f8f7;
|
||||
--shadow: 0 1px 2px rgb(20 40 40 / 0.06), 0 8px 28px rgb(20 40 40 / 0.07);
|
||||
--shadow-strong: 0 2px 6px rgb(20 40 40 / 0.1), 0 18px 48px rgb(20 40 40 / 0.16);
|
||||
--veil: rgb(16 30 31 / 0.28);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-mood='nuit'] {
|
||||
--bg: #0e1819;
|
||||
--surface: #142224;
|
||||
--surface-2: #1b2d2f;
|
||||
--ink: #e2ebe8;
|
||||
--ink-soft: #a2b4b0;
|
||||
--ink-faint: #6e827e;
|
||||
--water: #6ec1c6;
|
||||
--water-light: #2d6a70;
|
||||
--water-wash: rgb(110 193 198 / 0.08);
|
||||
--moss: #8dbf84;
|
||||
--moss-wash: rgb(141 191 132 / 0.12);
|
||||
--ochre: #e0a458;
|
||||
--ochre-wash: rgb(224 164 88 / 0.12);
|
||||
--on-water: #0e1819;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 28px rgb(0 0 0 / 0.28);
|
||||
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.4), 0 18px 48px rgb(0 0 0 / 0.5);
|
||||
--veil: rgb(0 0 0 / 0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif;
|
||||
--r-card: 16px;
|
||||
--r-input: 12px;
|
||||
--r-pill: 20px;
|
||||
--r-icon: 14px;
|
||||
--sidebar: 15.5rem;
|
||||
--panel: 27rem;
|
||||
--measure: 36rem;
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--water);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--water);
|
||||
outline-offset: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--water-light);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Buttons — native, custom. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
box-shadow: 0 6px 18px rgb(31 111 120 / 0.22);
|
||||
}
|
||||
.btn.quiet {
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
padding-inline: 0.6rem;
|
||||
}
|
||||
.btn.quiet:hover {
|
||||
background: var(--water-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn.small {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Fields — native, custom. */
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
padding: 0.6rem 0.8rem;
|
||||
min-height: 2.4rem;
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.06);
|
||||
}
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.65;
|
||||
min-height: 9rem;
|
||||
}
|
||||
.input:focus-visible,
|
||||
.textarea:focus-visible,
|
||||
.select:focus-visible {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag.water {
|
||||
background: var(--water-wash);
|
||||
color: var(--water);
|
||||
}
|
||||
.tag.moss {
|
||||
background: var(--moss-wash);
|
||||
color: var(--moss);
|
||||
}
|
||||
.tag.ochre {
|
||||
background: var(--ochre-wash);
|
||||
color: var(--ochre);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--water);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.faint {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* The texts of reference. */
|
||||
.prose {
|
||||
font-family: var(--font-text);
|
||||
font-size: clamp(1.04rem, 0.98rem + 0.25vw, 1.14rem);
|
||||
line-height: 1.72;
|
||||
font-variation-settings: 'opsz' 14;
|
||||
hyphens: auto;
|
||||
}
|
||||
.prose p + p,
|
||||
.prose p + ul,
|
||||
.prose ul + p {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin: 0.5em 0 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.prose li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.prose strong {
|
||||
font-weight: 650;
|
||||
}
|
||||
.prose code {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--surface-2);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
background: var(--moss-wash);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.diff-removed {
|
||||
background: var(--ochre-wash);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
declare global {
|
||||
const __LD_BUILD__: string;
|
||||
namespace App {}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<title>libreDecision</title>
|
||||
<script>
|
||||
// Mood before first paint: no flash.
|
||||
try {
|
||||
const m = localStorage.getItem('ld:mood');
|
||||
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre';
|
||||
} catch { document.documentElement.dataset.mood = 'pierre'; }
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
// The one door to the server. Mutations carry the anti-CSRF header the server
|
||||
// requires; errors come back as a stable code the interface translates.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'x-requested-with': 'libredecision' };
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, 'network');
|
||||
}
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new ApiError(res.status, (data && typeof data.error === 'string' && data.error) || 'internal');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => call<T>('GET', path),
|
||||
post: <T>(path: string, body: unknown = {}) => call<T>('POST', path, body),
|
||||
patch: <T>(path: string, body: unknown) => call<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body: unknown) => call<T>('PUT', path, body),
|
||||
del: <T>(path: string) => call<T>('DELETE', path),
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { plain } from '$lib/markdown';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
selected,
|
||||
onopen,
|
||||
}: { items: { unit: Unit; proposal: Proposal }[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
const variants = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
</script>
|
||||
|
||||
<ul class="deep">
|
||||
{#each items as { unit, proposal: p }, i (p.id)}
|
||||
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section}
|
||||
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(p, unit)}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if}
|
||||
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</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}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="faint none">{t('deep.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.deep {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0 -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
border: 0;
|
||||
border-radius: var(--r-card);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.section {
|
||||
margin: 1.6rem 0 0.3rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.section:first-child {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.none {
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { DiffPart } from '$lib/types';
|
||||
let { parts }: { parts: DiffPart[] } = $props();
|
||||
</script>
|
||||
|
||||
<p class="diff prose">
|
||||
{#each parts as part, i (i)}
|
||||
{#if part.op === 'added'}<ins class="diff-added">{part.text}</ins>{:else if part.op === 'removed'}<del class="diff-removed">{part.text}</del>{:else}<span>{part.text}</span>{/if}
|
||||
{/each}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.diff {
|
||||
white-space: pre-wrap;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import type { DocState, PanelState } from '$lib/doc.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import type { Lang } from '$lib/types';
|
||||
import Icon from './Icon.svelte';
|
||||
import ProposeView from './ProposeView.svelte';
|
||||
import VoteView from './VoteView.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
panel,
|
||||
lang,
|
||||
onchange,
|
||||
onclose,
|
||||
}: { doc: DocState; panel: PanelState; lang: Lang; onchange: (p: PanelState) => void; onclose: () => void } = $props();
|
||||
|
||||
const unit = $derived(doc.unit(panel.unitId));
|
||||
const versions = $derived(
|
||||
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
|
||||
);
|
||||
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
|
||||
const tabs = $derived(
|
||||
(
|
||||
[
|
||||
['vote', t('panel.vote'), !!voteId],
|
||||
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
|
||||
['propose', t('panel.propose'), true],
|
||||
] as const
|
||||
).filter(([, , show]) => show),
|
||||
);
|
||||
let body: HTMLElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
void panel.tab;
|
||||
void panel.proposalId;
|
||||
body?.scrollTo({ top: 0 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="panel card" aria-label={t('panel.vote')}>
|
||||
<div class="bar">
|
||||
<div class="tabs" role="tablist">
|
||||
{#each tabs as [key, label] (key)}
|
||||
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
<div class="body" bind:this={body}>
|
||||
{#if panel.tab === 'vote' && voteId}
|
||||
<VoteView
|
||||
{doc}
|
||||
proposalId={voteId}
|
||||
{lang}
|
||||
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
|
||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
||||
/>
|
||||
{:else if panel.tab === 'proposals' && unit}
|
||||
<ul class="versions">
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button type="button" class="version" class:current={v.id === voteId} onclick={() => onchange({ ...panel, proposalId: v.id, tab: 'vote' })}>
|
||||
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
||||
<span class="text">
|
||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<span class="title">{titleOf(v, unit)}</span>
|
||||
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{t('doc.action.propose')}</button>
|
||||
{:else if panel.tab === 'propose'}
|
||||
<ProposeView
|
||||
{doc}
|
||||
unitId={panel.unitId}
|
||||
draftId={panel.draftId ?? null}
|
||||
prefill={panel.prefill ?? null}
|
||||
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0.8rem;
|
||||
right: 0.8rem;
|
||||
bottom: 0.8rem;
|
||||
width: min(var(--panel), calc(100vw - 1.6rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 35;
|
||||
box-shadow: var(--shadow-strong);
|
||||
animation: enter 0.24s var(--ease);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
background: var(--surface-2);
|
||||
padding: 3px;
|
||||
border-radius: var(--r-pill);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 0.35rem 0.8rem;
|
||||
min-height: 2.1rem;
|
||||
font-weight: 750;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-icon);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 1.2rem 1.6rem;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.versions {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.version {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.version:hover,
|
||||
.version.current {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.text {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.title {
|
||||
font-weight: 750;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
@keyframes enter {
|
||||
from {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.panel {
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-height: 86dvh;
|
||||
border-radius: var(--r-card) var(--r-card) 0 0;
|
||||
animation-name: sheet;
|
||||
}
|
||||
@keyframes sheet {
|
||||
from {
|
||||
transform: translateY(24px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { mood } from '$lib/mood.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let body = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
export function open() {
|
||||
error = '';
|
||||
dialog?.showModal();
|
||||
}
|
||||
|
||||
async function send(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/feedback', {
|
||||
route: page.url.pathname + page.url.search,
|
||||
body,
|
||||
context: {
|
||||
lang: i18n.lang,
|
||||
mood: mood.current,
|
||||
viewport: `${innerWidth}x${innerHeight}`,
|
||||
build: __LD_BUILD__,
|
||||
},
|
||||
});
|
||||
body = '';
|
||||
dialog?.close();
|
||||
toasts.show(t('feedback.sent'));
|
||||
} catch (err) {
|
||||
error = t(`error.${err instanceof ApiError ? err.code : 'internal'}` as never);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class="dialog card" aria-labelledby="fb-title">
|
||||
<form onsubmit={send}>
|
||||
<h2 id="fb-title">{t('feedback.title')}</h2>
|
||||
<label class="field">
|
||||
<span>{t('feedback.prompt')}</span>
|
||||
<textarea class="textarea" bind:value={body} rows="6" maxlength="4000"></textarea>
|
||||
</label>
|
||||
<p class="faint small">{t('feedback.context')}</p>
|
||||
{#if error}<p class="err" role="alert">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
<button class="btn quiet" type="button" onclick={() => dialog?.close()}>{t('common.cancel')}</button>
|
||||
<button class="btn primary" type="submit" disabled={busy || !body.trim()}>{t('feedback.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.dialog {
|
||||
border: 0;
|
||||
padding: 1.5rem;
|
||||
width: min(92vw, 32rem);
|
||||
color: var(--ink);
|
||||
}
|
||||
.dialog::backdrop {
|
||||
background: var(--veil);
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.err {
|
||||
color: var(--ochre);
|
||||
font-weight: 650;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// The threshold formula, typeset with native MathML — no KaTeX needed.
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import type { Evaluation, Inertia, Tally } from '$lib/types';
|
||||
|
||||
let {
|
||||
electorate,
|
||||
source,
|
||||
tally,
|
||||
evaluation,
|
||||
inertia,
|
||||
presets,
|
||||
floor,
|
||||
base,
|
||||
}: {
|
||||
electorate: number;
|
||||
source: 'wot' | 'group';
|
||||
tally: Tally;
|
||||
evaluation: Evaluation | null;
|
||||
inertia: Inertia;
|
||||
presets: { key: Inertia; majorityPct: number; gradient: number }[];
|
||||
floor: number;
|
||||
base: number;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(presets.find((x) => x.key === inertia));
|
||||
</script>
|
||||
|
||||
<details class="formula">
|
||||
<summary>{t('formula.title')}</summary>
|
||||
<p class="muted">{t('formula.intro')}</p>
|
||||
<math display="block" class="math">
|
||||
<mrow>
|
||||
<mi>R</mi><mo>=</mo><mi>C</mi><mo>+</mo><msup><mi>B</mi><mi>W</mi></msup><mo>+</mo>
|
||||
<mrow><mo>(</mo><mi>M</mi><mo>+</mo><mo>(</mo><mn>1</mn><mo>−</mo><mi>M</mi><mo>)</mo><mo>(</mo><mn>1</mn><mo>−</mo>
|
||||
<msup><mrow><mo>(</mo><mfrac><mi>T</mi><mi>W</mi></mfrac><mo>)</mo></mrow><mi>G</mi></msup><mo>)</mo><mo>)</mo></mrow>
|
||||
<mo>·</mo><mi>max</mi><mo>(</mo><mn>0</mn><mo>,</mo><mi>T</mi><mo>−</mo><mi>C</mi><mo>)</mo>
|
||||
</mrow>
|
||||
</math>
|
||||
<ul class="values">
|
||||
<li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
|
||||
<li>{t('formula.T', { n: tally.for + tally.against })}</li>
|
||||
{#if p}
|
||||
<li>{t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}</li>
|
||||
<li>{t('formula.G', { n: p.gradient })}</li>
|
||||
{/if}
|
||||
<li>{t('formula.B', { n: base })}</li>
|
||||
<li>{t('formula.C', { n: floor })}</li>
|
||||
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
|
||||
</ul>
|
||||
<p class="faint">{t('formula.reading')}</p>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.formula {
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
padding: 0.7rem 0.9rem;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.formula[open] summary {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.formula > :not(summary) + :not(summary) {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.math {
|
||||
font-size: 1.05rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.values {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.r {
|
||||
font-weight: 800;
|
||||
}
|
||||
.faint {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
// Shown instead of a page that needs a session, or that failed to load.
|
||||
import { page } from '$app/state';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
|
||||
let { code, onretry }: { code: string; onretry?: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="guard">
|
||||
<p>{t(`error.${code}` as Key)}</p>
|
||||
{#if code === 'unauthenticated'}
|
||||
<a class="btn primary" href="/connexion?return={encodeURIComponent(page.url.pathname)}">{t('nav.signin')}</a>
|
||||
{:else if onretry && (code === 'network' || code === 'internal')}
|
||||
<button class="btn" type="button" onclick={onretry}>{t('error.retry')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guard {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
justify-items: start;
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
// A few line icons, drawn here rather than pulled from a library.
|
||||
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus'; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
{#if name === 'lock'}
|
||||
<rect x="5" y="11" width="14" height="9" rx="2.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||
{:else if name === 'close'}
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
{:else if name === 'menu'}
|
||||
<path d="M4 7h16M4 12h16M4 17h10" />
|
||||
{:else if name === 'moon'}
|
||||
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />
|
||||
{:else if name === 'sun'}
|
||||
<circle cx="12" cy="12" r="4" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" />
|
||||
{:else if name === 'chevron'}
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
{:else if name === 'message'}
|
||||
<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H10l-4 3v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z" />
|
||||
{:else if name === 'key'}
|
||||
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
|
||||
{:else if name === 'plus'}
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
|
||||
type Mode = 'replace' | 'remove' | 'new';
|
||||
type Hint =
|
||||
| { kind: 'split'; parts: string[] }
|
||||
| { kind: 'similar'; proposalId: string; unitId: string | null; title: string | null; inForce: boolean; score: number }
|
||||
| { kind: 'long'; chars: number };
|
||||
|
||||
let {
|
||||
doc,
|
||||
unitId,
|
||||
draftId = null,
|
||||
prefill = null,
|
||||
ondone,
|
||||
}: {
|
||||
doc: DocState;
|
||||
unitId: string | null;
|
||||
draftId?: string | null;
|
||||
prefill?: { title: string; body: string } | null;
|
||||
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
|
||||
} = $props();
|
||||
|
||||
const draft = $derived(doc.proposal(draftId));
|
||||
let target = $state<string | null>(null);
|
||||
let mode = $state<Mode>('replace');
|
||||
let after = $state<string>('');
|
||||
let title = $state('');
|
||||
let body = $state('');
|
||||
let hints = $state<Hint[]>([]);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let savedId = $state<string | null>(null);
|
||||
|
||||
const unit = $derived(doc.unit(target));
|
||||
const units = $derived(doc.view?.units ?? []);
|
||||
|
||||
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
||||
// which would wipe what the person is typing.
|
||||
$effect(() => {
|
||||
const [u0, d0, pf] = [unitId, draftId, prefill];
|
||||
untrack(() => {
|
||||
const u = doc.unit(u0);
|
||||
const d = doc.proposal(d0);
|
||||
target = u0;
|
||||
savedId = d0;
|
||||
mode = u0 ? 'replace' : 'new';
|
||||
after = doc.inForce.at(-1)?.id ?? '';
|
||||
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
||||
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
|
||||
error = '';
|
||||
});
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const text = `${title}\n${body}`;
|
||||
clearTimeout(timer);
|
||||
if (mode === 'remove' || body.trim().length < 20 || !doc.view) {
|
||||
hints = [];
|
||||
return;
|
||||
}
|
||||
const id = doc.view.doc.id;
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api.post<{ hints: Hint[] }>(`/docs/${id}/assist`, { text, proposalId: savedId });
|
||||
hints = r.hints.filter((h) => h.kind !== 'similar' || h.unitId !== target);
|
||||
} catch {
|
||||
hints = [];
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
|
||||
function message(e: unknown): string {
|
||||
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
||||
}
|
||||
|
||||
/** Create the draft, or update it; returns its id. */
|
||||
async function save(): Promise<string> {
|
||||
if (!doc.view) throw new ApiError(0, 'internal');
|
||||
if (savedId) {
|
||||
await api.patch(`/proposals/${savedId}`, { title, body });
|
||||
return savedId;
|
||||
}
|
||||
const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, {
|
||||
kind: mode === 'remove' ? 'remove' : 'text',
|
||||
unitId: mode === 'new' ? null : target,
|
||||
afterUnitId: mode === 'new' ? after || null : null,
|
||||
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
||||
body: mode === 'remove' ? '' : body,
|
||||
submit: false,
|
||||
});
|
||||
savedId = r.id;
|
||||
return r.id;
|
||||
}
|
||||
|
||||
async function keep() {
|
||||
busy = true;
|
||||
try {
|
||||
const id = await save();
|
||||
await doc.reload();
|
||||
toasts.show(t('propose.drafted'));
|
||||
ondone({ proposalId: id, submitted: false });
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
busy = true;
|
||||
try {
|
||||
const id = await save();
|
||||
await api.post(`/proposals/${id}/submit`);
|
||||
await doc.reload();
|
||||
toasts.show(t('propose.submitted'));
|
||||
ondone({ proposalId: id, submitted: true });
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function split(parts: string[]) {
|
||||
busy = true;
|
||||
try {
|
||||
const id = await save();
|
||||
await api.post(`/proposals/${id}/split`, { parts });
|
||||
await doc.reload();
|
||||
toasts.show(t('assist.split.done', { n: parts.length }));
|
||||
ondone({ proposalId: null, submitted: false });
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function variantOf(h: Extract<Hint, { kind: 'similar' }>) {
|
||||
if (!h.unitId || savedId) return;
|
||||
target = h.unitId;
|
||||
mode = 'replace';
|
||||
}
|
||||
|
||||
const canSubmit = $derived(mode === 'remove' || (title.trim().length > 0 && body.trim().length > 0));
|
||||
</script>
|
||||
|
||||
<div class="propose">
|
||||
{#if !draft}
|
||||
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
|
||||
{#if unit}
|
||||
<label class:on={mode === 'replace'}><input type="radio" bind:group={mode} value="replace" />{t('propose.mode.replace')}</label>
|
||||
{#if unit.inForce}<label class:on={mode === 'remove'}><input type="radio" bind:group={mode} value="remove" />{t('propose.mode.remove')}</label>{/if}
|
||||
{/if}
|
||||
<label class:on={mode === 'new'}><input type="radio" bind:group={mode} value="new" />{t('propose.mode.new')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'new'}
|
||||
<label class="field">
|
||||
<span>{t('propose.after')}</span>
|
||||
<select class="select" bind:value={after}>
|
||||
{#each units.filter((u) => u.inForce) as u (u.id)}<option value={u.id}>{u.inForce?.title}</option>{/each}
|
||||
<option value="">{t('propose.after.end')}</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'remove'}
|
||||
<p class="muted">{t('propose.remove.note')}</p>
|
||||
{:else}
|
||||
<label class="field"><span>{t('propose.title')}</span><input class="input" bind:value={title} maxlength="120" /></label>
|
||||
<label class="field"><span>{t('propose.body')}</span><textarea class="textarea" bind:value={body} rows="9" maxlength="4000"></textarea></label>
|
||||
|
||||
<section class="assist" aria-live="polite">
|
||||
<h4 class="eyebrow">{t('assist.title')}</h4>
|
||||
{#each hints as h, i (i)}
|
||||
{#if h.kind === 'split'}
|
||||
<p>{t('assist.split', { n: h.parts.length })}</p>
|
||||
<button class="btn small" type="button" disabled={busy} onclick={() => split(h.parts)}>{t('assist.split.do', { n: h.parts.length })}</button>
|
||||
{:else if h.kind === 'similar'}
|
||||
<p>{t('assist.similar', { title: h.title ?? '', where: h.inForce ? t('assist.similar.inForce') : '' })}</p>
|
||||
{#if h.unitId && !savedId}<button class="btn small" type="button" onclick={() => variantOf(h)}>{t('assist.similar.variant')}</button>{/if}
|
||||
{:else if h.kind === 'long'}
|
||||
<p>{t('assist.long', { n: h.chars })}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="faint">{t('assist.quiet')}</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<p class="faint small">{t('propose.rule')}</p>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" disabled={busy || !canSubmit} onclick={keep}>{t('propose.draft')}</button>
|
||||
<button class="btn primary" type="button" disabled={busy || !canSubmit} onclick={submit}>{t('propose.submit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.propose {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.modes label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modes label.on {
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.modes input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.modes label:has(input:focus-visible) {
|
||||
outline: 2px solid var(--water);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.assist {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--water-wash);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.assist .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { render } from '$lib/markdown';
|
||||
let { text, class: cls = '' }: { text: string; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<!-- render() escapes every character of the source before adding markup. -->
|
||||
<div class="prose {cls}">{@html render(text)}</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
// Hexagram 48 — 井 Tsing, The Well (Wilhelm). Water ☵ over wind/wood ☴.
|
||||
// True six-line figure, line 1 at the bottom: yin, yang, yang, yin, yang, yin.
|
||||
const lines: boolean[] = [false, true, true, false, true, false];
|
||||
const seg = 42;
|
||||
</script>
|
||||
|
||||
<svg class="seal" viewBox="0 0 112 105" role="img" aria-label="Hexagramme 48 — Le Puits">
|
||||
{#each lines as yang, i (i)}
|
||||
{@const y = 91 - i * 17}
|
||||
{#if yang}
|
||||
<rect x="6" {y} width="100" height="8" />
|
||||
{:else}
|
||||
<rect x="6" {y} width={seg} height="8" />
|
||||
<rect x={6 + seg + 16} {y} width={seg} height="8" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.seal {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 12px;
|
||||
width: 44px;
|
||||
height: auto;
|
||||
opacity: 0.26;
|
||||
fill: var(--water);
|
||||
filter: drop-shadow(1px 1px 0.5px rgb(0 0 0 / 0.25));
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { mood } from '$lib/mood.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import Stamp from './Stamp.svelte';
|
||||
|
||||
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
|
||||
|
||||
const group = $derived(page.params.group ?? null);
|
||||
const docsHref = $derived(
|
||||
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
|
||||
);
|
||||
const path = $derived(page.url.pathname);
|
||||
const tools = $derived([
|
||||
{ key: 'decisions', label: t('nav.decisions'), href: null },
|
||||
{ key: 'docs', label: t('nav.docs'), href: docsHref },
|
||||
{ key: 'mandats', label: t('nav.mandates'), href: null },
|
||||
{ key: 'protocoles', label: t('nav.protocols'), href: null },
|
||||
]);
|
||||
|
||||
async function signOut() {
|
||||
await session.logout();
|
||||
onnavigate?.();
|
||||
await goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label={t('nav.label')}>
|
||||
<a class="brand" href="/" onclick={() => onnavigate?.()}>
|
||||
<Stamp />
|
||||
<span class="word">libre<b>Decision</b></span>
|
||||
</a>
|
||||
|
||||
{#if session.me}
|
||||
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
|
||||
<span class="eyebrow">{t('nav.groups')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ul class="tools">
|
||||
{#each tools as tool (tool.key)}
|
||||
<li>
|
||||
{#if tool.href}
|
||||
<a
|
||||
href={tool.href}
|
||||
class="tool"
|
||||
class:current={path.includes('/docs')}
|
||||
aria-current={path.includes('/docs') ? 'page' : undefined}
|
||||
onclick={() => onnavigate?.()}>{tool.label}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
|
||||
{tool.label}<span class="soon">{t('nav.soon')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="foot">
|
||||
{#if session.me?.admin}
|
||||
<a class="line" href="/acces" class:current={path === '/acces'} onclick={() => onnavigate?.()}>
|
||||
<Icon name="key" size={16} />{t('nav.access')}<span class="faint hint">{t('nav.access.hint')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if session.me}
|
||||
<button class="line" type="button" onclick={onfeedback}><Icon name="message" size={16} />{t('nav.feedback')}</button>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="langs" role="group" aria-label={t('nav.lang')}>
|
||||
{#each LANGS as l (l)}
|
||||
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
onclick={() => mood.toggle()}
|
||||
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
||||
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
||||
>
|
||||
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if session.me}
|
||||
<div class="who">
|
||||
<span class="name">{session.me.displayName}</span>
|
||||
<button class="btn quiet small" type="button" onclick={signOut}>{t('nav.signout')}</button>
|
||||
</div>
|
||||
{:else if session.ready}
|
||||
<a class="btn primary small signin" href="/connexion" onclick={() => onnavigate?.()}>{t('nav.signin')}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
height: 100%;
|
||||
padding: 1.4rem 1rem 1.2rem;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.word {
|
||||
font-size: 1.08rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.word b {
|
||||
font-weight: 800;
|
||||
}
|
||||
.groups {
|
||||
padding: 0 0.6rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.groups.current .eyebrow {
|
||||
color: var(--ink);
|
||||
}
|
||||
.tools {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.tool {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: var(--r-input);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
a.tool:hover {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
a.tool.current {
|
||||
background: var(--water-wash);
|
||||
color: var(--water);
|
||||
}
|
||||
.tool.disabled {
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
.soon {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.foot {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 650;
|
||||
font-size: 0.92rem;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
}
|
||||
.line:hover,
|
||||
.line.current {
|
||||
background: var(--water-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
.hint {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
.langs {
|
||||
display: inline-flex;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px;
|
||||
}
|
||||
.langs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--r-pill);
|
||||
min-width: 2.25rem;
|
||||
min-height: 2rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.langs button.on {
|
||||
background: var(--surface);
|
||||
color: var(--water);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-icon);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--water-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.2rem 0 0.7rem;
|
||||
}
|
||||
.name {
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.signin {
|
||||
justify-self: stretch;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
|
||||
let { size = 2.2 }: { size?: number } = $props();
|
||||
const lines: boolean[] = [false, true, true, false, true, false];
|
||||
</script>
|
||||
|
||||
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
|
||||
<svg viewBox="0 0 112 105">
|
||||
{#each lines as yang, i (i)}
|
||||
{@const y = 91 - i * 17}
|
||||
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.stamp {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: var(--s);
|
||||
height: var(--s);
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--water-wash);
|
||||
transform: rotate(-10deg);
|
||||
transition: transform 0.2s var(--ease);
|
||||
}
|
||||
svg {
|
||||
width: 56%;
|
||||
fill: var(--water);
|
||||
}
|
||||
:global(a:hover) > .stamp {
|
||||
transform: rotate(-2deg) scale(1.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
</script>
|
||||
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
{#each toasts.list as toast (toast.id)}
|
||||
<button class="toast {toast.tone}" type="button" onclick={() => toasts.dismiss(toast.id)}>{toast.text}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.4rem;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
z-index: 60;
|
||||
width: min(92vw, 30rem);
|
||||
}
|
||||
.toast {
|
||||
border: 0;
|
||||
border-radius: var(--r-card);
|
||||
padding: 0.8rem 1.1rem;
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
box-shadow: var(--shadow-strong);
|
||||
animation: rise 0.35s var(--ease);
|
||||
}
|
||||
.toast.rise {
|
||||
background: var(--moss);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.toast.warn {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { Shown } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
|
||||
let {
|
||||
text,
|
||||
mark = 'unchanged',
|
||||
seedMinimal = false,
|
||||
enteredAt = null,
|
||||
rivals = 0,
|
||||
selected = false,
|
||||
actions = true,
|
||||
onaction,
|
||||
}: {
|
||||
text: Shown;
|
||||
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
|
||||
seedMinimal?: boolean;
|
||||
enteredAt?: string | null;
|
||||
rivals?: number;
|
||||
selected?: boolean;
|
||||
actions?: boolean;
|
||||
onaction: (tab: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="unit {mark}" class:selected>
|
||||
<header>
|
||||
<h2 class="eyebrow">{text.title}</h2>
|
||||
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
|
||||
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
|
||||
</header>
|
||||
<Prose text={text.body} />
|
||||
{#if actions}
|
||||
<footer class="actions">
|
||||
<span class="status faint">
|
||||
{#if seedMinimal}<span title={t('doc.seed.long')}>{t('doc.seed')} · 0 vote</span>
|
||||
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||
</span>
|
||||
<span class="links">
|
||||
<button type="button" onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.unit {
|
||||
position: relative;
|
||||
padding: 1.1rem 1.3rem 0.9rem;
|
||||
margin: 0 -1.3rem;
|
||||
border-radius: var(--r-card);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
.unit + :global(.unit) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.unit:hover,
|
||||
.unit:focus-within,
|
||||
.unit.selected {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
.removed :global(.prose) {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.added,
|
||||
.replaced {
|
||||
background: var(--moss-wash);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem 1rem;
|
||||
margin-top: 0.7rem;
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.unit:hover .links,
|
||||
.unit:focus-within .links,
|
||||
.unit.selected .links {
|
||||
opacity: 1;
|
||||
}
|
||||
.links button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
padding: 0.35rem 0.6rem;
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links button:hover {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.links {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { shown, type DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { titleOf } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
||||
import DiffText from './DiffText.svelte';
|
||||
import Formula from './Formula.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
proposalId,
|
||||
lang,
|
||||
onopen,
|
||||
onpropose,
|
||||
}: {
|
||||
doc: DocState;
|
||||
proposalId: string;
|
||||
lang: Lang;
|
||||
onopen: (proposalId: string) => void;
|
||||
onpropose: (prefill: { title: string; body: string }) => void;
|
||||
} = $props();
|
||||
|
||||
interface Detail {
|
||||
proposal: Proposal & { author: string | null };
|
||||
unit: { id: string; code: string; inertia: Inertia };
|
||||
inForce: { id: string; title: string; body: string } | null;
|
||||
diff: DiffPart[] | null;
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[];
|
||||
signals: {
|
||||
id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
||||
other: { id: string; title: string; status: string; code: string | null } | null;
|
||||
}[];
|
||||
electorate: number;
|
||||
voteFloor: number;
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
let busy = $state(false);
|
||||
let showDiff = $state(false);
|
||||
let error = $state('');
|
||||
let inertiaTarget = $state<Inertia | ''>('');
|
||||
let typoOpen = $state(false);
|
||||
let typoTitle = $state('');
|
||||
let typoBody = $state('');
|
||||
let typoSubstantive = $state(false);
|
||||
let trTitle = $state('');
|
||||
let trBody = $state('');
|
||||
let signalTarget = $state('');
|
||||
let signalReason = $state('');
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
||||
const p = $derived(detail?.proposal);
|
||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||
const text = $derived(p ? shown(p, lang, source) : null);
|
||||
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
|
||||
const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open'));
|
||||
const signalTargets = $derived(
|
||||
(view?.units ?? []).flatMap((u) =>
|
||||
[u.inForce, ...u.proposals]
|
||||
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
|
||||
.filter((x) => !(u.id === unit?.id))
|
||||
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
||||
),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
detail = await api.get<Detail>(`/proposals/${proposalId}`);
|
||||
error = '';
|
||||
const d = detail;
|
||||
typoTitle = d.proposal.title;
|
||||
typoBody = d.proposal.body;
|
||||
typoSubstantive = false;
|
||||
const tr = d.proposal.translations[lang];
|
||||
trTitle = tr?.title ?? d.proposal.title;
|
||||
trBody = tr?.body ?? d.proposal.body;
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void proposalId;
|
||||
void lang;
|
||||
void load();
|
||||
});
|
||||
|
||||
function message(e: unknown): string {
|
||||
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await doc.reload();
|
||||
await load();
|
||||
}
|
||||
|
||||
function announce(entered: Entered[]) {
|
||||
for (const e of entered) {
|
||||
const u = doc.unit(e.unitId);
|
||||
const title = u?.inForce?.title ?? u?.code ?? '';
|
||||
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
|
||||
toasts.show(t(key, { title }), 'rise', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
async function vote(choice: 'for' | 'against' | null) {
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
|
||||
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
||||
await refresh();
|
||||
announce(r.entered);
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function proposeInertia() {
|
||||
if (!unit || !inertiaTarget) return;
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget });
|
||||
inertiaTarget = '';
|
||||
await doc.reload();
|
||||
onopen(r.id);
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTypo(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
typoSubstantive = false;
|
||||
try {
|
||||
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
|
||||
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
||||
typoOpen = false;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.code === 'substantive_in_force' || err.code === 'substantive_not_author')) {
|
||||
typoSubstantive = true;
|
||||
} else {
|
||||
error = message(err);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTranslation(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
||||
toasts.show(t('translate.saved'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSignal(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
|
||||
signalTarget = '';
|
||||
signalReason = '';
|
||||
toasts.show(t('signal.sent'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function lift(id: string) {
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
||||
await refresh();
|
||||
announce(r.entered);
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw() {
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/proposals/${proposalId}/withdraw`);
|
||||
toasts.show(t('propose.withdrawn'));
|
||||
await doc.reload();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if detail && p && text && view}
|
||||
<article class="vote">
|
||||
<header>
|
||||
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
|
||||
<p class="meta faint">
|
||||
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
||||
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||
{#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if p.kind === 'text'}
|
||||
{#if detail.diff && showDiff}
|
||||
<DiffText parts={detail.diff} />
|
||||
{:else}
|
||||
<Prose text={text.body} />
|
||||
{/if}
|
||||
{#if detail.diff}
|
||||
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
|
||||
{/if}
|
||||
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
||||
{:else if p.kind === 'remove' && detail.inForce}
|
||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||
{/if}
|
||||
|
||||
<section class="box">
|
||||
<div class="level">
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
||||
<div class="numbers">
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
||||
{#if p.evaluation}
|
||||
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||
{/if}
|
||||
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||
|
||||
{#if votable}
|
||||
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
|
||||
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
||||
<span class="label">{t('vote.yours')}</span>
|
||||
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
|
||||
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
|
||||
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
|
||||
</div>
|
||||
<p class="faint small">{t('vote.private')}</p>
|
||||
{:else}
|
||||
<p class="muted">{t('vote.closed')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if p.kind !== 'inertia' && unit}
|
||||
<section class="sub">
|
||||
<h4>{t('inertia.title')} · <span class="muted">{t(`inertia.level.${unit.inertia}`)}</span></h4>
|
||||
<p class="faint small">{t('inertia.explain')}</p>
|
||||
{#each inertiaOpen as ip (ip.id)}
|
||||
<button class="link" type="button" onclick={() => onopen(ip.id)}>{t('inertia.change.open', { level: t(`inertia.level.${ip.inertiaTarget ?? 'standard'}`) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}</button>
|
||||
{/each}
|
||||
<details>
|
||||
<summary>{t('inertia.change')}</summary>
|
||||
<div class="row">
|
||||
<select class="select" bind:value={inertiaTarget} aria-label={t('inertia.change.to')}>
|
||||
<option value="" disabled>{t('inertia.change.to')}</option>
|
||||
{#each ['low', 'standard', 'high', 'max'] as const as level (level)}
|
||||
{#if level !== unit.inertia}<option value={level}>{t(`inertia.level.${level}`)}</option>{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn small" type="button" disabled={!inertiaTarget || busy} onclick={proposeInertia}>{t('inertia.change.submit')}</button>
|
||||
</div>
|
||||
<p class="faint small">{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}</p>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} presets={view.inertia} floor={detail.voteFloor} base={view.doc.formula.base} />
|
||||
|
||||
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||
<section class="sub">
|
||||
<details bind:open={typoOpen}>
|
||||
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
||||
<form class="form" onsubmit={saveTypo}>
|
||||
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
|
||||
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
{#if typoSubstantive}
|
||||
<p class="warn">{t('typo.substantive')}</p>
|
||||
<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>
|
||||
{/if}
|
||||
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if lang !== source && p.kind === 'text'}
|
||||
<section class="sub">
|
||||
<details>
|
||||
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
|
||||
<form class="form" onsubmit={saveTranslation}>
|
||||
<p class="faint small">{t('translate.note')}</p>
|
||||
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.history.length > 1}
|
||||
<section class="sub">
|
||||
<h4>{t('history.title')}</h4>
|
||||
<ol class="history">
|
||||
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
||||
<li>
|
||||
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}</p>
|
||||
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if p.kind === 'text' && votable}
|
||||
<section class="sub">
|
||||
<h4>{t('signal.title')}</h4>
|
||||
{#each detail.signals as s (s.id)}
|
||||
<div class="signal" class:lifted={s.liftedAt}>
|
||||
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
|
||||
<p class="faint small">
|
||||
{t('signal.by', { name: s.author })} · {i18n.date(s.at)}
|
||||
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
|
||||
</p>
|
||||
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="faint small">{t('signal.none')}</p>
|
||||
{/each}
|
||||
<details>
|
||||
<summary>{t('signal.add')}</summary>
|
||||
<form class="form" onsubmit={sendSignal}>
|
||||
<p class="faint small">{t('signal.note')}</p>
|
||||
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
|
||||
<option value="" disabled>{t('signal.target')}</option>
|
||||
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
||||
</select>
|
||||
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if p.mine && p.status === 'open'}
|
||||
<button class="btn quiet small" type="button" disabled={busy} onclick={withdraw}>{t('propose.withdraw')}</button>
|
||||
{/if}
|
||||
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</article>
|
||||
{:else if error}
|
||||
<p class="warn" role="alert">{error}</p>
|
||||
{:else}
|
||||
<p class="faint">{t('common.loading')}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.vote {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.note,
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.removed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.box {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.level {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.numbers {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.tally {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state {
|
||||
font-weight: 700;
|
||||
}
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
.btn.on {
|
||||
background: var(--moss);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.btn.on-against {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sub {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
details > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.form .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.textarea.short {
|
||||
min-height: 5rem;
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--water);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.history {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.signal {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--ochre-wash);
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.signal.lifted {
|
||||
background: var(--surface-2);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.signal .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
// The signature of this tool: a proposal rises toward the surface as its
|
||||
// « for » votes approach the threshold; reached, it stands full, in moss.
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import type { Evaluation, Tally } from '$lib/types';
|
||||
|
||||
let {
|
||||
tally,
|
||||
evaluation,
|
||||
blocked = false,
|
||||
size = 'row',
|
||||
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'row' | 'large' } = $props();
|
||||
|
||||
const ratio = $derived(
|
||||
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0,
|
||||
);
|
||||
const pct = $derived(Math.round(ratio * 100));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="level {size}"
|
||||
class:reached={evaluation?.reached}
|
||||
class:blocked
|
||||
role="img"
|
||||
aria-label={t('tally.level', { pct })}
|
||||
title={t('tally.level', { pct })}
|
||||
>
|
||||
<span class="water" style:height="{Math.max(pct, tally.for > 0 ? 6 : 0)}%"></span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.level {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 2.4rem;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.level.large {
|
||||
width: 14px;
|
||||
height: 5.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.water {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
background: linear-gradient(to top, var(--water), color-mix(in srgb, var(--water) 55%, var(--water-light)));
|
||||
transition: height 0.7s var(--ease);
|
||||
}
|
||||
.reached .water {
|
||||
height: 100% !important;
|
||||
background: var(--moss);
|
||||
}
|
||||
.blocked {
|
||||
box-shadow: inset 0 0 0 2px var(--ochre);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
// The surface of the well: above, the text in force; below, what may rise.
|
||||
</script>
|
||||
|
||||
<div class="surface" aria-hidden="true">
|
||||
<svg viewBox="0 0 600 14" preserveAspectRatio="none">
|
||||
<path d="M0 7 C 25 1, 50 1, 75 7 S 125 13, 150 7 S 200 1, 225 7 S 275 13, 300 7 S 350 1, 375 7 S 425 13, 450 7 S 500 1, 525 7 S 575 13, 600 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.surface {
|
||||
margin: 3.5rem 0 0;
|
||||
height: 14px;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: var(--water-light);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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.
|
||||
|
||||
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';
|
||||
export type Tab = 'vote' | 'proposals' | 'propose';
|
||||
|
||||
export interface PanelState {
|
||||
unitId: string | null;
|
||||
proposalId: string | null;
|
||||
tab: Tab;
|
||||
draftId?: 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LangMap } from './types';
|
||||
|
||||
export interface GroupView {
|
||||
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
|
||||
tools: { key: string; active: boolean }[];
|
||||
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { Messages } from './fr';
|
||||
|
||||
export const en: Messages = {
|
||||
'app.name': 'libreDecision',
|
||||
'app.skip': 'Skip to content',
|
||||
|
||||
'nav.label': 'Toolbox',
|
||||
'nav.home': 'Home',
|
||||
'nav.decisions': 'Decisions',
|
||||
'nav.docs': 'Reference documents',
|
||||
'nav.mandates': 'Mandates',
|
||||
'nav.protocols': 'Protocols',
|
||||
'nav.soon': 'coming soon',
|
||||
'nav.groups': 'My groups',
|
||||
'nav.access': 'Access',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Give feedback',
|
||||
'nav.signin': 'Sign in',
|
||||
'nav.signout': 'Sign out',
|
||||
'nav.mood.toNight': 'Night mood',
|
||||
'nav.mood.toStone': 'Stone mood',
|
||||
'nav.lang': 'Language',
|
||||
'nav.menu': 'Menu',
|
||||
'nav.close': 'Close',
|
||||
|
||||
'home.title': 'Making collective decisions possible',
|
||||
'home.subtitle': 'depending on the context, processes and methods, a toolbox',
|
||||
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
|
||||
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
|
||||
'home.mandates': 'Create and revoke any kind of mandate.',
|
||||
'home.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
|
||||
'home.open': 'open',
|
||||
'home.soon': 'coming soon',
|
||||
'home.signin': 'Sign in with your Ğ1 key',
|
||||
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
|
||||
'home.continue': 'Open my groups',
|
||||
|
||||
'signin.title': 'Sign in',
|
||||
'signin.g1': 'Sign in with Ğ1',
|
||||
'signin.g1.how': "You'll be sent to connect.monnaie-libre.fr, the sign-in service of the Monnaie libre forum. There you approve the sign-in in Ğecko (link or QR code), then come back here. No password and no key is ever passed to us.",
|
||||
'signin.g1.members': 'For members of the Ğ1 web of trust only.',
|
||||
'signin.g1.pending': "Ğ1 sign-in is on its way: libreDecision's registration with connect.monnaie-libre.fr is in progress. Meanwhile, you get in with an invitation link.",
|
||||
'signin.invite': 'Got an invitation link? Just open it: it signs you in.',
|
||||
'signin.dev': 'Development sign-in',
|
||||
'signin.dev.name': 'Name',
|
||||
'signin.dev.admin': 'access administrator',
|
||||
'signin.dev.go': 'Enter',
|
||||
'signin.error.refus': 'The sign-in was declined on connect.monnaie-libre.fr.',
|
||||
'signin.error.etat': 'The sign-in expired or came from another browser. Start again from this browser.',
|
||||
'signin.error.incomplet': 'The response from connect.monnaie-libre.fr was incomplete. Start again.',
|
||||
'signin.error.echange': "connect.monnaie-libre.fr isn't responding. Try again in a moment.",
|
||||
'signin.error.interne': 'Sign-in failed on our side. Try again in a moment.',
|
||||
|
||||
'invite.title': 'Invitation',
|
||||
'invite.working': 'Signing in…',
|
||||
'invite.invalid': 'This invitation link is no longer valid: it has already been used or it has expired. Ask the person who sent it to you for a new link.',
|
||||
'invite.missing': 'This link is incomplete. Open the whole invitation link, exactly as you received it.',
|
||||
|
||||
'groups.title': 'My groups',
|
||||
'groups.same.key': 'Groups that use the same key',
|
||||
'groups.locked': 'Members only',
|
||||
'groups.locked.long': 'This group opens to its members. Ask a trustinWallet administrator for access.',
|
||||
'groups.test': 'For testing',
|
||||
'groups.open': 'Open',
|
||||
'groups.empty': 'No group is visible yet.',
|
||||
|
||||
'group.tools': 'Tools',
|
||||
'group.tool.soon': 'This tool is coming later.',
|
||||
'group.docs.empty': 'No reference documents in this group yet.',
|
||||
|
||||
'docs.title': 'Reference documents',
|
||||
'docs.inForce.one': '{n} article in force',
|
||||
'docs.inForce.other': '{n} articles in force',
|
||||
'docs.open.one': '{n} open proposal',
|
||||
'docs.open.other': '{n} open proposals',
|
||||
|
||||
'doc.view.label': 'Read',
|
||||
'doc.view.inforce': 'In force',
|
||||
'doc.view.wellrated': 'If well-rated ones pass',
|
||||
'doc.view.all': 'If everything passes',
|
||||
'doc.view.wellrated.note': 'Preview: the text as it would be if the well-rated proposals came into force. Nothing is adopted.',
|
||||
'doc.view.all.note': 'Preview: the text as it would be if all proposals came into force, even those with little support. Nothing is adopted.',
|
||||
'doc.view.empty': 'No proposal changes the text in this preview.',
|
||||
'doc.mark.added': 'added',
|
||||
'doc.mark.replaced': 'changed',
|
||||
'doc.mark.removed': 'removed',
|
||||
'doc.lang.label': 'Text language',
|
||||
'doc.lang.translation': 'Editorial translation, not voted on. The authoritative text is in French.',
|
||||
'doc.lang.original': 'Read the original',
|
||||
'doc.lang.stale': 'translation needs review',
|
||||
'doc.lang.missing': 'not translated yet',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': 'Came into force without a vote, so as not to start from a blank page. It remains open for voting: 0 votes so far.',
|
||||
'doc.confirmed': 'confirmed by vote',
|
||||
'doc.adopted': 'adopted on {date}',
|
||||
'doc.action.vote': 'Voting',
|
||||
'doc.action.proposals': 'Proposals',
|
||||
'doc.action.propose': 'Propose a change',
|
||||
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
|
||||
'doc.published': 'Published to the text repository',
|
||||
'doc.publish.pending': 'Publication pending',
|
||||
|
||||
'deep.title': 'Open proposals',
|
||||
'deep.note': 'Not in force yet. Each one moves up as soon as it reaches its threshold: at least {floor} votes in favor, then the majority its inertia requires.',
|
||||
'deep.new': 'Propose a new article',
|
||||
'deep.drafts': 'Your drafts',
|
||||
'deep.drafts.note': 'Not submitted to vote. Only you can see them.',
|
||||
'deep.rival': 'would replace “{title}”',
|
||||
'deep.addition': 'new article',
|
||||
'deep.removal': 'removal of “{title}”',
|
||||
'deep.inertia': 'inertia: {level}',
|
||||
'deep.blocked': 'blocked',
|
||||
'deep.mine': 'your proposal',
|
||||
'deep.stale': 'needs reconfirming',
|
||||
'deep.wellrated': 'well-rated',
|
||||
'deep.filter.all': 'All',
|
||||
'deep.filter.wellrated': 'Well-rated',
|
||||
'deep.filter.mine': 'Mine',
|
||||
'deep.none': 'No proposals in this filter.',
|
||||
'deep.variant': 'variant {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Fundamental commitments',
|
||||
'section.technique': 'Technical commitments',
|
||||
'section.conclusion': 'Conclusion',
|
||||
'section.annexe': 'Annexes',
|
||||
'section.formule': 'Voting formula',
|
||||
'section.inertie': 'Inertia',
|
||||
'section.ordonnancement': 'Ordering',
|
||||
|
||||
'tally.short': '{for} for · {against} against',
|
||||
'tally.required': 'Threshold: {n} votes in favor',
|
||||
'tally.missing.one': '{n} more vote in favor needed.',
|
||||
'tally.missing.other': '{n} more votes in favor needed.',
|
||||
'tally.reached': 'Threshold reached.',
|
||||
'tally.level': 'Level: {pct}% of the threshold',
|
||||
|
||||
'inertia.low': 'low inertia',
|
||||
'inertia.standard': 'standard inertia',
|
||||
'inertia.high': 'high inertia',
|
||||
'inertia.max': 'maximum inertia',
|
||||
'inertia.level.low': 'low',
|
||||
'inertia.level.standard': 'standard',
|
||||
'inertia.level.high': 'high',
|
||||
'inertia.level.max': 'maximum',
|
||||
|
||||
'panel.vote': 'Voting',
|
||||
'panel.proposals': 'Proposals',
|
||||
'panel.propose': 'Propose',
|
||||
'panel.close': 'Close panel',
|
||||
'panel.inforce': 'In force',
|
||||
'panel.proposal': 'Proposal',
|
||||
'panel.by': 'proposed by {name}',
|
||||
'panel.seed.origin': 'text from the seed',
|
||||
'panel.changes': 'View changes',
|
||||
'panel.text': 'View text',
|
||||
|
||||
'vote.yours': 'Your vote',
|
||||
'vote.for': 'For',
|
||||
'vote.against': 'Against',
|
||||
'vote.retract': 'Withdraw my vote',
|
||||
'vote.saved': 'Vote saved.',
|
||||
'vote.retracted': 'Vote withdrawn.',
|
||||
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
||||
'vote.reconfirm': 'Reconfirm: {choice}',
|
||||
'vote.private': 'Nobody sees your vote: only the totals are visible.',
|
||||
'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.",
|
||||
'vote.entered': '“{title}” has come into force.',
|
||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||
'vote.closed': 'This version is no longer open for voting.',
|
||||
|
||||
'inertia.title': 'Inertia',
|
||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||
'inertia.change': 'Propose an inertia change',
|
||||
'inertia.change.to': 'New inertia',
|
||||
'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.',
|
||||
'inertia.change.submit': 'Submit to vote',
|
||||
'inertia.change.open': 'Inertia change in progress: to {level}',
|
||||
|
||||
'formula.title': 'The threshold formula',
|
||||
'formula.intro': 'The number of votes in favor required, R, depends on turnout:',
|
||||
'formula.W': 'W — electorate: {n} ({source})',
|
||||
'formula.W.wot': 'members of the Ğ1 web of trust',
|
||||
'formula.W.group': 'group members',
|
||||
'formula.T': 'T — votes cast: {n}',
|
||||
'formula.M': 'M — majority at full turnout: {n}%',
|
||||
'formula.G': 'G — gradient: {n}',
|
||||
'formula.B': 'B — base: {n}',
|
||||
'formula.C': 'C — minimum votes in favor: {n}',
|
||||
'formula.R': 'R — current threshold: {n} votes in favor',
|
||||
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.',
|
||||
|
||||
'history.title': 'Corrections',
|
||||
'history.initial': 'initial text',
|
||||
'history.typo': 'typo fixed',
|
||||
'history.substantive': 'substantive change (votes to reconfirm)',
|
||||
'history.by': 'by {name}',
|
||||
|
||||
'typo.title': 'Fix a typo',
|
||||
'typo.note': 'Accents, punctuation, typos: the fix applies without a vote, with its author and its diff. A change in meaning goes through a proposal.',
|
||||
'typo.save': 'Save the fix',
|
||||
'typo.done': 'Typo fixed, votes untouched.',
|
||||
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
|
||||
'typo.asProposal': 'Make it a proposal',
|
||||
|
||||
'signal.title': 'Contradictions',
|
||||
'signal.none': 'No flags.',
|
||||
'signal.add': 'Flag a contradiction',
|
||||
'signal.target': 'Contradicts',
|
||||
'signal.reason': 'How these two texts contradict each other',
|
||||
'signal.send': 'Flag',
|
||||
'signal.sent': 'Flag saved.',
|
||||
'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.",
|
||||
'signal.lift': 'Lift',
|
||||
'signal.lifted': 'lifted',
|
||||
'signal.lifted.revision': 'lapsed: the text changed',
|
||||
'signal.by': 'by {name}',
|
||||
|
||||
'propose.mode.replace': 'Change this text',
|
||||
'propose.mode.remove': 'Remove this article',
|
||||
'propose.mode.new': 'New article',
|
||||
'propose.after': 'Place after',
|
||||
'propose.after.end': 'at the end',
|
||||
'propose.title': 'Title',
|
||||
'propose.body': 'Text',
|
||||
'propose.remove.note': 'A removal proposal is voted on like any other; if it passes, the article leaves the text in force.',
|
||||
'propose.draft': 'Save draft',
|
||||
'propose.submit': 'Submit to vote',
|
||||
'propose.drafted': 'Draft saved.',
|
||||
'propose.submitted': 'Proposal submitted to vote.',
|
||||
'propose.rule': "While it's a draft, you edit it freely. Once it's submitted, a typo can be fixed without touching the votes; a substantive change asks voters to reconfirm.",
|
||||
'propose.edit': 'Edit',
|
||||
'propose.withdraw': 'Withdraw my proposal',
|
||||
'propose.withdrawn': 'Proposal withdrawn.',
|
||||
'propose.saved.typo': 'Fix saved, the votes stand.',
|
||||
'propose.saved.substantive': 'New version saved: voters will have to reconfirm.',
|
||||
|
||||
'assist.title': 'Assistant',
|
||||
'assist.quiet': 'Nothing to report: one proposal, one decision.',
|
||||
'assist.split': 'This text seems to carry {n} decisions. One proposal = one decision: split it?',
|
||||
'assist.split.do': 'Split into {n} drafts',
|
||||
'assist.split.done': 'Split into {n} drafts.',
|
||||
'assist.similar': 'Close to “{title}”{where}.',
|
||||
'assist.similar.inForce': ' (in force)',
|
||||
'assist.similar.variant': 'Make it a variant of this article',
|
||||
'assist.long': 'Long text ({n} characters): a short article is easier to vote on.',
|
||||
|
||||
'translate.title': 'Translate ({lang})',
|
||||
'translate.save': 'Save translation',
|
||||
'translate.saved': 'Translation saved.',
|
||||
'translate.note': 'Translations are not voted on. They switch to “needs review” when the French text changes in substance.',
|
||||
|
||||
'feedback.title': 'Give feedback',
|
||||
'feedback.prompt': 'What you did, what you expected, what happened.',
|
||||
'feedback.send': 'Send feedback',
|
||||
'feedback.sent': 'Feedback sent. Thank you.',
|
||||
'feedback.context': 'The page address, language and screen size are attached.',
|
||||
|
||||
'access.title': 'trustinWallet — access',
|
||||
'access.intro': 'Who opens which group. Monnaie libre opens by itself to members of the Ğ1 web of trust; other groups open one by one.',
|
||||
'access.groups': 'Groups',
|
||||
'access.members.none': 'No members granted access.',
|
||||
'access.members.wot': '+ all members of the Ğ1 web of trust',
|
||||
'access.grant': 'Grant',
|
||||
'access.revoke': 'Remove',
|
||||
'access.choose': 'Choose an identity',
|
||||
'access.invitations': 'Invitations',
|
||||
'access.invite.label': "Who it's for",
|
||||
'access.invite.groups': 'Groups opened by the link',
|
||||
'access.invite.admin': 'access administrator',
|
||||
'access.invite.create': 'Create invitation link',
|
||||
'access.invite.link': "Invitation link, single use, valid for 14 days. It won't be shown again: copy it now.",
|
||||
'access.invite.copy': 'Copy link',
|
||||
'access.invite.copied': 'Link copied.',
|
||||
'access.invite.cancel': 'Cancel',
|
||||
'access.invite.pending': 'Pending',
|
||||
'access.identities': 'Identities',
|
||||
'access.admin': 'administrator',
|
||||
'access.admin.give': 'Make administrator',
|
||||
'access.admin.take': 'Remove role',
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitation',
|
||||
'access.kind.dev': 'dev',
|
||||
'access.member': 'web of trust member',
|
||||
'access.feedback': 'Feedback',
|
||||
'access.feedback.none': 'No feedback.',
|
||||
'access.feedback.seen': 'Seen',
|
||||
'access.feedback.done': 'Handled',
|
||||
'access.denied': 'Access management is for trustinWallet administrators only.',
|
||||
|
||||
'soon.title': 'Coming soon',
|
||||
'soon.body': 'This tool is coming later. Tools open one at a time: reference documents first.',
|
||||
|
||||
'error.unauthenticated': 'Sign in to continue.',
|
||||
'error.forbidden': "You don't have access to this page.",
|
||||
'error.not_found': "This page doesn't exist, or isn't visible to you.",
|
||||
'error.network': "The server isn't responding. Check your connection and try again.",
|
||||
'error.internal': 'Something failed on our side. Try again in a moment.',
|
||||
'error.title': 'The title is empty or too long (120 characters max).',
|
||||
'error.body': 'The text is empty or too long (4,000 characters max).',
|
||||
'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).',
|
||||
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
|
||||
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
|
||||
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
|
||||
'error.last_admin': 'At least one administrator must remain.',
|
||||
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
||||
'error.invitation_invalid': 'This invitation link is no longer valid.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.save': 'Save',
|
||||
'common.loading': 'Loading…',
|
||||
'common.back': 'Back',
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { Messages } from './fr';
|
||||
|
||||
export const es: Messages = {
|
||||
'app.name': 'libreDecision',
|
||||
'app.skip': 'Ir al contenido',
|
||||
|
||||
'nav.label': 'Caja de herramientas',
|
||||
'nav.home': 'Inicio',
|
||||
'nav.decisions': 'Decisiones',
|
||||
'nav.docs': 'Documentos de referencia',
|
||||
'nav.mandates': 'Mandatos',
|
||||
'nav.protocols': 'Protocolos',
|
||||
'nav.soon': 'próximamente',
|
||||
'nav.groups': 'Mis grupos',
|
||||
'nav.access': 'Accesos',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Dejar un comentario',
|
||||
'nav.signin': 'Iniciar sesión',
|
||||
'nav.signout': 'Cerrar sesión',
|
||||
'nav.mood.toNight': 'Ambiente noche',
|
||||
'nav.mood.toStone': 'Ambiente piedra',
|
||||
'nav.lang': 'Idioma',
|
||||
'nav.menu': 'Menú',
|
||||
'nav.close': 'Cerrar',
|
||||
|
||||
'home.title': 'Hacer posible la decisión colectiva',
|
||||
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas',
|
||||
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
|
||||
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
|
||||
'home.mandates': 'Crear y revocar todo tipo de mandato.',
|
||||
'home.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
|
||||
'home.open': 'abierto',
|
||||
'home.soon': 'próximamente',
|
||||
'home.signin': 'Iniciar sesión con tu clave Ğ1',
|
||||
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
|
||||
'home.continue': 'Abrir mis grupos',
|
||||
|
||||
'signin.title': 'Iniciar sesión',
|
||||
'signin.g1': 'Iniciar sesión con Ğ1',
|
||||
'signin.g1.how': 'Se te redirige a connect.monnaie-libre.fr, el servicio de conexión del foro Monnaie libre. Allí apruebas la conexión en Ğecko (enlace o código QR) y luego vuelves aquí. No se nos transmite ninguna contraseña ni ninguna clave.',
|
||||
'signin.g1.members': 'Reservado a los miembros de la red de confianza Ğ1.',
|
||||
'signin.g1.pending': 'La conexión Ğ1 llega pronto: el registro de libreDecision en connect.monnaie-libre.fr está en curso. Mientras tanto, se entra con un enlace de invitación.',
|
||||
'signin.invite': '¿Has recibido un enlace de invitación? Ábrelo sin más: te conecta.',
|
||||
'signin.dev': 'Conexión de desarrollo',
|
||||
'signin.dev.name': 'Nombre',
|
||||
'signin.dev.admin': 'administrador de accesos',
|
||||
'signin.dev.go': 'Entrar',
|
||||
'signin.error.refus': 'La conexión fue rechazada en connect.monnaie-libre.fr.',
|
||||
'signin.error.etat': 'La conexión ha caducado o viene de otro navegador. Vuelve a empezar desde este navegador.',
|
||||
'signin.error.incomplet': 'La respuesta de connect.monnaie-libre.fr llegó incompleta. Vuelve a empezar.',
|
||||
'signin.error.echange': 'connect.monnaie-libre.fr no responde. Inténtalo de nuevo en un momento.',
|
||||
'signin.error.interne': 'La conexión ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
|
||||
|
||||
'invite.title': 'Invitación',
|
||||
'invite.working': 'Conectando…',
|
||||
'invite.invalid': 'Este enlace de invitación ya no es válido: ya se ha usado o ha caducado. Pide un enlace nuevo a la persona que te lo envió.',
|
||||
'invite.missing': 'Este enlace está incompleto. Abre el enlace de invitación completo, tal como lo recibiste.',
|
||||
|
||||
'groups.title': 'Mis grupos',
|
||||
'groups.same.key': 'Grupos que usan la misma clave',
|
||||
'groups.locked': 'Reservado a sus miembros',
|
||||
'groups.locked.long': 'Este grupo se abre a sus miembros. El acceso se pide a un administrador de trustinWallet.',
|
||||
'groups.test': 'Para pruebas',
|
||||
'groups.open': 'Abrir',
|
||||
'groups.empty': 'Por ahora no hay ningún grupo visible.',
|
||||
|
||||
'group.tools': 'Herramientas',
|
||||
'group.tool.soon': 'Esta herramienta llegará más adelante.',
|
||||
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.',
|
||||
|
||||
'docs.title': 'Documentos de referencia',
|
||||
'docs.inForce.one': '{n} artículo en vigor',
|
||||
'docs.inForce.other': '{n} artículos en vigor',
|
||||
'docs.open.one': '{n} propuesta en curso',
|
||||
'docs.open.other': '{n} propuestas en curso',
|
||||
|
||||
'doc.view.label': 'Leer',
|
||||
'doc.view.inforce': 'En vigor',
|
||||
'doc.view.wellrated': 'Si pasan las bien valoradas',
|
||||
'doc.view.all': 'Si todo pasa',
|
||||
'doc.view.wellrated.note': 'Vista previa: el texto tal como quedaría si las propuestas bien valoradas entraran en vigor. No se adopta nada.',
|
||||
'doc.view.all.note': 'Vista previa: el texto tal como quedaría si todas las propuestas entraran en vigor, incluso las poco apoyadas. No se adopta nada.',
|
||||
'doc.view.empty': 'Ninguna propuesta cambia el texto en esta vista previa.',
|
||||
'doc.mark.added': 'añadido',
|
||||
'doc.mark.replaced': 'modificado',
|
||||
'doc.mark.removed': 'retirado',
|
||||
'doc.lang.label': 'Idioma del texto',
|
||||
'doc.lang.translation': 'Traducción editorial, sin votación. El texto que hace fe está en francés.',
|
||||
'doc.lang.original': 'Leer el original',
|
||||
'doc.lang.stale': 'traducción por revisar',
|
||||
'doc.lang.missing': 'aún sin traducir',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': 'Entró en vigor sin votación, para no partir de una página en blanco. Sigue en votación: 0 votos por ahora.',
|
||||
'doc.confirmed': 'confirmado por la votación',
|
||||
'doc.adopted': 'adoptado el {date}',
|
||||
'doc.action.vote': 'Votación',
|
||||
'doc.action.proposals': 'Propuestas',
|
||||
'doc.action.propose': 'Proponer una modificación',
|
||||
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
|
||||
'doc.published': 'Publicado en el repositorio de textos',
|
||||
'doc.publish.pending': 'Publicación pendiente',
|
||||
|
||||
'deep.title': 'Propuestas en curso',
|
||||
'deep.note': 'Aún no están en vigor. Cada una sube en cuanto alcanza su umbral: al menos {floor} votos a favor y, después, la mayoría que exige su inercia.',
|
||||
'deep.new': 'Proponer un artículo nuevo',
|
||||
'deep.drafts': 'Tus borradores',
|
||||
'deep.drafts.note': 'No sometidos a votación. Solo tú los ves.',
|
||||
'deep.rival': 'reemplazaría «{title}»',
|
||||
'deep.addition': 'artículo nuevo',
|
||||
'deep.removal': 'retirada de «{title}»',
|
||||
'deep.inertia': 'inercia: {level}',
|
||||
'deep.blocked': 'bloqueada',
|
||||
'deep.mine': 'tu propuesta',
|
||||
'deep.stale': 'por reconfirmar',
|
||||
'deep.wellrated': 'bien valorada',
|
||||
'deep.filter.all': 'Todas',
|
||||
'deep.filter.wellrated': 'Bien valoradas',
|
||||
'deep.filter.mine': 'Las mías',
|
||||
'deep.none': 'Ninguna propuesta en este filtro.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'section.introduction': 'Introducción',
|
||||
'section.fondamental': 'Compromisos fundamentales',
|
||||
'section.technique': 'Compromisos técnicos',
|
||||
'section.conclusion': 'Conclusión',
|
||||
'section.annexe': 'Anexos',
|
||||
'section.formule': 'Fórmula de votación',
|
||||
'section.inertie': 'Inercia',
|
||||
'section.ordonnancement': 'Ordenación',
|
||||
|
||||
'tally.short': '{for} a favor · {against} en contra',
|
||||
'tally.required': 'Umbral: {n} votos a favor',
|
||||
'tally.missing.one': 'Falta {n} voto a favor.',
|
||||
'tally.missing.other': 'Faltan {n} votos a favor.',
|
||||
'tally.reached': 'Umbral alcanzado.',
|
||||
'tally.level': 'Nivel: {pct} % del umbral',
|
||||
|
||||
'inertia.low': 'inercia baja',
|
||||
'inertia.standard': 'inercia estándar',
|
||||
'inertia.high': 'inercia alta',
|
||||
'inertia.max': 'inercia máxima',
|
||||
'inertia.level.low': 'baja',
|
||||
'inertia.level.standard': 'estándar',
|
||||
'inertia.level.high': 'alta',
|
||||
'inertia.level.max': 'máxima',
|
||||
|
||||
'panel.vote': 'Votación',
|
||||
'panel.proposals': 'Propuestas',
|
||||
'panel.propose': 'Proponer',
|
||||
'panel.close': 'Cerrar el panel',
|
||||
'panel.inforce': 'En vigor',
|
||||
'panel.proposal': 'Propuesta',
|
||||
'panel.by': 'propuesto por {name}',
|
||||
'panel.seed.origin': 'texto procedente del seed',
|
||||
'panel.changes': 'Ver los cambios',
|
||||
'panel.text': 'Ver el texto',
|
||||
|
||||
'vote.yours': 'Tu voto',
|
||||
'vote.for': 'A favor',
|
||||
'vote.against': 'En contra',
|
||||
'vote.retract': 'Retirar mi voto',
|
||||
'vote.saved': 'Voto registrado.',
|
||||
'vote.retracted': 'Voto retirado.',
|
||||
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
||||
'vote.reconfirm': 'Reconfirmar: {choice}',
|
||||
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
|
||||
'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.',
|
||||
'vote.entered': '«{title}» ha entrado en vigor.',
|
||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||
'vote.closed': 'Esta versión ya no está en votación.',
|
||||
|
||||
'inertia.title': 'Inercia',
|
||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
||||
'inertia.change': 'Proponer un cambio de inercia',
|
||||
'inertia.change.to': 'Nueva inercia',
|
||||
'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.',
|
||||
'inertia.change.submit': 'Someter a votación',
|
||||
'inertia.change.open': 'Cambio de inercia en curso: a {level}',
|
||||
|
||||
'formula.title': 'La fórmula del umbral',
|
||||
'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:',
|
||||
'formula.W': 'W — electorado: {n} ({source})',
|
||||
'formula.W.wot': 'miembros de la red de confianza Ğ1',
|
||||
'formula.W.group': 'miembros del grupo',
|
||||
'formula.T': 'T — votos emitidos: {n}',
|
||||
'formula.M': 'M — mayoría con participación plena: {n} %',
|
||||
'formula.G': 'G — gradiente: {n}',
|
||||
'formula.B': 'B — base: {n}',
|
||||
'formula.C': 'C — mínimo de votos a favor: {n}',
|
||||
'formula.R': 'R — umbral actual: {n} votos a favor',
|
||||
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.',
|
||||
|
||||
'history.title': 'Correcciones',
|
||||
'history.initial': 'texto inicial',
|
||||
'history.typo': 'errata corregida',
|
||||
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
||||
'history.by': 'por {name}',
|
||||
|
||||
'typo.title': 'Corregir una errata',
|
||||
'typo.note': 'Acentos, puntuación, erratas: la corrección se aplica sin votación, con su autor y su diff. Un cambio de sentido pasa por una propuesta.',
|
||||
'typo.save': 'Guardar la corrección',
|
||||
'typo.done': 'Errata corregida, sin tocar los votos.',
|
||||
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||
'typo.asProposal': 'Convertirlo en propuesta',
|
||||
|
||||
'signal.title': 'Contradicciones',
|
||||
'signal.none': 'Ningún aviso.',
|
||||
'signal.add': 'Señalar una contradicción',
|
||||
'signal.target': 'Contradice',
|
||||
'signal.reason': 'En qué se contradicen estos dos textos',
|
||||
'signal.send': 'Señalar',
|
||||
'signal.sent': 'Aviso registrado.',
|
||||
'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.',
|
||||
'signal.lift': 'Levantar',
|
||||
'signal.lifted': 'levantado',
|
||||
'signal.lifted.revision': 'sin efecto: el texto ha cambiado',
|
||||
'signal.by': 'por {name}',
|
||||
|
||||
'propose.mode.replace': 'Modificar este texto',
|
||||
'propose.mode.remove': 'Retirar este artículo',
|
||||
'propose.mode.new': 'Artículo nuevo',
|
||||
'propose.after': 'Colocar después de',
|
||||
'propose.after.end': 'al final',
|
||||
'propose.title': 'Título',
|
||||
'propose.body': 'Texto',
|
||||
'propose.remove.note': 'La propuesta de retirada se vota como cualquier otra; si se aprueba, el artículo sale del texto en vigor.',
|
||||
'propose.draft': 'Guardar el borrador',
|
||||
'propose.submit': 'Someter a votación',
|
||||
'propose.drafted': 'Borrador guardado.',
|
||||
'propose.submitted': 'Propuesta sometida a votación.',
|
||||
'propose.rule': 'Mientras sea un borrador, lo modificas libremente. Una vez sometido, una errata se corrige sin tocar los votos; un cambio de fondo pide a los votantes que reconfirmen.',
|
||||
'propose.edit': 'Modificar',
|
||||
'propose.withdraw': 'Retirar mi propuesta',
|
||||
'propose.withdrawn': 'Propuesta retirada.',
|
||||
'propose.saved.typo': 'Corrección guardada, los votos se mantienen.',
|
||||
'propose.saved.substantive': 'Nueva versión guardada: los votantes tendrán que reconfirmar.',
|
||||
|
||||
'assist.title': 'Asistente',
|
||||
'assist.quiet': 'Todo en orden: una propuesta, una decisión.',
|
||||
'assist.split': 'Este texto parece contener {n} decisiones. Una propuesta = una decisión: ¿dividirlo?',
|
||||
'assist.split.do': 'Dividir en {n} borradores',
|
||||
'assist.split.done': 'Dividido en {n} borradores.',
|
||||
'assist.similar': 'Parecido a «{title}»{where}.',
|
||||
'assist.similar.inForce': ' (en vigor)',
|
||||
'assist.similar.variant': 'Convertirlo en una variante de este artículo',
|
||||
'assist.long': 'Texto largo ({n} caracteres): un artículo corto se vota mejor.',
|
||||
|
||||
'translate.title': 'Traducir ({lang})',
|
||||
'translate.save': 'Guardar la traducción',
|
||||
'translate.saved': 'Traducción guardada.',
|
||||
'translate.note': 'Las traducciones no se votan. Pasan a «por revisar» cuando el texto francés cambia de fondo.',
|
||||
|
||||
'feedback.title': 'Dejar un comentario',
|
||||
'feedback.prompt': 'Lo que hiciste, lo que esperabas, lo que pasó.',
|
||||
'feedback.send': 'Enviar el comentario',
|
||||
'feedback.sent': 'Comentario enviado. Gracias.',
|
||||
'feedback.context': 'Se adjuntan la dirección de la página, el idioma y el tamaño de la pantalla.',
|
||||
|
||||
'access.title': 'trustinWallet — accesos',
|
||||
'access.intro': 'Quién abre qué grupo. Monnaie libre se abre por sí solo a los miembros de la red de confianza Ğ1; los demás grupos se abren uno a uno.',
|
||||
'access.groups': 'Grupos',
|
||||
'access.members.none': 'Ningún miembro tiene acceso.',
|
||||
'access.members.wot': '+ todos los miembros de la red de confianza Ğ1',
|
||||
'access.grant': 'Conceder',
|
||||
'access.revoke': 'Retirar',
|
||||
'access.choose': 'Elegir una identidad',
|
||||
'access.invitations': 'Invitaciones',
|
||||
'access.invite.label': 'Para quién',
|
||||
'access.invite.groups': 'Grupos que abre el enlace',
|
||||
'access.invite.admin': 'administrador de accesos',
|
||||
'access.invite.create': 'Crear el enlace de invitación',
|
||||
'access.invite.link': 'Enlace de invitación, de un solo uso, válido 14 días. No volverá a mostrarse: cópialo ahora.',
|
||||
'access.invite.copy': 'Copiar el enlace',
|
||||
'access.invite.copied': 'Enlace copiado.',
|
||||
'access.invite.cancel': 'Cancelar',
|
||||
'access.invite.pending': 'Pendiente',
|
||||
'access.identities': 'Identidades',
|
||||
'access.admin': 'administrador',
|
||||
'access.admin.give': 'Nombrar administrador',
|
||||
'access.admin.take': 'Retirar el rol',
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitación',
|
||||
'access.kind.dev': 'dev',
|
||||
'access.member': 'miembro de la red de confianza',
|
||||
'access.feedback': 'Comentarios',
|
||||
'access.feedback.none': 'Ningún comentario.',
|
||||
'access.feedback.seen': 'Visto',
|
||||
'access.feedback.done': 'Atendido',
|
||||
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
|
||||
|
||||
'soon.title': 'Próximamente',
|
||||
'soon.body': 'Esta herramienta llegará más adelante. Las herramientas se abren una a una: primero, los documentos de referencia.',
|
||||
|
||||
'error.unauthenticated': 'Inicia sesión para continuar.',
|
||||
'error.forbidden': 'No tienes acceso a esta página.',
|
||||
'error.not_found': 'Esta página no existe, o no es visible para ti.',
|
||||
'error.network': 'El servidor no responde. Comprueba tu conexión e inténtalo de nuevo.',
|
||||
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
|
||||
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
|
||||
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
|
||||
'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).',
|
||||
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
|
||||
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
|
||||
'error.last_admin': 'Debe quedar al menos un administrador.',
|
||||
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
|
||||
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.close': 'Cerrar',
|
||||
'common.save': 'Guardar',
|
||||
'common.loading': 'Cargando…',
|
||||
'common.back': 'Volver',
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
// Source unique des libellés de l'interface — en français d'abord.
|
||||
// Ton : on tutoie la personne ; le sujet est la personne ou le collectif,
|
||||
// jamais la formule ni le système. Les clés en/es doivent être identiques
|
||||
// (vérifié à la compilation). Termes interdits : tests/unit/lexicon.test.ts.
|
||||
|
||||
export const fr = {
|
||||
'app.name': 'libreDecision',
|
||||
'app.skip': 'Aller au contenu',
|
||||
|
||||
'nav.label': 'Boîte à outils',
|
||||
'nav.home': 'Accueil',
|
||||
'nav.decisions': 'Décisions',
|
||||
'nav.docs': 'Docs de référence',
|
||||
'nav.mandates': 'Mandats',
|
||||
'nav.protocols': 'Protocoles',
|
||||
'nav.soon': 'à venir',
|
||||
'nav.groups': 'Mes groupes',
|
||||
'nav.access': 'Accès',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Faire un retour',
|
||||
'nav.signin': 'Se connecter',
|
||||
'nav.signout': 'Se déconnecter',
|
||||
'nav.mood.toNight': 'Ambiance nuit',
|
||||
'nav.mood.toStone': 'Ambiance pierre',
|
||||
'nav.lang': 'Langue',
|
||||
'nav.menu': 'Menu',
|
||||
'nav.close': 'Fermer',
|
||||
|
||||
'home.title': 'Rendre possible la décision collective',
|
||||
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils',
|
||||
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
|
||||
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
|
||||
'home.mandates': 'Créer et révoquer tout type de mandat.',
|
||||
'home.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
|
||||
'home.open': 'ouvert',
|
||||
'home.soon': 'à venir',
|
||||
'home.signin': 'Se connecter avec sa clé Ğ1',
|
||||
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
|
||||
'home.continue': 'Ouvrir mes groupes',
|
||||
|
||||
'signin.title': 'Se connecter',
|
||||
'signin.g1': 'Se connecter avec Ğ1',
|
||||
'signin.g1.how': "Tu es envoyé sur connect.monnaie-libre.fr, le service de connexion du forum Monnaie libre. Tu y approuves la connexion dans Ğecko (lien ou QR code), puis tu reviens ici. Aucun mot de passe, aucune clé ne nous est transmise.",
|
||||
'signin.g1.members': 'Réservé aux membres de la toile de confiance Ğ1.',
|
||||
'signin.g1.pending': "La connexion Ğ1 arrive : l'inscription de libreDecision auprès de connect.monnaie-libre.fr est en cours. En attendant, on entre avec un lien d'invitation.",
|
||||
'signin.invite': "Tu as reçu un lien d'invitation ? Ouvre-le simplement : il te connecte.",
|
||||
'signin.dev': 'Connexion de développement',
|
||||
'signin.dev.name': 'Nom',
|
||||
'signin.dev.admin': 'administrateur des accès',
|
||||
'signin.dev.go': 'Entrer',
|
||||
'signin.error.refus': 'La connexion a été refusée sur connect.monnaie-libre.fr.',
|
||||
'signin.error.etat': 'La connexion a expiré ou vient d’un autre navigateur. Recommence depuis ce navigateur.',
|
||||
'signin.error.incomplet': 'Le retour de connect.monnaie-libre.fr était incomplet. Recommence.',
|
||||
'signin.error.echange': 'connect.monnaie-libre.fr ne répond pas. Réessaie dans un moment.',
|
||||
'signin.error.interne': 'La connexion a échoué de notre côté. Réessaie dans un moment.',
|
||||
|
||||
'invite.title': 'Invitation',
|
||||
'invite.working': 'Connexion en cours…',
|
||||
'invite.invalid': "Ce lien d'invitation n'est plus valable : il a déjà servi ou il a expiré. Demande un nouveau lien à la personne qui te l'a envoyé.",
|
||||
'invite.missing': "Ce lien est incomplet. Ouvre le lien d'invitation entier, tel que tu l'as reçu.",
|
||||
|
||||
'groups.title': 'Mes groupes',
|
||||
'groups.same.key': 'Groupes qui utilisent la même clé',
|
||||
'groups.locked': 'Réservé à ses membres',
|
||||
'groups.locked.long': "Ce groupe s'ouvre à ses membres. L'accès se demande à un administrateur de trustinWallet.",
|
||||
'groups.test': 'Pour les essais',
|
||||
'groups.open': 'Ouvrir',
|
||||
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
|
||||
|
||||
'group.tools': 'Outils',
|
||||
'group.tool.soon': 'Cet outil arrive plus tard.',
|
||||
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.",
|
||||
|
||||
'docs.title': 'Docs de référence',
|
||||
'docs.inForce.one': '{n} article en vigueur',
|
||||
'docs.inForce.other': '{n} articles en vigueur',
|
||||
'docs.open.one': '{n} proposition en cours',
|
||||
'docs.open.other': '{n} propositions en cours',
|
||||
|
||||
'doc.view.label': 'Lire',
|
||||
'doc.view.inforce': 'En vigueur',
|
||||
'doc.view.wellrated': 'Si les bien cotées passent',
|
||||
'doc.view.all': 'Si tout passe',
|
||||
'doc.view.wellrated.note': "Aperçu : le texte tel qu'il serait si les propositions bien cotées entraient en vigueur. Rien n'est adopté.",
|
||||
'doc.view.all.note': "Aperçu : le texte tel qu'il serait si toutes les propositions entraient en vigueur, même peu soutenues. Rien n'est adopté.",
|
||||
'doc.view.empty': 'Aucune proposition ne change le texte dans cet aperçu.',
|
||||
'doc.mark.added': 'ajouté',
|
||||
'doc.mark.replaced': 'modifié',
|
||||
'doc.mark.removed': 'retiré',
|
||||
'doc.lang.label': 'Langue du texte',
|
||||
'doc.lang.translation': 'Traduction éditoriale, sans vote. Le texte qui fait foi est en français.',
|
||||
'doc.lang.original': "Lire l'original",
|
||||
'doc.lang.stale': 'traduction à revoir',
|
||||
'doc.lang.missing': 'pas encore traduit',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': "Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote : 0 vote pour l'instant.",
|
||||
'doc.confirmed': 'confirmé par le vote',
|
||||
'doc.adopted': 'adopté le {date}',
|
||||
'doc.action.vote': 'Le vote',
|
||||
'doc.action.proposals': 'Propositions',
|
||||
'doc.action.propose': 'Proposer une modification',
|
||||
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
|
||||
'doc.published': 'Publié sur le dépôt des textes',
|
||||
'doc.publish.pending': 'Publication en attente',
|
||||
|
||||
'deep.title': 'Propositions en cours',
|
||||
'deep.note': "Pas encore en vigueur. Chacune remonte dès qu'elle atteint son seuil : au moins {floor} voix pour, puis la majorité que demande son inertie.",
|
||||
'deep.new': 'Proposer un nouvel article',
|
||||
'deep.drafts': 'Tes brouillons',
|
||||
'deep.drafts.note': 'Non soumis au vote. Toi seul les vois.',
|
||||
'deep.rival': 'remplacerait « {title} »',
|
||||
'deep.addition': 'nouvel article',
|
||||
'deep.removal': 'retrait de « {title} »',
|
||||
'deep.inertia': 'inertie : {level}',
|
||||
'deep.blocked': 'bloquée',
|
||||
'deep.mine': 'ta proposition',
|
||||
'deep.stale': 'à reconfirmer',
|
||||
'deep.wellrated': 'bien cotée',
|
||||
'deep.filter.all': 'Toutes',
|
||||
'deep.filter.wellrated': 'Bien cotées',
|
||||
'deep.filter.mine': 'Les miennes',
|
||||
'deep.none': 'Aucune proposition dans ce filtre.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Engagements fondamentaux',
|
||||
'section.technique': 'Engagements techniques',
|
||||
'section.conclusion': 'Conclusion',
|
||||
'section.annexe': 'Annexes',
|
||||
'section.formule': 'Formule de vote',
|
||||
'section.inertie': 'Inertie',
|
||||
'section.ordonnancement': 'Ordonnancement',
|
||||
|
||||
'tally.short': '{for} pour · {against} contre',
|
||||
'tally.required': 'Seuil : {n} voix pour',
|
||||
'tally.missing.one': 'Il manque {n} voix pour.',
|
||||
'tally.missing.other': 'Il manque {n} voix pour.',
|
||||
'tally.reached': 'Seuil atteint.',
|
||||
'tally.level': 'Niveau : {pct} % du seuil',
|
||||
|
||||
'inertia.low': 'inertie faible',
|
||||
'inertia.standard': 'inertie standard',
|
||||
'inertia.high': 'inertie haute',
|
||||
'inertia.max': 'inertie maximale',
|
||||
'inertia.level.low': 'faible',
|
||||
'inertia.level.standard': 'standard',
|
||||
'inertia.level.high': 'haute',
|
||||
'inertia.level.max': 'maximale',
|
||||
|
||||
'panel.vote': 'Le vote',
|
||||
'panel.proposals': 'Propositions',
|
||||
'panel.propose': 'Proposer',
|
||||
'panel.close': 'Fermer le panneau',
|
||||
'panel.inforce': 'En vigueur',
|
||||
'panel.proposal': 'Proposition',
|
||||
'panel.by': 'proposé par {name}',
|
||||
'panel.seed.origin': 'texte issu du seed',
|
||||
'panel.changes': 'Voir les changements',
|
||||
'panel.text': 'Voir le texte',
|
||||
|
||||
'vote.yours': 'Ton vote',
|
||||
'vote.for': 'Pour',
|
||||
'vote.against': 'Contre',
|
||||
'vote.retract': 'Retirer mon vote',
|
||||
'vote.saved': 'Vote enregistré.',
|
||||
'vote.retracted': 'Vote retiré.',
|
||||
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
|
||||
'vote.reconfirm': 'Reconfirmer : {choice}',
|
||||
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
|
||||
'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.",
|
||||
'vote.entered': '« {title} » est entré en vigueur.',
|
||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||
'vote.closed': 'Cette version ne se vote plus.',
|
||||
|
||||
'inertia.title': 'Inertie',
|
||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||
'inertia.change': "Proposer un changement d'inertie",
|
||||
'inertia.change.to': 'Nouvelle inertie',
|
||||
'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.",
|
||||
'inertia.change.submit': 'Soumettre au vote',
|
||||
'inertia.change.open': "Changement d'inertie en cours : vers {level}",
|
||||
|
||||
'formula.title': 'La formule du seuil',
|
||||
'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :',
|
||||
'formula.W': "W — électorat : {n} ({source})",
|
||||
'formula.W.wot': 'membres de la toile de confiance Ğ1',
|
||||
'formula.W.group': 'membres du groupe',
|
||||
'formula.T': 'T — voix exprimées : {n}',
|
||||
'formula.M': 'M — majorité à pleine participation : {n} %',
|
||||
'formula.G': 'G — gradient : {n}',
|
||||
'formula.B': 'B — base : {n}',
|
||||
'formula.C': 'C — minimum de voix pour : {n}',
|
||||
'formula.R': 'R — seuil actuel : {n} voix pour',
|
||||
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.",
|
||||
|
||||
'history.title': 'Corrections',
|
||||
'history.initial': 'texte initial',
|
||||
'history.typo': 'coquille corrigée',
|
||||
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
||||
'history.by': 'par {name}',
|
||||
|
||||
'typo.title': 'Corriger une coquille',
|
||||
'typo.note': "Accents, ponctuation, faute de frappe : la correction s'applique sans vote, avec son auteur et son diff. Un changement de sens passe par une proposition.",
|
||||
'typo.save': 'Enregistrer la correction',
|
||||
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
|
||||
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
|
||||
'typo.asProposal': 'En faire une proposition',
|
||||
|
||||
'signal.title': 'Contradictions',
|
||||
'signal.none': 'Aucun signalement.',
|
||||
'signal.add': 'Signaler une contradiction',
|
||||
'signal.target': 'Contredit',
|
||||
'signal.reason': 'En quoi ces deux textes se contredisent',
|
||||
'signal.send': 'Signaler',
|
||||
'signal.sent': 'Signalement enregistré.',
|
||||
'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.",
|
||||
'signal.lift': 'Lever',
|
||||
'signal.lifted': 'levé',
|
||||
'signal.lifted.revision': 'tombé : le texte a changé',
|
||||
'signal.by': 'par {name}',
|
||||
|
||||
'propose.mode.replace': 'Modifier ce texte',
|
||||
'propose.mode.remove': 'Retirer cet article',
|
||||
'propose.mode.new': 'Nouvel article',
|
||||
'propose.after': 'Placer après',
|
||||
'propose.after.end': 'à la fin',
|
||||
'propose.title': 'Titre',
|
||||
'propose.body': 'Texte',
|
||||
'propose.remove.note': 'La proposition de retrait se vote comme une autre ; si elle passe, l’article sort du texte en vigueur.',
|
||||
'propose.draft': 'Enregistrer le brouillon',
|
||||
'propose.submit': 'Soumettre au vote',
|
||||
'propose.drafted': 'Brouillon enregistré.',
|
||||
'propose.submitted': 'Proposition soumise au vote.',
|
||||
'propose.rule': "Tant que c'est un brouillon, tu le modifies librement. Une fois soumis, une coquille se corrige sans toucher aux votes ; un changement de fond demande aux votants de reconfirmer.",
|
||||
'propose.edit': 'Modifier',
|
||||
'propose.withdraw': 'Retirer ma proposition',
|
||||
'propose.withdrawn': 'Proposition retirée.',
|
||||
'propose.saved.typo': 'Correction enregistrée, les votes restent.',
|
||||
'propose.saved.substantive': 'Nouvelle version enregistrée : les votants devront reconfirmer.',
|
||||
|
||||
'assist.title': 'Assistant',
|
||||
'assist.quiet': 'Rien à signaler : une proposition, une décision.',
|
||||
'assist.split': 'Ce texte semble porter {n} décisions. Une proposition = une décision : les découper ?',
|
||||
'assist.split.do': 'Découper en {n} brouillons',
|
||||
'assist.split.done': 'Découpé en {n} brouillons.',
|
||||
'assist.similar': 'Proche de « {title} »{where}.',
|
||||
'assist.similar.inForce': ' (en vigueur)',
|
||||
'assist.similar.variant': 'En faire une variante de cet article',
|
||||
'assist.long': 'Texte long ({n} signes) : un article court se vote mieux.',
|
||||
|
||||
'translate.title': 'Traduire ({lang})',
|
||||
'translate.save': 'Enregistrer la traduction',
|
||||
'translate.saved': 'Traduction enregistrée.',
|
||||
'translate.note': 'Les traductions ne se votent pas. Elles passent « à revoir » quand le texte français change de fond.',
|
||||
|
||||
'feedback.title': 'Faire un retour',
|
||||
'feedback.prompt': "Ce que tu as fait, ce que tu attendais, ce qui s'est passé.",
|
||||
'feedback.send': 'Envoyer le retour',
|
||||
'feedback.sent': 'Retour envoyé. Merci.',
|
||||
'feedback.context': "L'adresse de la page, la langue et la taille de l'écran sont jointes.",
|
||||
|
||||
'access.title': 'trustinWallet — accès',
|
||||
'access.intro': "Qui ouvre quel groupe. Monnaie libre s'ouvre de lui-même aux membres de la toile de confiance Ğ1 ; les autres groupes s'ouvrent un par un.",
|
||||
'access.groups': 'Groupes',
|
||||
'access.members.none': 'Aucun membre accordé.',
|
||||
'access.members.wot': '+ tous les membres de la toile Ğ1',
|
||||
'access.grant': 'Accorder',
|
||||
'access.revoke': 'Retirer',
|
||||
'access.choose': 'Choisir une identité',
|
||||
'access.invitations': 'Invitations',
|
||||
'access.invite.label': 'Pour qui',
|
||||
'access.invite.groups': 'Groupes ouverts par le lien',
|
||||
'access.invite.admin': 'administrateur des accès',
|
||||
'access.invite.create': "Créer le lien d'invitation",
|
||||
'access.invite.link': "Lien d'invitation, à usage unique, valable 14 jours. Il ne s'affichera plus : copie-le maintenant.",
|
||||
'access.invite.copy': 'Copier le lien',
|
||||
'access.invite.copied': 'Lien copié.',
|
||||
'access.invite.cancel': 'Annuler',
|
||||
'access.invite.pending': 'En attente',
|
||||
'access.identities': 'Identités',
|
||||
'access.admin': 'administrateur',
|
||||
'access.admin.give': 'Rendre administrateur',
|
||||
'access.admin.take': 'Retirer le rôle',
|
||||
'access.kind.g1': 'Ğ1',
|
||||
'access.kind.invite': 'invitation',
|
||||
'access.kind.dev': 'dev',
|
||||
'access.member': 'membre de la toile',
|
||||
'access.feedback': 'Retours',
|
||||
'access.feedback.none': 'Aucun retour.',
|
||||
'access.feedback.seen': 'Vu',
|
||||
'access.feedback.done': 'Traité',
|
||||
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
|
||||
|
||||
'soon.title': 'À venir',
|
||||
'soon.body': "Cet outil arrive plus tard. Les chantiers s'ouvrent un par un : d'abord les docs de référence.",
|
||||
|
||||
'error.unauthenticated': 'Connecte-toi pour continuer.',
|
||||
'error.forbidden': "Tu n'as pas accès à cette page.",
|
||||
'error.not_found': "Cette page n'existe pas, ou elle ne t'est pas visible.",
|
||||
'error.network': 'Le serveur ne répond pas. Vérifie ta connexion et réessaie.',
|
||||
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
|
||||
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
|
||||
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
|
||||
'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).',
|
||||
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
|
||||
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
|
||||
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
|
||||
'error.last_admin': 'Il faut garder au moins un administrateur.',
|
||||
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
||||
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
'common.close': 'Fermer',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.back': 'Retour',
|
||||
} as const;
|
||||
|
||||
export type Key = keyof typeof fr;
|
||||
export type Messages = Record<Key, string>;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Interface language: fr, en, es (Yvv, 2026-10-21). French is the source.
|
||||
import { en } from './en';
|
||||
import { es } from './es';
|
||||
import { fr, type Key, type Messages } from './fr';
|
||||
import type { Lang } from '../types';
|
||||
|
||||
export const LANGS: Lang[] = ['fr', 'en', 'es'];
|
||||
const CATALOGS: Record<Lang, Messages> = { fr, en, es };
|
||||
const LOCALES: Record<Lang, string> = { fr: 'fr-FR', en: 'en-GB', es: 'es-ES' };
|
||||
|
||||
function initial(): Lang {
|
||||
try {
|
||||
const saved = localStorage.getItem('ld:lang');
|
||||
if (saved === 'fr' || saved === 'en' || saved === 'es') return saved;
|
||||
} catch {
|
||||
/* no storage */
|
||||
}
|
||||
const nav = (typeof navigator !== 'undefined' ? navigator.language : 'fr').slice(0, 2);
|
||||
return nav === 'en' || nav === 'es' ? nav : 'fr';
|
||||
}
|
||||
|
||||
class I18n {
|
||||
lang = $state<Lang>('fr');
|
||||
|
||||
init() {
|
||||
this.set(initial());
|
||||
}
|
||||
|
||||
set(lang: Lang) {
|
||||
this.lang = lang;
|
||||
document.documentElement.lang = lang;
|
||||
try {
|
||||
localStorage.setItem('ld:lang', lang);
|
||||
} catch {
|
||||
/* no storage */
|
||||
}
|
||||
}
|
||||
|
||||
t = (key: Key, params: Record<string, string | number> = {}): string => {
|
||||
const raw = CATALOGS[this.lang][key] ?? fr[key] ?? key;
|
||||
return raw.replace(/\{(\w+)\}/g, (_, k: string) => (k in params ? String(params[k]) : `{${k}}`));
|
||||
};
|
||||
|
||||
/** `<key>.one` / `<key>.other` — French and Spanish use « one » for 0 and 1. */
|
||||
plural = (key: string, n: number, params: Record<string, string | number> = {}): string => {
|
||||
const one = this.lang === 'en' ? n === 1 : n <= 1;
|
||||
return this.t(`${key}.${one ? 'one' : 'other'}` as Key, { n, ...params });
|
||||
};
|
||||
|
||||
date = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString(LOCALES[this.lang], { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
};
|
||||
|
||||
number = (n: number): string => n.toLocaleString(LOCALES[this.lang]);
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = i18n.t;
|
||||
export type { Key };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { t } from './i18n/index.svelte';
|
||||
import type { Proposal, Unit } from './types';
|
||||
|
||||
/** What a proposal is, in words, whatever its kind. */
|
||||
export function titleOf(p: Proposal, unit: Unit | undefined): string {
|
||||
if (p.kind === 'inertia' && p.inertiaTarget) {
|
||||
return t('deep.inertia', { level: t(`inertia.level.${p.inertiaTarget}`) });
|
||||
}
|
||||
if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title });
|
||||
return p.title;
|
||||
}
|
||||
|
||||
/** Its relation to the text in force. */
|
||||
export function relationOf(p: Proposal, unit: Unit | undefined): string {
|
||||
if (p.kind !== 'text') return '';
|
||||
if (unit?.inForce && unit.inForce.id !== p.id) return t('deep.rival', { title: unit.inForce.title });
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiError } from './api';
|
||||
|
||||
/** A page's remote data: value, error code, reload. */
|
||||
export class Remote<T> {
|
||||
value = $state<T | null>(null);
|
||||
error = $state<string | null>(null);
|
||||
loading = $state(true);
|
||||
|
||||
constructor(private fetcher: () => Promise<T>) {}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.value = await this.fetcher();
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof ApiError ? e.code : 'internal';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// A deliberately tiny Markdown subset for the texts of reference: paragraphs,
|
||||
// line breaks, lists, **bold**, *italic*, `code`. HTML is always escaped first,
|
||||
// so a proposal can never inject markup.
|
||||
|
||||
const ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
|
||||
}
|
||||
|
||||
export function inline(s: string): string {
|
||||
return escapeHtml(s)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
const BULLET = /^\s*[-*]\s+/;
|
||||
const NUMBERED = /^\s*\d+[.)]\s+/;
|
||||
|
||||
export function render(md: string): string {
|
||||
const blocks = md.replace(/\r\n?/g, '\n').trim().split(/\n\s*\n/);
|
||||
return blocks
|
||||
.map((block) => {
|
||||
const lines = block.split('\n').filter((l) => l.trim().length > 0);
|
||||
if (lines.length > 0 && lines.every((l) => BULLET.test(l))) {
|
||||
return `<ul>${lines.map((l) => `<li>${inline(l.replace(BULLET, ''))}</li>`).join('')}</ul>`;
|
||||
}
|
||||
if (lines.length > 0 && lines.every((l) => NUMBERED.test(l))) {
|
||||
return `<ol>${lines.map((l) => `<li>${inline(l.replace(NUMBERED, ''))}</li>`).join('')}</ol>`;
|
||||
}
|
||||
return `<p>${lines.map(inline).join('<br>')}</p>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Plain text, for one-line excerpts. */
|
||||
export function plain(md: string): string {
|
||||
return md.replace(/[*`]/g, '').replace(/^\s*[-*]\s+/gm, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
|
||||
export type Mood = 'pierre' | 'nuit';
|
||||
|
||||
class MoodState {
|
||||
current = $state<Mood>('pierre');
|
||||
|
||||
init() {
|
||||
const m = document.documentElement.dataset.mood;
|
||||
this.current = m === 'nuit' ? 'nuit' : 'pierre';
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
|
||||
document.documentElement.dataset.mood = this.current;
|
||||
try {
|
||||
localStorage.setItem('ld:mood', this.current);
|
||||
} catch {
|
||||
/* private mode: the mood just won't persist */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mood = new MoodState();
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api } from './api';
|
||||
import type { Me } from './types';
|
||||
|
||||
class Session {
|
||||
me = $state<Me | null>(null);
|
||||
ready = $state(false);
|
||||
g1 = $state(false);
|
||||
dev = $state(false);
|
||||
|
||||
async load() {
|
||||
const [me, cfg] = await Promise.all([
|
||||
api.get<{ me: Me | null }>('/me').catch(() => ({ me: null })),
|
||||
api.get<{ g1: boolean; dev: boolean }>('/auth/config').catch(() => ({ g1: false, dev: false })),
|
||||
]);
|
||||
this.me = me.me;
|
||||
this.g1 = cfg.g1;
|
||||
this.dev = cfg.dev;
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await api.post('/auth/logout');
|
||||
this.me = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new Session();
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
tone: 'info' | 'rise' | 'warn';
|
||||
}
|
||||
|
||||
class Toasts {
|
||||
list = $state<Toast[]>([]);
|
||||
#n = 0;
|
||||
|
||||
show(text: string, tone: Toast['tone'] = 'info', ms = 4200) {
|
||||
const id = ++this.#n;
|
||||
this.list = [...this.list, { id, text, tone }];
|
||||
setTimeout(() => this.dismiss(id), ms);
|
||||
}
|
||||
|
||||
dismiss(id: number) {
|
||||
this.list = this.list.filter((t) => t.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
export const toasts = new Toasts();
|
||||
@@ -0,0 +1,127 @@
|
||||
export type Lang = 'fr' | 'en' | 'es';
|
||||
export type LangMap = Partial<Record<Lang, string>>;
|
||||
export type Inertia = 'low' | 'standard' | 'high' | 'max';
|
||||
|
||||
export interface Me {
|
||||
id: string;
|
||||
kind: 'g1' | 'invite' | 'dev';
|
||||
displayName: string;
|
||||
address: string | null;
|
||||
g1Member: boolean;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
parent: string | null;
|
||||
policy: 'g1-members' | 'managed';
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface Tally {
|
||||
for: number;
|
||||
against: number;
|
||||
}
|
||||
|
||||
export interface Evaluation {
|
||||
cast: number;
|
||||
required: number;
|
||||
reached: boolean;
|
||||
missing: number;
|
||||
requiredRatio: number;
|
||||
wellRated: boolean;
|
||||
}
|
||||
|
||||
export interface Translation {
|
||||
title: string;
|
||||
body: string;
|
||||
stale: boolean;
|
||||
origin: 'seed' | 'member';
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
unitId: string;
|
||||
kind: 'text' | 'remove' | 'inertia';
|
||||
inertiaTarget: Inertia | null;
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn';
|
||||
origin: 'seed' | 'member';
|
||||
label: string | null;
|
||||
author: { id: string; name: string | null } | null;
|
||||
mine: boolean;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt: string | null;
|
||||
enteredAt: string | null;
|
||||
confirmedAt: string | null;
|
||||
seedMinimal: boolean;
|
||||
tally: Tally;
|
||||
evaluation: Evaluation | null;
|
||||
votedAtInertia: Inertia;
|
||||
blocked: boolean;
|
||||
signals: number;
|
||||
myVote: 'for' | 'against' | null;
|
||||
myVoteStale: boolean;
|
||||
translations: Partial<Record<Lang, Translation>>;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
code: string;
|
||||
section: string | null;
|
||||
position: number;
|
||||
inertia: Inertia;
|
||||
inForce: Proposal | null;
|
||||
proposals: Proposal[];
|
||||
}
|
||||
|
||||
export interface Publication {
|
||||
state: 'none' | 'pending' | 'done';
|
||||
configured: boolean;
|
||||
since?: string;
|
||||
doneAt?: string | null;
|
||||
commit?: string | null;
|
||||
attempts?: number;
|
||||
}
|
||||
|
||||
export interface DocView {
|
||||
doc: {
|
||||
id: string;
|
||||
slug: string;
|
||||
groupSlug: string;
|
||||
groupName: string;
|
||||
title: LangMap;
|
||||
summary: LangMap;
|
||||
sourceLang: Lang;
|
||||
voteFloor: number;
|
||||
electorate: number;
|
||||
electorateSource: 'wot' | 'group';
|
||||
formula: { base: number };
|
||||
publication: Publication;
|
||||
};
|
||||
units: Unit[];
|
||||
inertia: { key: Inertia; majorityPct: number; gradient: number }[];
|
||||
}
|
||||
|
||||
export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string };
|
||||
|
||||
export interface PreviewUnit {
|
||||
unitId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
mark: 'unchanged' | 'added' | 'replaced' | 'removed';
|
||||
proposalId: string | null;
|
||||
versionId: string | null;
|
||||
translations: Partial<Record<Lang, Translation>>;
|
||||
}
|
||||
|
||||
export interface Entered {
|
||||
proposalId: string;
|
||||
unitId: string;
|
||||
code: string;
|
||||
kind: 'text' | 'remove' | 'inertia';
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 105" fill="#1f6f78">
|
||||
<!-- Hexagram 48 — 井 Tsing — Le Puits -->
|
||||
<rect x="6" y="6" width="42" height="8"/><rect x="64" y="6" width="42" height="8"/>
|
||||
<rect x="6" y="23" width="100" height="8"/>
|
||||
<rect x="6" y="40" width="42" height="8"/><rect x="64" y="40" width="42" height="8"/>
|
||||
<rect x="6" y="57" width="100" height="8"/>
|
||||
<rect x="6" y="74" width="100" height="8"/>
|
||||
<rect x="6" y="91" width="42" height="8"/><rect x="64" y="91" width="42" height="8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 535 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 105" fill="currentColor">
|
||||
<!-- Hexagram 48 — 井 Tsing — Le Puits (flat, traits épais, coins nets) -->
|
||||
<rect x="6" y="6" width="42" height="8"/>
|
||||
<rect x="64" y="6" width="42" height="8"/>
|
||||
<rect x="6" y="23" width="100" height="8"/>
|
||||
<rect x="6" y="40" width="42" height="8"/>
|
||||
<rect x="64" y="40" width="42" height="8"/>
|
||||
<rect x="6" y="57" width="100" height="8"/>
|
||||
<rect x="6" y="74" width="100" height="8"/>
|
||||
<rect x="6" y="91" width="42" height="8"/>
|
||||
<rect x="64" y="91" width="42" height="8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
// SPA served by the Rust server: every unknown path falls back to index.html.
|
||||
adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', precompress: true, strict: true }),
|
||||
version: { name: process.env.LD_BUILD ?? 'dev' },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// The words of the screen: every catalogue has the same keys and the same
|
||||
// placeholders, and no visible text uses a forbidden term.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { en } from '../../src/lib/i18n/en';
|
||||
import { es } from '../../src/lib/i18n/es';
|
||||
import { fr } from '../../src/lib/i18n/fr';
|
||||
|
||||
/** Never shown to a person (case- and accent-insensitive). They may live on as code identifiers. */
|
||||
export const FORBIDDEN_UI_TERMS = [
|
||||
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
|
||||
'verdict', 'triage', 'oligarchie', 'peuple adulte',
|
||||
];
|
||||
|
||||
const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const marks = (s: string) => (s.match(/\{\w+\}/g) ?? []).sort().join(',');
|
||||
|
||||
function svelteFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const p = join(dir, name);
|
||||
return statSync(p).isDirectory() ? svelteFiles(p) : p.endsWith('.svelte') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('catalogues', () => {
|
||||
it('en and es have exactly the keys of fr, in order', () => {
|
||||
expect(Object.keys(en)).toEqual(Object.keys(fr));
|
||||
expect(Object.keys(es)).toEqual(Object.keys(fr));
|
||||
});
|
||||
it('placeholders are kept', () => {
|
||||
for (const k of Object.keys(fr) as (keyof typeof fr)[]) {
|
||||
expect(marks(en[k]), `en ${k}`).toBe(marks(fr[k]));
|
||||
expect(marks(es[k]), `es ${k}`).toBe(marks(fr[k]));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('anti-lexicon', () => {
|
||||
it('no forbidden term in the French catalogue', () => {
|
||||
for (const [k, v] of Object.entries(fr)) {
|
||||
for (const term of FORBIDDEN_UI_TERMS) expect(fold(v).includes(fold(term)), `${k}: ${term}`).toBe(false);
|
||||
}
|
||||
});
|
||||
it('no forbidden term in template text of components', () => {
|
||||
for (const file of svelteFiles(join(__dirname, '../../src'))) {
|
||||
const markup = readFileSync(file, 'utf8')
|
||||
.replace(/<script[\s\S]*?<\/script>/g, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/g, '')
|
||||
.replace(/\{[^}]*\}/g, ' ');
|
||||
const text = fold(markup.replace(/<[^>]+>/g, ' '));
|
||||
for (const term of FORBIDDEN_UI_TERMS) expect(text.includes(fold(term)), `${file}: ${term}`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plain, render } from '../../src/lib/markdown';
|
||||
|
||||
describe('markdown', () => {
|
||||
it('escapes html before anything', () => {
|
||||
expect(render('<script>alert(1)</script>')).toBe('<p><script>alert(1)</script></p>');
|
||||
expect(render('**<b>x</b>**')).toBe('<p><strong><b>x</b></strong></p>');
|
||||
});
|
||||
it('paragraphs, breaks, emphasis, code', () => {
|
||||
expect(render('un **être humain** vivant\nligne\n\n*note*')).toBe(
|
||||
'<p>un <strong>être humain</strong> vivant<br>ligne</p><p><em>note</em></p>',
|
||||
);
|
||||
expect(render('`DU(n+1) = DU(n)`')).toBe('<p><code>DU(n+1) = DU(n)</code></p>');
|
||||
});
|
||||
it('lists', () => {
|
||||
expect(render('- Stock de **100**\n- 5 jours')).toBe('<ul><li>Stock de <strong>100</strong></li><li>5 jours</li></ul>');
|
||||
expect(render('1. un\n2. deux')).toBe('<ol><li>un</li><li>deux</li></ol>');
|
||||
});
|
||||
it('numbered paragraphs that are not lists stay paragraphs', () => {
|
||||
expect(render('**1.** Derrière une clé')).toBe('<p><strong>1.</strong> Derrière une clé</p>');
|
||||
});
|
||||
it('plain excerpts', () => {
|
||||
expect(plain('**1.** Derrière\n- une clé')).toBe('1. Derrière une clé');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// In development the Rust server runs on :8002 (`cargo run -p ld-server`);
|
||||
// Vite serves the interface on :3002 and forwards /api to it.
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
define: { __LD_BUILD__: JSON.stringify(process.env.LD_BUILD ?? 'dev') },
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3002,
|
||||
strictPort: true,
|
||||
proxy: { '/api': { target: 'http://127.0.0.1:8002', changeOrigin: false } },
|
||||
},
|
||||
test: { include: ['tests/unit/**/*.test.ts'], environment: 'node' },
|
||||
});
|
||||
Reference in New Issue
Block a user