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