forked from yvv/decision
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>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
// State of the reader: the document, the view (in force / previews), the
|
|
// language the text is read in, and the panel (which unit, which version,
|
|
// which tab) — the panel lives in the URL so a link reopens it, as does the
|
|
// vote unfolded under an article (`?vote=`).
|
|
|
|
import { api, ApiError } from './api';
|
|
import type { GroupView } from './group';
|
|
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
|
|
|
export type View = 'inforce' | 'wellrated' | 'all';
|
|
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
|
|
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
|
|
|
|
export interface PanelState {
|
|
unitId: string | null;
|
|
proposalId: string | null;
|
|
tab: Tab;
|
|
draftId?: string | null;
|
|
/** The basket, opened on one section. */
|
|
section?: string | null;
|
|
prefill?: { title: string; body: string } | null;
|
|
/** A new proposal that answers this objection (ADR 0010). */
|
|
objectionId?: string | null;
|
|
/** The details of one's own version, its edit form open: to reword it. */
|
|
edit?: boolean;
|
|
}
|
|
|
|
/** The tabs as the URL names them. */
|
|
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
|
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
|
|
|
/** The query of a reader's URL: the unfolded vote and the panel. */
|
|
export function panelSearch(panel: PanelState | null, vote: string | null): string {
|
|
const q = new URLSearchParams();
|
|
if (vote) q.set('vote', vote);
|
|
if (panel) {
|
|
q.set('onglet', TAB_NAMES[panel.tab]);
|
|
if (panel.unitId) q.set('article', panel.unitId);
|
|
if (panel.proposalId) q.set('version', panel.proposalId);
|
|
if (panel.draftId) q.set('brouillon', panel.draftId);
|
|
if (panel.section) q.set('section', panel.section);
|
|
if (panel.objectionId) q.set('objection', panel.objectionId);
|
|
if (panel.edit) q.set('modifier', '1');
|
|
}
|
|
return q.toString();
|
|
}
|
|
|
|
export interface Shown {
|
|
title: string;
|
|
body: string;
|
|
/** The text is a translation (editorial). */
|
|
translated: boolean;
|
|
stale: boolean;
|
|
/** Asked for a translation that does not exist yet: the source is shown. */
|
|
missing: boolean;
|
|
}
|
|
|
|
export function shown(
|
|
v: { title: string; body: string; translations: Partial<Record<Lang, Translation>> },
|
|
lang: Lang,
|
|
source: Lang,
|
|
): Shown {
|
|
if (lang === source) return { title: v.title, body: v.body, translated: false, stale: false, missing: false };
|
|
const tr = v.translations[lang];
|
|
if (!tr) return { title: v.title, body: v.body, translated: false, stale: false, missing: true };
|
|
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
|
|
}
|
|
|
|
export class DocState {
|
|
view = $state<DocView | null>(null);
|
|
error = $state<string | null>(null);
|
|
mode = $state<View>('inforce');
|
|
preview = $state<PreviewUnit[] | null>(null);
|
|
original = $state(false);
|
|
|
|
async load(group: string, slug: string) {
|
|
try {
|
|
const g = await api.get<GroupView>(`/groups/${group}`);
|
|
const d = g.docs.find((x) => x.slug === slug);
|
|
if (!d) {
|
|
this.error = g.group.open ? 'not_found' : 'forbidden';
|
|
return;
|
|
}
|
|
this.view = await api.get<DocView>(`/docs/${d.id}`);
|
|
this.error = null;
|
|
if (this.mode !== 'inforce') await this.loadPreview();
|
|
} catch (e) {
|
|
this.error = e instanceof ApiError ? e.code : 'internal';
|
|
}
|
|
}
|
|
|
|
async reload() {
|
|
if (!this.view) return;
|
|
this.view = await api.get<DocView>(`/docs/${this.view.doc.id}`);
|
|
if (this.mode !== 'inforce') await this.loadPreview();
|
|
}
|
|
|
|
async setMode(mode: View) {
|
|
this.mode = mode;
|
|
if (mode === 'inforce') this.preview = null;
|
|
else await this.loadPreview();
|
|
}
|
|
|
|
async loadPreview() {
|
|
if (!this.view) return;
|
|
const r = await api.get<{ units: PreviewUnit[] }>(`/docs/${this.view.doc.id}/preview?mode=${this.mode}`);
|
|
this.preview = r.units;
|
|
}
|
|
|
|
unit(id: string | null): Unit | undefined {
|
|
return this.view?.units.find((u) => u.id === id);
|
|
}
|
|
|
|
proposal(id: string | null): Proposal | undefined {
|
|
if (!id || !this.view) return undefined;
|
|
for (const u of this.view.units) {
|
|
if (u.inForce?.id === id) return u.inForce;
|
|
const p = u.proposals.find((x) => x.id === id);
|
|
if (p) return p;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
get inForce(): Unit[] {
|
|
return (this.view?.units ?? []).filter((u) => u.inForce);
|
|
}
|
|
|
|
/** Open proposals, in document order; drafts apart. */
|
|
get open(): { unit: Unit; proposal: Proposal }[] {
|
|
const out: { unit: Unit; proposal: Proposal }[] = [];
|
|
for (const unit of this.view?.units ?? []) {
|
|
for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
get drafts(): { unit: Unit; proposal: Proposal }[] {
|
|
const out: { unit: Unit; proposal: Proposal }[] = [];
|
|
for (const unit of this.view?.units ?? []) {
|
|
for (const proposal of unit.proposals) if (proposal.status === 'draft') out.push({ unit, proposal });
|
|
}
|
|
return out;
|
|
}
|
|
}
|