forked from yvv/decision
Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). 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>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
||||
// note promises everyone can check their own vote with its salt: the receipt is
|
||||
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
||||
// it is checked here, in the browser, without asking the server anything.
|
||||
import { onMount } from 'svelte';
|
||||
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 { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { BallotEntry, LangMap } from '$lib/types';
|
||||
|
||||
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
||||
const MAX_FILE = 1_000_000;
|
||||
|
||||
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
||||
onMount(() => {
|
||||
if (session.me) void data.load();
|
||||
});
|
||||
|
||||
let pasted = $state('');
|
||||
let check = $state<ReceiptCheck | null>(null);
|
||||
|
||||
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
||||
|
||||
function standing(b: BallotEntry): string {
|
||||
if (b.status === 'pending') return t('ballots.status.pending');
|
||||
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||
return t('ballots.status.expired');
|
||||
}
|
||||
|
||||
async function fromFile(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
pasted = '';
|
||||
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
||||
}
|
||||
|
||||
function fromText(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
check = checkReceipt(pasted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('ballots.title')}</h1>
|
||||
<p class="lead">{t('ballots.intro')}</p>
|
||||
|
||||
<section aria-labelledby="ballots-mine">
|
||||
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
||||
{#if !session.me}
|
||||
<p class="muted">
|
||||
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
<ul class="list">
|
||||
{#each data.value.ballots as b (b.id)}
|
||||
<li class="card block ballot">
|
||||
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||
<p class="state {b.status}">
|
||||
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||
</p>
|
||||
<code class="comment">{b.comment}</code>
|
||||
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="faint">{t('ballots.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ballots-verify">
|
||||
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
||||
<div class="card block">
|
||||
<p class="muted">{t('ballots.verify.intro')}</p>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.file')}</span>
|
||||
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
||||
</label>
|
||||
<form class="paste" onsubmit={fromText}>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.paste')}</span>
|
||||
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
||||
</label>
|
||||
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
||||
</form>
|
||||
<div class="result" aria-live="polite">
|
||||
{#if check?.kind === 'ok'}
|
||||
<p class="ok">{t('ballots.verify.ok')}</p>
|
||||
<code class="comment">{check.comment}</code>
|
||||
<ul class="lines">
|
||||
{#each check.lines as l (l.proposalId)}
|
||||
<li>
|
||||
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
|
||||
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="faint small">
|
||||
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
||||
</p>
|
||||
{:else if check?.kind === 'mismatch'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
||||
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
||||
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
||||
{:else if check?.kind === 'unreadable'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.list,
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.ballot {
|
||||
justify-items: start;
|
||||
}
|
||||
.state {
|
||||
font-weight: 750;
|
||||
}
|
||||
.state.confirmed {
|
||||
color: var(--moss);
|
||||
}
|
||||
.state.pending {
|
||||
color: var(--water);
|
||||
}
|
||||
.state.refused,
|
||||
.state.expired {
|
||||
color: var(--ochre);
|
||||
}
|
||||
.comment {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
textarea {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.paste {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.paste .field,
|
||||
.paste textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lines {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.unsealed {
|
||||
display: block;
|
||||
}
|
||||
.ok {
|
||||
font-weight: 800;
|
||||
color: var(--moss);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,10 +11,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';
|
||||
@@ -26,23 +28,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, v.modules).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, v.modules)));
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
@@ -52,16 +61,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,
|
||||
@@ -130,6 +130,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