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