forked from yvv/decision
v3 : interface des objections, des coquilles validées et de la messagerie ; contrôles C28–C31
Une version retenue par une objection le dit sous l'article, à chacun selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé » pour une redite, « Reformuler » pour une contradiction) ; la personne qui objecte, lever ou réitérer avant l'échéance. Le panier la montre sans choix et ne l'envoie jamais. « Détails » : faire une objection (redit / contredit), les objections et où elles en sont, les coquilles proposées par d'autres avec leur diff resserré, valider / refuser / revenir, l'historique dit qui a validé. Une coquille se propose sur toute version ouverte ou en vigueur. Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message traité n'a plus de geste. Répondre à une objection lit son message. Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
|
||||
afterNavigate(() => {
|
||||
drawer = false;
|
||||
if (session.ready) void session.refreshUnread();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -43,7 +44,8 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><Logo size={1.2} /></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>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
|
||||
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -157,6 +159,7 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.menu {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
@@ -167,5 +170,21 @@
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
}
|
||||
/* Unread messages, seen without opening the menu. */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -0.3rem;
|
||||
right: -0.3rem;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
import { sections } from '$lib/basket';
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { sections, votableIds } from '$lib/basket';
|
||||
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import { BasketState } from '$lib/picks.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
@@ -24,23 +26,30 @@
|
||||
$effect(() => {
|
||||
void doc.load(group, slug);
|
||||
});
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote
|
||||
// (or held back by an objection) leave it.
|
||||
$effect(() => {
|
||||
const v = doc.view;
|
||||
if (!v) return;
|
||||
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
basket.load(v.doc.id, known);
|
||||
basket.load(v.doc.id, votableIds(sections(v.units)));
|
||||
});
|
||||
|
||||
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
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'), section: q.get('section'), prefill };
|
||||
return {
|
||||
unitId: q.get('article'),
|
||||
proposalId: q.get('version'),
|
||||
tab,
|
||||
draftId: q.get('brouillon'),
|
||||
section: q.get('section'),
|
||||
prefill,
|
||||
objectionId: q.get('objection'),
|
||||
edit: q.get('modifier') === '1',
|
||||
};
|
||||
});
|
||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||
const vote = $derived.by(() => {
|
||||
@@ -50,16 +59,7 @@
|
||||
|
||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||
prefill = nextPanel?.prefill ?? null;
|
||||
const q = new URLSearchParams();
|
||||
if (nextVote) q.set('vote', nextVote);
|
||||
if (nextPanel) {
|
||||
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
||||
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
||||
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
||||
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
||||
if (nextPanel.section) q.set('section', nextPanel.section);
|
||||
}
|
||||
const search = q.toString();
|
||||
const search = panelSearch(nextPanel, nextVote);
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
||||
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
||||
noScroll: true,
|
||||
@@ -122,6 +122,7 @@
|
||||
onopen={(id) => go(panel, id)}
|
||||
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
||||
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
||||
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
|
||||
onsign={sign}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
|
||||
// ADR 0010): what waits for a gesture of the person — an objection to answer,
|
||||
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
|
||||
// deadline. A message dealt with offers no gesture any more. No mail.
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import DiffText from '$lib/components/DiffText.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { condense } from '$lib/diff';
|
||||
import { panelSearch } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Message } from '$lib/types';
|
||||
import { errorText } from '$lib/vote';
|
||||
|
||||
const box = new Remote(() => api.get<{ unread: number; messages: Message[] }>('/messages'));
|
||||
onMount(() => {
|
||||
if (session.me) void box.load();
|
||||
});
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
const unread = $derived(box.value?.unread ?? 0);
|
||||
|
||||
async function reload() {
|
||||
await box.load();
|
||||
session.unread = box.value?.unread ?? session.unread;
|
||||
}
|
||||
|
||||
async function read(m: Message) {
|
||||
if (m.read) return;
|
||||
const r = await api.post<{ unread: number }>(`/messages/${m.id}/read`).catch(() => null);
|
||||
if (r) session.unread = r.unread;
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
await api.post('/messages/read').catch(() => null);
|
||||
await reload();
|
||||
}
|
||||
|
||||
/** The text of a message, in the language of the reader, from its subject. */
|
||||
function textOf(m: Message): string {
|
||||
const o = m.objection;
|
||||
const fix = m.typo;
|
||||
if (o) {
|
||||
const held = o.heldVersion?.title ?? '';
|
||||
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
|
||||
if (m.kind === 'objection_reiterated') return t('messages.objection_reiterated', { name: o.objector ?? '', held });
|
||||
return t('messages.objection_answered', { held });
|
||||
}
|
||||
if (fix) return t(fix.answerer === 'admin' ? 'messages.typo_pending.admin' : 'messages.typo_pending.author', { name: fix.writer ?? '', title: fix.version?.title ?? '' });
|
||||
return '';
|
||||
}
|
||||
|
||||
function outcomeOf(m: Message): string {
|
||||
const o = m.outcome;
|
||||
if (!o) return t('messages.outcome.done');
|
||||
if (o === 'deadline') return t(m.topic === 'typo' ? 'messages.outcome.deadline.typo' : 'messages.outcome.deadline.objection');
|
||||
const key = `messages.outcome.${o}`;
|
||||
return key in OUTCOMES ? t(key as Key) : t('messages.outcome.done');
|
||||
}
|
||||
const OUTCOMES: Record<string, true> = Object.fromEntries(
|
||||
['answered', 'reiterated', 'lifted', 'admin', 'withdrawn', 'applied', 'refused', 'stale'].map((k) => [`messages.outcome.${k}`, true]),
|
||||
);
|
||||
|
||||
/** The version a message is about, in the reader: its details. */
|
||||
function hrefOf(m: Message): string {
|
||||
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
|
||||
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
|
||||
if (!v) return base;
|
||||
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
|
||||
}
|
||||
|
||||
async function gesture(m: Message, g: Message['gestures'][number]) {
|
||||
error = '';
|
||||
if (g === 'respond' && m.objection) {
|
||||
const panel = answerPanel(m.objection, session.me?.id ?? null);
|
||||
void read(m);
|
||||
await goto(`/g/${m.doc.groupSlug}/docs/${m.doc.slug}?${panelSearch(panel, null)}`);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if ((g === 'lift' || g === 'reiterate') && m.objection) {
|
||||
await api.post(`/objections/${m.objection.id}/${g}`);
|
||||
toasts.show(t(g === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||
} else if ((g === 'validate' || g === 'refuse') && m.typo) {
|
||||
await api.post(`/typos/${m.typo.id}/${g}`);
|
||||
toasts.show(t(g === 'validate' ? 'typo.validated' : 'typo.refused'));
|
||||
}
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
await reload();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function label(m: Message, g: Message['gestures'][number]): string {
|
||||
switch (g) {
|
||||
case 'respond':
|
||||
return m.objection ? t(m.objection.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit') : t('messages.respond');
|
||||
case 'lift':
|
||||
return t('objection.lift');
|
||||
case 'reiterate':
|
||||
return t('objection.reiterate');
|
||||
case 'validate':
|
||||
return t('typo.validate');
|
||||
case 'refuse':
|
||||
return t('typo.refuse');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('messages.title')} · libreDecision</title></svelte:head>
|
||||
|
||||
{#if !session.me}
|
||||
<Guard code="unauthenticated" />
|
||||
{:else if box.error}
|
||||
<Guard code={box.error} onretry={() => box.load()} />
|
||||
{:else}
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<h1>{t('messages.title')}</h1>
|
||||
<p class="muted">{t('messages.intro')}</p>
|
||||
{#if unread}
|
||||
<button class="btn small" type="button" onclick={readAll}>{t('messages.readAll')}</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
|
||||
{#if box.value}
|
||||
<ul class="box">
|
||||
{#each box.value.messages as m (m.id)}
|
||||
<li class="message" class:unread={!m.read} class:done={m.done}>
|
||||
<p class="top">
|
||||
<span class="tag" class:ochre={m.topic === 'objection'} class:water={m.topic === 'typo'}>{t(`messages.topic.${m.topic}`)}</span>
|
||||
<span class="doc">{m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''}</span>
|
||||
<span class="faint">{i18n.date(m.createdAt)}</span>
|
||||
{#if !m.read}<span class="new">{t('messages.unread')}</span>{/if}
|
||||
</p>
|
||||
<p class="text">{textOf(m)}</p>
|
||||
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
|
||||
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
|
||||
{#if m.done}
|
||||
<p class="outcome">{outcomeOf(m)}</p>
|
||||
{:else if m.dueAt}
|
||||
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if !m.done}
|
||||
{#each m.gestures as g (g)}
|
||||
<button class="btn small" class:primary={g === 'respond' || g === 'lift' || g === 'validate'} type="button" disabled={busy} onclick={() => gesture(m, g)}>{label(m, g)}</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<a class="linkish" href={hrefOf(m)} onclick={() => void read(m)}>{t('messages.open')}</a>
|
||||
{#if !m.read}<button class="linkish" type="button" onclick={() => read(m)}>{t('messages.read')}</button>{/if}
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty muted">{t('messages.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="faint">{t('common.loading')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 5rem;
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.head .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.5rem);
|
||||
}
|
||||
.box {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.message.unread {
|
||||
background: color-mix(in srgb, var(--surface) 88%, var(--water));
|
||||
}
|
||||
.message.done {
|
||||
opacity: 0.78;
|
||||
box-shadow: none;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.6rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.doc {
|
||||
font-weight: 750;
|
||||
}
|
||||
.new {
|
||||
margin-left: auto;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reason {
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.due {
|
||||
font-weight: 750;
|
||||
color: var(--ochre);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.outcome {
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.8rem;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.2rem 0;
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user