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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:58:34 +02:00
co-authored by Claude Opus 5.5
parent 67ddee28b2
commit 7a022dd35e
66 changed files with 7771 additions and 15 deletions
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
interface Overview {
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
}
interface Feedback {
feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[];
}
const data = new Remote(() => api.get<Overview>('/access'));
const fb = new Remote(() => api.get<Feedback>('/access/feedback'));
onMount(() => {
void data.load();
void fb.load();
});
let choose = $state<Record<string, string>>({});
let label = $state('');
let groups = $state<string[]>([]);
let admin = $state(false);
let link = $state('');
let error = $state('');
const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id;
async function act(fn: () => Promise<unknown>) {
error = '';
try {
await fn();
await data.load();
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
}
async function invite(e: SubmitEvent) {
e.preventDefault();
await act(async () => {
const r = await api.post<{ link: string }>('/access/invitations', { label, groups, admin });
link = r.link;
label = '';
groups = [];
admin = false;
});
}
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
}
async function status(id: number, s: 'seen' | 'done') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
}
</script>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if !session.me.admin}
<div class="page"><p class="muted">{t('access.denied')}</p></div>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('access.title')}</h1>
<p class="muted">{t('access.intro')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<section>
<h2 class="eyebrow">{t('access.groups')}</h2>
{#each data.value.groups as g (g.slug)}
<div class="card block">
<h3>{g.name}</h3>
{#if g.policy === 'g1-members'}<p class="faint small">{t('access.members.wot')}</p>{/if}
<ul class="members">
{#each g.members as m (m.identityId)}
<li>
<span>{nameOf(m.identityId)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/groups/${g.slug}/members/${encodeURIComponent(m.identityId)}`))}>{t('access.revoke')}</button>
</li>
{:else}
<li class="faint small">{t('access.members.none')}</li>
{/each}
</ul>
<div class="row">
<select class="select" bind:value={choose[g.slug]} aria-label={t('access.choose')}>
<option value={undefined}>{t('access.choose')}</option>
{#each data.value.identities.filter((i) => !g.members.some((m) => m.identityId === i.id)) as i (i.id)}
<option value={i.id}>{i.displayName} · {t(`access.kind.${i.kind}`)}</option>
{/each}
</select>
<button class="btn small" type="button" disabled={!choose[g.slug]} onclick={() => act(() => api.post(`/access/groups/${g.slug}/members`, { identityId: choose[g.slug] }))}>{t('access.grant')}</button>
</div>
</div>
{/each}
</section>
<section>
<h2 class="eyebrow">{t('access.invitations')}</h2>
<form class="card block" onsubmit={invite}>
<label class="field"><span>{t('access.invite.label')}</span><input class="input" bind:value={label} maxlength="60" required /></label>
<fieldset>
<legend>{t('access.invite.groups')}</legend>
{#each data.value.groups as g (g.slug)}
<label class="check"><input type="checkbox" value={g.slug} bind:group={groups} /> {g.name}</label>
{/each}
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
</fieldset>
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
{#if link}
<div class="link">
<p class="small">{t('access.invite.link')}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
{#each data.value.invitations as inv (inv.id)}
<li>
<span><b>{inv.label}</b> · {t('access.invite.pending')} · {i18n.date(inv.expiresAt)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/invitations/${inv.id}`))}>{t('access.invite.cancel')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
{#each data.value.identities as i (i.id)}
<li>
<span>
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
{#if i.g1Member} · {t('access.member')}{/if}
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</li>
{/each}
</ul>
</section>
<section>
<h2 class="eyebrow">{t('access.feedback')}</h2>
{#each fb.value?.feedback ?? [] as f (f.id)}
<div class="card block fb" class:done={f.status === 'done'}>
<p class="small faint">{f.author ?? '—'} · {i18n.date(f.createdAt)} · <code>{f.route}</code></p>
<p class="body">{f.body}</p>
<div class="row">
{#if f.status === 'new'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'seen')}>{t('access.feedback.seen')}</button>{/if}
{#if f.status !== 'done'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'done')}>{t('access.feedback.done')}</button>{/if}
</div>
</div>
{:else}
<p class="faint">{t('access.feedback.none')}</p>
{/each}
</section>
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
h3 {
font-size: 1.05rem;
font-weight: 800;
}
section {
display: grid;
gap: 0.7rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.6rem;
}
.members,
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.members li,
.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.row {
display: flex;
gap: 0.5rem;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
legend {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
margin-bottom: 0.3rem;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
}
.block > .btn {
justify-self: start;
}
.link {
display: grid;
gap: 0.5rem;
padding: 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.link code {
word-break: break-all;
font-size: 0.85rem;
}
.link .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.fb .body {
white-space: pre-wrap;
}
.fb.done {
opacity: 0.6;
}
</style>