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,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>
|
||||
Reference in New Issue
Block a user