forked from yvv/decision
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:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user