v2 : les 14 écrans — le tour complet de la démocratie d'exercice

- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes,
  Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation)
- Registre + fiche décision (timeline, périmètre premier/second lieu auditable,
  affluence non-ignorable, S'instruire condensé, éléments + cartographie de
  clôture, épreuve du réel, Remettre en question, PV A4, gravure)
- Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité
  avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse,
  Pour moi, Explorer, cristallisation-geste —, élection à départage humain)
- Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des
  formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux
  de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser)
- Onboarding 7 gabarits + Données locales (export/import, attributs, atelier)
- voting→framing gardé (Reformuler d'un réglage figé non cristallisé)
- Pages v1 supprimées (login, documents, mandates, protocols, sanctuary,
  tools, decisions/new) — 342 tests verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 13:47:20 +02:00
co-authored by Claude Fable 5
parent d886302b59
commit e164b5f6c1
96 changed files with 14097 additions and 11777 deletions
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* /creer — onboarding en 3 étapes (layout bare) : le premier usage EST le
* tutoriel. Étape 1 identité, étape 2 gabarit, étape 3 personnes.
* Atterrit sur / où la première décision « Adopter notre Pacte » attend.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import type { TemplateId } from '~/data/templates'
import { CREATE_SIGNATURE, FIRST_DECISION, OPENING_DREAM } from '~/lexicon'
import { COLOR_CHOICES } from '~/components/onboarding/OnboardingIdentity.vue'
import { TEMPLATE_ICONS } from '~/components/onboarding/OnboardingTemplates.vue'
definePageMeta({ layout: 'bare' })
const store = useCollectiveStore()
const step = ref<1 | 2 | 3>(1)
const name = ref('')
const color = ref(COLOR_CHOICES[0]!.hex)
const template = ref<TemplateId | null>(null)
const meName = ref('')
const members = ref<string[]>([])
const busy = ref(false)
const issues = ref<string[]>([])
const STEP_TITLES: Record<1 | 2 | 3, string> = {
1: 'Donne-lui un nom, une couleur, une ambiance.',
2: 'Choisis un point de départ — tout reste amendable par décision.',
3: 'Toi, puis les premières personnes autour de la table.',
}
const canContinue = computed(() => {
if (busy.value) return false
if (step.value === 1) return name.value.trim().length > 0
if (step.value === 2) return template.value !== null
return meName.value.trim().length > 0
})
function next() {
if (!canContinue.value) return
issues.value = []
if (step.value < 3) step.value++
else void create()
}
function back() {
if (busy.value || step.value === 1) return
issues.value = []
step.value--
}
/** « Les Jardins du Canal » → « les-jardins-du-canal ». */
function slugify(text: string): string {
return (
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'collectif'
)
}
function keepBlockingIssues(all: { level: string; message: string }[]): boolean {
const blocking = all.filter(i => i.level === 'error').map(i => i.message)
issues.value = blocking
return blocking.length > 0
}
async function create() {
if (!template.value || busy.value) return
busy.value = true
const result = await store.createFromTemplate(template.value, {
name: name.value.trim(),
slug: slugify(name.value),
color: color.value,
icon: TEMPLATE_ICONS[template.value],
meName: meName.value.trim(),
memberNames: members.value,
})
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
async function explore(seed: SeedName) {
if (busy.value) return
busy.value = true
issues.value = []
const result = await store.loadSeed(seed)
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key !== 'Enter') return
const target = ev.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(target.tagName)) return
next()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<!-- ld-v2 -->
<div class="creer">
<header class="creer__head">
<h1 class="creer__dream">{{ OPENING_DREAM }}</h1>
<p class="creer__step-title">{{ STEP_TITLES[step] }}</p>
</header>
<OnboardingIdentity
v-if="step === 1"
v-model:name="name"
v-model:color="color"
@next="next"
/>
<OnboardingTemplates
v-else-if="step === 2"
v-model="template"
@next="next"
@seed="explore"
/>
<OnboardingMembers
v-else
v-model:me-name="meName"
v-model:members="members"
/>
<ul v-if="issues.length" class="creer__issues">
<li v-for="msg in issues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
<footer class="creer__nav">
<button v-if="step > 1" type="button" class="ld-btn ld-btn--quiet" :disabled="busy" @click="back">
<UIcon name="i-lucide-arrow-left" /> Retour
</button>
<span v-else class="creer__spacer" />
<span class="creer__dots" role="status" :aria-label="`Étape ${step} sur 3`">
<span
v-for="n in 3"
:key="n"
class="creer__dot"
:class="{ 'creer__dot--active': step === n, 'creer__dot--done': step > n }"
/>
</span>
<button type="button" class="ld-btn" :disabled="!canContinue" @click="next">
<template v-if="step < 3">
Continuer <UIcon name="i-lucide-arrow-right" />
</template>
<template v-else>
<UIcon name="i-lucide-sparkles" /> Fonder le collectif
</template>
</button>
</footer>
<p v-if="step === 3" class="creer__hint">
La première décision t'attendra : « {{ FIRST_DECISION }} ».
</p>
<p v-if="step === 3" class="creer__signature">{{ CREATE_SIGNATURE }}</p>
</div>
</template>
<style scoped>
.creer {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2.25rem);
width: min(100%, 56rem);
margin-inline: auto;
padding-bottom: 3.5rem;
}
.creer__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.creer__dream {
margin: 0;
font-size: clamp(1.6rem, 5vw, 2.6rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.creer__step-title {
margin: 0;
font-size: clamp(0.9375rem, 2.5vw, 1.0625rem);
color: var(--mood-text-muted);
}
.creer__issues {
margin: 0;
padding: 0.875rem 1.1rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.9rem;
font-weight: 600;
}
.creer__issues li {
display: flex;
align-items: center;
gap: 0.45rem;
}
.creer__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.creer__spacer {
min-width: 5.5rem;
}
.creer__dots {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.creer__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--mood-text-muted);
opacity: 0.3;
transition: transform 0.12s ease, opacity 0.12s ease, background 0.12s ease;
}
.creer__dot--active {
background: var(--mood-accent);
opacity: 1;
transform: scale(1.25);
}
.creer__dot--done {
background: var(--mood-accent);
opacity: 0.55;
}
.creer__hint {
margin: -0.75rem 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.creer__signature {
margin: 0;
text-align: center;
font-size: 0.875rem;
font-style: italic;
opacity: 0.65;
color: var(--mood-accent);
}
@media (max-width: 480px) {
.creer__nav {
flex-wrap: wrap;
justify-content: center;
}
.creer__dots {
order: -1;
width: 100%;
justify-content: center;
}
.creer__spacer { display: none; }
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
/**
* « /decider — Le chemin » : une phrase → un chemin exécutable en un tap.
* Le moteur pur recalcule à chaque geste ; tout est expliqué en une phrase,
* tout est contournable (alourdir libre, alléger motivé). Chemin nominal :
* phrase + Entrée (au Fil) puis LE bouton — 2 gestes.
*/
import type { ApplyPathEdits } from '~/stores/decisions'
import type {
ChainKind, Decision, DecisionRoute, Id, ParamSpec, Reversibility,
TriageInput, Verdict, Weight,
} from '~/types/domain'
import { computeConcerned } from '~/engine'
import {
ADOPTED_STAMP, ADOPTED_TOAST, METHOD_LABELS,
CAPTURE_PLACEHOLDER, ROUTE_LABELS, WHY_DISCLOSURE,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
type AltKey = 'parametric' | 'record' | 'binary'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
// ── State of the tunnel ──
const title = ref('')
const scope = ref<Decision['scope']>({ selfOnly: true, circleIds: [], personIds: [] })
const reversibility = ref<Reversibility>('easy')
const weight = ref<Weight>('light')
const urgent = ref(false)
const amendsClauseId = ref<Id | undefined>()
const parentId = ref<Id | undefined>()
const chainKind = ref<ChainKind | undefined>()
const baselineNote = ref('')
const resourceNote = ref('')
const resourceAmount = ref<number | null>(null)
const resourceUnit = ref('heures')
const chosenAlt = ref<AltKey | null>(null)
const decidedHow = ref('')
const overrideNote = ref('')
const spec = ref<ParamSpec | undefined>()
const reviewChecked = ref(false)
const engraveChecked = ref(false)
const mandateAttached = ref(false)
const error = ref('')
const stamping = ref(false)
// ── Prefill from the query (?titre ?clause ?parent ?chain ?mandat) ──
const q = route.query
if (typeof q.titre === 'string') title.value = q.titre
if (typeof q.clause === 'string') amendsClauseId.value = q.clause
if (typeof q.parent === 'string') {
const prefill = decisions.reopen(q.parent)
if (!('ok' in prefill)) {
parentId.value = prefill.parentDecisionId
chainKind.value = prefill.chainKind
if (title.value.length === 0) title.value = prefill.title
scope.value = prefill.scope
reversibility.value = prefill.reversibility
weight.value = prefill.weight
if (prefill.amendsClauseId) amendsClauseId.value = prefill.amendsClauseId
}
}
if (typeof q.chain === 'string') chainKind.value = q.chain as ChainKind
if (typeof q.mandat === 'string') {
const mandate = col.mandates.find(m => m.id === q.mandat)
if (mandate) {
scope.value = { selfOnly: false, circleIds: [...mandate.domain.circleIds], personIds: [] }
}
}
// ── The live path — the pure engine recomputes at every gesture ──
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
const tags = computed(() => [...title.value.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()))
const engineInput = computed<TriageInput>(() => ({
title: title.value,
tags: tags.value,
scope: scope.value,
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
...(amendsClauseId.value ? { amendsClauseId: amendsClauseId.value } : {}),
}))
const chemin = computed<Verdict | null>(() => {
const res = decisions.runTriage(engineInput.value)
return 'ok' in res ? null : res
})
watch(() => chemin.value?.reviewRequired, v => { reviewChecked.value = v === true }, { immediate: true })
watch(() => chemin.value?.engravingSuggested, v => { engraveChecked.value = v === true }, { immediate: true })
// ── Concernés pré-calculés (plancher) ──
const stack = computed(() => {
if (scope.value.selfOnly || !col.me) return []
const active = col.mandates.filter(m => m.status === 'active')
return computeConcerned(scope.value, col.circles, active, col.me.id).flatMap((entry) => {
const person = col.people.find(p => p.id === entry.personId)
return person ? [{ person, origin: 'computed' as const, reason: entry.reason }] : []
})
})
// ── Route effective & asymétrie de dérogation ──
const ROUTE_ORDER: Record<DecisionRoute, number> = {
record: 0, solo: 0, mandate: 1, transmit: 1, advice: 2, collective: 3,
}
const effectiveRoute = computed<DecisionRoute>(() => {
if (!chemin.value) return 'solo'
if (!chosenAlt.value) return chemin.value.route
return chosenAlt.value === 'record' ? 'record' : 'collective'
})
const overridden = computed(() => chemin.value !== null && effectiveRoute.value !== chemin.value.route)
const lightening = computed(
() => chemin.value !== null && ROUTE_ORDER[effectiveRoute.value] < ROUTE_ORDER[chemin.value.route],
)
const overrideLabel = computed(() => {
if (chosenAlt.value === 'parametric') return METHOD_LABELS.parametric
if (chosenAlt.value === 'binary') return METHOD_LABELS.binary
if (chosenAlt.value === 'record') return ROUTE_LABELS.record
return ''
})
// ── Frise : durée / seuil (si connus) ──
const protocol = computed(() => {
if (effectiveRoute.value !== 'collective') return undefined
let id = chemin.value?.protocolId
if (chosenAlt.value === 'parametric') id = col.settings?.protocolByRange.parametric ?? id
if (chosenAlt.value === 'binary') id = col.protocols.find(p => p.method === 'binary')?.id ?? id
return col.protocols.find(p => p.id === id)
})
const framingPlanned = computed(
() => chemin.value?.framingDays !== undefined && effectiveRoute.value === 'collective',
)
const durationLabel = computed(() => {
const v = chemin.value
if (!v || effectiveRoute.value === 'solo' || effectiveRoute.value === 'record') return ''
if (framingPlanned.value) return `${v.framingDays} jours de formulation`
if (v.windowHours !== undefined && effectiveRoute.value === v.route) return `fenêtre de ${v.windowHours} h`
return protocol.value ? `${protocol.value.durationDays} jours de vote` : ''
})
const thresholdLabel = computed(() => {
const p = protocol.value
if (!p) return ''
if (p.method === 'consent') return 'zéro objection maintenue'
return METHOD_LABELS[p.method]
})
// ── Bouton principal ──
const mainLabel = computed(() => {
switch (effectiveRoute.value) {
case 'advice': return 'Demande leur avis'
case 'transmit': return 'Transmets'
case 'record': return 'Consigne'
case 'collective': return framingPlanned.value ? 'Ouvre la formulation' : 'Ouvre le vote'
default: return 'Décide'
}
})
const blockReason = computed(() =>
lightening.value && overrideNote.value.trim().length === 0
? 'Alléger se motive — écris pourquoi, ta note s\'affichera.'
: '',
)
const disabled = computed(
() => title.value.trim().length === 0 || blockReason.value !== '' || chemin.value === null,
)
const engagesOpen = computed(
() => (weight.value === 'binding' || weight.value === 'structural') && !scope.value.selfOnly,
)
const baselineSuggested = computed(() => ['advice', 'collective'].includes(effectiveRoute.value))
const suggestion = computed(() => chemin.value?.suggestion)
const reviewLocked = computed(
() => chemin.value?.reviewRequired === true && effectiveRoute.value !== 'solo',
)
// ── Valider : capture → applyPath → fiche (ou Fil si adopté sur-le-champ) ──
async function validate() {
const v = chemin.value
if (!v || disabled.value) return
const created = decisions.capture(title.value)
if ('ok' in created) { error.value = created.reason; return }
if (parentId.value) {
created.parentDecisionId = parentId.value
created.chainKind = chainKind.value ?? 'revision'
}
const edits: ApplyPathEdits = {
scope: {
selfOnly: scope.value.selfOnly,
circleIds: [...scope.value.circleIds],
personIds: [...scope.value.personIds],
},
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
visibility: scope.value.selfOnly ? 'private' : 'scope',
}
if (effectiveRoute.value !== v.route) edits.route = effectiveRoute.value
if (amendsClauseId.value) edits.amendsClauseId = amendsClauseId.value
if (baselineSuggested.value && baselineNote.value.trim()) edits.baselineNote = baselineNote.value.trim()
if (resourceNote.value.trim().length > 0 || typeof resourceAmount.value === 'number') {
edits.resources = {
note: resourceNote.value.trim(),
...(typeof resourceAmount.value === 'number'
? { amount: resourceAmount.value, unit: resourceUnit.value }
: {}),
}
}
if (chosenAlt.value === 'record' && decidedHow.value.trim()) edits.decidedHow = decidedHow.value.trim()
if (chosenAlt.value === 'parametric') {
if (spec.value) edits.paramSpec = spec.value
const pid = col.settings?.protocolByRange.parametric
if (pid) edits.protocolId = pid
}
if (chosenAlt.value === 'binary') {
const pid = col.protocols.find(p => p.method === 'binary')?.id
if (pid) edits.protocolId = pid
}
if (lightening.value) edits.overrideNote = overrideNote.value.trim()
if (mandateAttached.value && suggestion.value?.kind === 'claim-mandate') {
edits.createsMandate = {
title: `Mandat — ${title.value.trim()}`,
domainCircleIds: [...scope.value.circleIds],
domainTags: [...tags.value],
durationDays: 90,
reportEveryDays: 30,
}
}
const applied = decisions.applyPath(created, { ...v, reviewRequired: reviewChecked.value }, edits)
if ('ok' in applied) { error.value = applied.reason; return }
if (engraveChecked.value && applied.status === 'adopted') await decisions.engrave(applied.id)
if (applied.status === 'adopted'
&& (effectiveRoute.value === 'solo' || effectiveRoute.value === 'record' || v.conservatoryChain)) {
stamping.value = true
setTimeout(() => navigateTo('/'), 1100)
return
}
await navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="decider">
<div v-if="!col.current" class="ld-card decider__none">
<p>Aucun collectif actif ouvre ou crée un collectif d'abord.</p>
<NuxtLink to="/creer" class="ld-btn">Créer un collectif</NuxtLink>
</div>
<template v-else>
<input
v-model="title"
type="text"
class="decider__title"
:placeholder="CAPTURE_PLACEHOLDER"
:aria-label="CAPTURE_PLACEHOLDER"
>
<CheminQ0
:title="title"
:linked-clause-id="amendsClauseId"
@contest="amendsClauseId = $event"
/>
<CheminChips
v-model:scope="scope"
v-model:reversibility="reversibility"
v-model:weight="weight"
v-model:urgent="urgent"
v-model:resource-note="resourceNote"
v-model:resource-amount="resourceAmount"
v-model:resource-unit="resourceUnit"
v-model:baseline-note="baselineNote"
:stack="stack"
:engages-open="engagesOpen"
:baseline-suggested="baselineSuggested"
/>
<template v-if="chemin">
<CheminPathCard
:path="chemin"
:route-shown="effectiveRoute"
:overridden="overridden"
:override-label="overrideLabel"
:stack="stack"
:self-only="scope.selfOnly"
:duration-label="durationLabel"
:threshold-label="thresholdLabel"
:main-label="mainLabel"
v-model:review-checked="reviewChecked"
v-model:engrave-checked="engraveChecked"
:review-locked="reviewLocked"
:disabled="disabled"
:block-reason="blockReason"
@validate="validate"
/>
<p v-if="error" class="decider__error">{{ error }}</p>
<CheminAlternatives
v-model:chosen="chosenAlt"
v-model:decided-how="decidedHow"
v-model:override-note="overrideNote"
v-model:spec="spec"
:alternatives="chemin.alternatives"
:parametric-hint="chemin.parametricHint === true"
:lightening="lightening"
/>
<details class="decider__why">
<summary>{{ WHY_DISCLOSURE }}</summary>
<p>Règle {{ chemin.rule }} — {{ chemin.explanation }}</p>
<p>
Le chemin est recommandé par le Pacte de ton collectif, jamais imposé :
alourdir est libre en un tap, alléger se motive et s'affiche à tous.
</p>
</details>
<CheminSuggestion
v-if="suggestion"
v-model:mandate-attached="mandateAttached"
:suggestion="suggestion"
/>
</template>
</template>
<!-- Micro-célébration tampon bref puis retour au Fil -->
<Transition name="stamp">
<div v-if="stamping" class="decider__overlay">
<span class="ld-stamp decider__stamp"> {{ ADOPTED_STAMP }}</span>
<p class="decider__toast">{{ ADOPTED_TOAST }}</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.decider {
width: 100%;
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.decider__none {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
}
.decider__title {
width: 100%;
padding: 0.875rem 1rem;
font-size: clamp(1.125rem, 3.5vw, 1.375rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.decider__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.decider__why {
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.decider__why summary {
cursor: pointer;
font-weight: 700;
padding: 0.25rem;
border-radius: var(--r-input);
width: fit-content;
}
.decider__why summary:hover { color: var(--mood-text); }
.decider__why p { margin: 0.5rem 0 0 0.25rem; line-height: 1.5; }
.decider__overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--mood-bg) 88%, transparent);
}
.decider__stamp { font-size: 1.5rem; padding: 0.75rem 1.5rem; }
.decider__toast { margin: 0; font-size: 1rem; font-weight: 600; }
.stamp-enter-active { transition: opacity 0.12s ease; }
.stamp-enter-from { opacity: 0; }
.stamp-enter-active .decider__stamp {
animation: stamp-in 0.12s ease;
}
@keyframes stamp-in {
from { transform: rotate(-10deg) scale(1.4); opacity: 0; }
to { transform: rotate(-10deg) scale(1); opacity: 1; }
}
</style>
-464
View File
@@ -1,464 +0,0 @@
<script setup lang="ts">
import type { DecisionStep, DecisionStepCreate } from '~/stores/decisions'
const route = useRoute()
const decisions = useDecisionsStore()
const decisionId = computed(() => route.params.id as string)
onMounted(async () => {
await decisions.fetchById(decisionId.value)
})
onUnmounted(() => {
decisions.clearCurrent()
})
watch(decisionId, async (newId) => {
if (newId) {
await decisions.fetchById(newId)
}
})
// --- Status helpers ---
const statusColor = (status: string) => {
switch (status) {
case 'draft': return 'warning'
case 'qualification': return 'info'
case 'review': return 'info'
case 'voting': return 'primary'
case 'executed': return 'success'
case 'closed': return 'neutral'
case 'pending': return 'warning'
case 'active': return 'success'
case 'in_progress': return 'success'
case 'completed': return 'info'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Brouillon'
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'voting': return 'En vote'
case 'executed': return 'Execute'
case 'closed': return 'Clos'
case 'pending': return 'En attente'
case 'active': return 'Actif'
case 'in_progress': return 'En cours'
case 'completed': return 'Termine'
default: return status
}
}
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modification de document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Changement de parametre'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// --- Terminal state check ---
const terminalStatuses = ['executed', 'closed']
const isTerminal = computed(() => {
if (!decisions.current) return true
return terminalStatuses.includes(decisions.current.status)
})
const isDraft = computed(() => decisions.current?.status === 'draft')
// --- Advance action ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try {
await decisions.advance(decisionId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
}
}
// --- Create vote session ---
async function handleCreateVoteSession(step: DecisionStep) {
try {
await decisions.createVoteSession(decisionId.value, step.id)
} catch {
// Error handled by store
}
}
// --- Edit modal ---
const showEditModal = ref(false)
const editData = ref({
title: '',
description: '' as string | null,
context: '' as string | null,
})
const saving = ref(false)
function openEdit() {
if (!decisions.current) return
editData.value = {
title: decisions.current.title,
description: decisions.current.description,
context: decisions.current.context,
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await decisions.update(decisionId.value, editData.value)
showEditModal.value = false
} catch {
// Error handled by store
} finally {
saving.value = false
}
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
async function handleDelete() {
deleting.value = true
try {
await decisions.delete(decisionId.value)
navigateTo('/decisions')
} catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
}
// --- Add step ---
const showAddStep = ref(false)
const newStep = ref<DecisionStepCreate>({
step_type: 'qualification',
title: '',
description: '',
})
const addingStep = ref(false)
const stepTypeOptions = [
{ label: 'Qualification', value: 'qualification' },
{ label: 'Revue', value: 'review' },
{ label: 'Vote', value: 'vote' },
{ label: 'Execution', value: 'execution' },
{ label: 'Compte rendu', value: 'reporting' },
]
async function handleAddStep() {
addingStep.value = true
try {
await decisions.addStep(decisionId.value, newStep.value)
showAddStep.value = false
newStep.value = { step_type: 'qualification', title: '', description: '' }
} catch {
// Error handled by store
} finally {
addingStep.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/decisions"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux decisions"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="decisions.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
</div>
</div>
</template>
<!-- Error state -->
<template v-else-if="decisions.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ decisions.error }}</p>
</div>
</UCard>
</template>
<!-- Decision detail -->
<template v-else-if="decisions.current">
<!-- Header with actions -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ decisions.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">
{{ typeLabel(decisions.current.decision_type) }}
</UBadge>
<UBadge :color="statusColor(decisions.current.status)" variant="subtle">
{{ statusLabel(decisions.current.status) }}
</UBadge>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<UButton
v-if="!isTerminal"
icon="i-lucide-fast-forward"
label="Avancer la decision"
color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div>
</div>
<!-- Description & Context -->
<UCard v-if="decisions.current.description || decisions.current.context">
<div class="space-y-4">
<div v-if="decisions.current.description">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ decisions.current.description }}
</p>
</div>
<div v-if="decisions.current.context">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Contexte</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ decisions.current.context }}
</p>
</div>
</div>
</UCard>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(decisions.current.created_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(decisions.current.updated_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ decisions.current.steps.length }}
</p>
</div>
</div>
</UCard>
<!-- Steps timeline -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Etapes du processus
</h2>
<UButton
v-if="!isTerminal"
icon="i-lucide-plus"
label="Ajouter une etape"
variant="soft"
color="primary"
size="sm"
@click="showAddStep = true"
/>
</div>
<DecisionWorkflow
:steps="decisions.current.steps"
:current-status="decisions.current.status"
@create-vote-session="handleCreateVoteSession"
/>
</div>
</template>
<!-- Edit modal -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier la decision
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="editData.title" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="editData.description" :rows="4" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contexte</label>
<UTextarea v-model="editData.context" :rows="3" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit"
/>
</div>
</div>
</template>
</UModal>
<!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer cette decision ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete"
/>
</div>
</div>
</template>
</UModal>
<!-- Add step modal -->
<UModal v-model:open="showAddStep">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Ajouter une etape
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type d'etape <span class="text-red-500">*</span>
</label>
<USelect
v-model="newStep.step_type"
:items="stepTypeOptions"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="newStep.title" placeholder="Titre de l'etape..." />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="newStep.description" :rows="3" placeholder="Description de l'etape..." />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showAddStep = false"
/>
<UButton
label="Ajouter"
icon="i-lucide-plus"
color="primary"
:loading="addingStep"
@click="handleAddStep"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* /decisions/[id] — la fiche décision, URL permanente de l'objet pivot.
* Progressive disclosure par état : timeline + périmètre toujours ;
* s'instruire (formulation/vote), fenêtre, formulation, éléments de dossier,
* session, vigueur, chaînage. Imprimable en PV A4 ; gravure locale.
*/
import {
ADOPTED_STAMP, BASELINE_ARROW, ENGAGES_LABEL, INERTIA_LABELS,
REOPEN_HANDLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE, WEIGHT_LABELS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, displayStatusLabel, dateFr } from '~/components/decisions/decisionUi'
const route = useRoute()
const col = useCollectiveStore()
const decisionId = computed(() => route.params.id as string)
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const shown = computed(() =>
decision.value ? displayStatus(decision.value, session.value) : 'draft')
// ── Origine : protocole et inertie (poignée « Remettre en question ») ──
const originProtocol = computed(() =>
decision.value?.protocolId
? col.protocols.find(p => p.id === decision.value!.protocolId)?.name
: undefined)
const originInertia = computed(() => {
const clauseId = decision.value?.amendsClauseId
if (!clauseId) return undefined
const clause = col.clauses.find(c => c.id === clauseId)
return clause ? INERTIA_LABELS[clause.inertia] : undefined
})
const originLine = computed(() => {
const parts: string[] = []
if (originProtocol.value) parts.push(originProtocol.value)
if (originInertia.value) parts.push(originInertia.value)
return parts.length > 0
? `Le protocole et l'inertie d'origine s'appliquent : ${parts.join(' — ')}.`
: 'Le protocole et l\'inertie d\'origine s\'appliquent.'
})
// ── Blocs selon l'état ──
const showInstruct = computed(() =>
decision.value?.status === 'framing' || decision.value?.status === 'voting')
const showWindow = computed(() =>
decision.value?.status === 'objection' || decision.value?.status === 'advice')
const hasElements = computed(() =>
col.decisions.some(d =>
d.parentDecisionId === decisionId.value && d.chainKind === 'element'))
const framingDays = computed(() => col.settings?.triage.framingDays ?? 14)
// ── Gestes ──
async function splitDossier() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=element`)
}
async function reopen() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=revision`)
}
</script>
<template>
<!-- ld-v2 -->
<article v-if="decision" class="fiche">
<!-- En-tête -->
<header class="fiche__head">
<div class="fiche__status-row">
<span class="status-pill" :class="`status-${shown}`">
{{ displayStatusLabel(shown) }}
</span>
<span v-if="decision.status === 'adopted'" class="ld-stamp">
{{ ADOPTED_STAMP }}
</span>
<span v-if="decision.engraving" class="fiche__well" title="gravée"></span>
</div>
<h1 class="fiche__title">{{ decision.title }}</h1>
<p v-if="decision.baselineNote" class="fiche__baseline">
<span class="fiche__baseline-arrow">{{ BASELINE_ARROW }}</span>
<span>{{ decision.baselineNote }}</span>
</p>
<div class="fiche__meta">
<span class="fiche__route" :style="{ color: `var(--route-${decision.route})` }">
<UIcon :name="ROUTE_ICONS[decision.route]" />
<span>{{ ROUTE_LABELS[decision.route] }}</span>
</span>
<span class="fiche__chip">{{ WEIGHT_LABELS[decision.weight] }}</span>
<span v-if="decision.urgent" class="fiche__urgent">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_BADGE }}</span>
</span>
</div>
<p v-if="decision.decidedHow" class="fiche__how">
Comment ça s'est décidé : « {{ decision.decidedHow }} »
</p>
<div v-if="decision.routeOverridden && decision.overrideNote" class="fiche__banner">
<UIcon name="i-lucide-feather" />
<span><strong>Chemin allégé</strong> « {{ decision.overrideNote }} »</span>
</div>
<div v-if="decision.scopeKeptNote" class="fiche__banner">
<UIcon name="i-lucide-circle-dot" />
<span><strong>Périmètre maintenu</strong> « {{ decision.scopeKeptNote }} »</span>
</div>
</header>
<!-- Actions + fiche de preuve -->
<DecisionProof :decision="decision" />
<!-- Cycle de vie + périmètre -->
<div class="fiche__grid">
<div class="ld-card fiche__card">
<h2 class="fiche__card-title">Cycle de vie</h2>
<DecisionTimeline :decision="decision" :session="session" />
</div>
<div class="ld-card fiche__card">
<DecisionPerimeter :decision="decision" />
</div>
</div>
<!-- S'instruire ── -->
<div v-if="showInstruct" class="ld-card fiche__card">
<DecisionInstruct :decision="decision" />
</div>
<!-- ── Fenêtre ── -->
<div v-if="showWindow" class="ld-card fiche__card">
<DecisionWindow :decision="decision" />
</div>
<!-- ── Formulation ── -->
<div v-if="decision.status === 'framing'" class="ld-card fiche__card">
<h2 class="fiche__card-title">Formulation</h2>
<p class="fiche__framing-phrase">
{{ framingDays }} jours pour s'instruire et formuler des contre-propositions.
</p>
<LdCountdown v-if="decision.windowEndsAt" :ends-at="decision.windowEndsAt" />
<div v-if="decision.weight === 'structural'" class="no-print">
<button type="button" class="ld-btn ld-btn--ghost" @click="splitDossier()">
<UIcon name="i-lucide-scissors" />
<span>Découper en micro-décisions</span>
</button>
</div>
</div>
<!-- Éléments du dossier -->
<div v-if="hasElements" class="ld-card fiche__card">
<DecisionElements :decision="decision" />
</div>
<!-- Session -->
<div v-if="session" class="ld-card fiche__card">
<DecisionSession :decision="decision" />
</div>
<!-- Vigueur -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card">
<DecisionVigor :decision="decision" />
</div>
<!-- Remettre en question -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card fiche__reopen no-print">
<div>
<p class="fiche__reopen-title">{{ REOPEN_HANDLE }}</p>
<p class="fiche__reopen-line">{{ originLine }}</p>
</div>
<button type="button" class="ld-btn ld-btn--ghost" @click="reopen()">
<UIcon name="i-lucide-rotate-ccw" />
<span>{{ REOPEN_HANDLE }}</span>
</button>
</div>
<!-- Chaînage -->
<div class="fiche__chain-wrap">
<DecisionChain :decision="decision" />
</div>
<!-- Pied de PV (impression) -->
<p class="print-only fiche__pv-foot">
{{ ENGAGES_LABEL }} : {{ decision.resources?.note ?? '—' }}
fiche imprimée le {{ dateFr(new Date().toISOString()) }} · libreDecision
</p>
</article>
<div v-else class="ld-card fiche__missing">
<p>Cette décision est introuvable dans ce collectif.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Retour au registre</NuxtLink>
</div>
</template>
<style scoped>
.fiche {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.fiche__head { display: flex; flex-direction: column; gap: 0.625rem; }
.fiche__status-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.fiche__well {
font-size: 1.25rem;
font-weight: 800;
color: var(--mood-status-vigueur);
transform: rotate(-10deg);
}
.fiche__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.875rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.fiche__baseline {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
font-size: 0.9375rem;
}
.fiche__baseline-arrow {
font-weight: 800;
font-size: 0.8125rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.fiche__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.fiche__route {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 800;
font-size: 0.9375rem;
}
.fiche__chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__urgent {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__how {
margin: 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.fiche__banner {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--r-input);
font-size: 0.9375rem;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
box-shadow: inset 0 0 0 1.5px var(--mood-status-fenetre);
}
.fiche__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.fiche__grid { grid-template-columns: 1fr 1fr; }
}
.fiche__card {
padding: 1.25rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fiche__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.fiche__framing-phrase { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.fiche__reopen {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.fiche__reopen-title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
.fiche__reopen-line {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.fiche__pv-foot { font-size: 0.75rem; color: #333333; }
.fiche__missing {
max-width: 52rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
@media print {
.fiche__grid { grid-template-columns: 1fr 1fr; }
.fiche__card { padding: 0.5rem 0; }
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<script setup lang="ts">
// <!-- ld-v2 --> La salle de vote — le geste démocratique central : instruit,
// puis prononcé. Cinq modalités, un seul lieu ; la clôture due est constatée
// au chargement, l'adoption s'applique d'elle-même et se fête sobrement.
import { wotThreshold } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
ADOPTED_STAMP, ADOPTED_TOAST, ELECTION_RULE, SECRET_DISPLAY, STATUS_LABELS,
VOTE_PRIVACY, WHO_VOTES, WORKSHOP_MODE,
} from '~/lexicon'
import type { Id, VoteSession } from '~/types/domain'
const route = useRoute()
const col = useCollectiveStore()
const store = useDecisionsStore()
const decisionId = computed(() => String(route.params.id))
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed<VoteSession | undefined>(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const protocol = computed(() => col.protocols.find(p => p.id === session.value?.protocolId))
const method = computed(() => protocol.value?.method)
const secret = computed(() => protocol.value?.ballot === 'secret')
// ── Qui vote — la liste arrêtée ──
const eligibleIds = computed<Id[]>(() => session.value?.corpusPersonIds ?? [])
const voterEntries = computed(() =>
col.people.filter(p => eligibleIds.value.includes(p.id)).map(person => ({ person })))
const arrestedDate = computed(() => session.value
? new Date(session.value.opensAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
: '')
// ── Mode atelier — saisir pour quelqu'un, en présence ──
const workshop = ref(false)
const asId = ref<Id | ''>('')
onMounted(() => { workshop.value = !!localStorage.getItem('ld2-workshop') })
const votingAs = computed<Id | undefined>(() =>
workshop.value && asId.value && asId.value !== col.me?.id ? asId.value : undefined)
const voterId = computed(() => votingAs.value ?? col.me?.id)
const inList = computed(() => voterId.value !== undefined && eligibleIds.value.includes(voterId.value))
const canAct = computed(() => session.value?.status === 'open' && inList.value)
const isSteward = computed(() => {
const d = decision.value
const me = col.me
if (!d || !me) return false
return d.stewardIds.length > 0 ? d.stewardIds.includes(me.id) : d.authorId === me.id
})
// ── Clôture due, constatée au chargement ──
const toast = ref('')
function celebrate() {
toast.value = ADOPTED_TOAST
setTimeout(() => { toast.value = '' }, 5000)
}
onMounted(() => {
const s = session.value
if (s && s.status === 'open' && s.closesAt <= col.now()) {
const closed = store.closeSession(s)
if (!('ok' in closed) && closed.outcome === 'adopted') celebrate()
}
})
const displayStatus = computed(() => {
if (session.value?.status === 'frozen') return { css: 'status-frozen', label: 'figé' }
const s = decision.value?.status ?? 'voting'
return { css: `status-${s}`, label: STATUS_LABELS[s] }
})
// ── Affiche de session (impression A4) ──
const closesAtLong = computed(() => session.value
? new Date(session.value.closesAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
: '')
const posterRule = computed(() => {
const f = protocol.value?.formula
if (!f) return ''
switch (method.value) {
case 'consent': return 'Zéro objection maintenue à l\'échéance — le collectif consent.'
case 'nuanced': return `${f.nuancedThresholdPct ?? 80} % de nuances positives (3 à 5) requis.`
case 'binary': {
const active = session.value ? store.activeVotes(session.value.id) : []
const total = active.filter(v => v.value === 'for' || v.value === 'against').length
const threshold = wotThreshold(Math.max(session.value?.corpusSize ?? 1, 1), total,
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase)
return `Seuil actuel : ${threshold.toLocaleString('fr-FR')} pour — il descend quand la participation monte.`
}
case 'parametric': return `Le collectif retient la médiane de chaque curseur, cristallisée par le garant${f.parametricMinParticipants !== undefined ? ` (quorum ${f.parametricMinParticipants})` : ''}.`
case 'election': return ELECTION_RULE
default: return ''
}
})
function printPoster() { window.print() }
</script>
<template>
<!-- ld-v2 -->
<div class="vote-room">
<template v-if="decision && session && protocol">
<div class="no-print vote-room__inner">
<!-- En-tête -->
<header class="vr__head">
<NuxtLink :to="`/decisions/${decision.id}`" class="vr__back">
<UIcon name="i-lucide-arrow-left" />
<span>Fiche décision</span>
</NuxtLink>
<div class="vr__title-row">
<h1 class="vr__title">{{ decision.title }}</h1>
<span v-if="decision.status === 'adopted'" class="ld-stamp"> {{ ADOPTED_STAMP }}</span>
</div>
<div class="vr__pills">
<span class="status-pill" :class="displayStatus.css">{{ displayStatus.label }}</span>
<span class="vr__protocol">{{ protocol.name }}</span>
<span v-if="secret" class="vr__secret">
<UIcon name="i-lucide-eye-off" />
<span>{{ SECRET_DISPLAY }}</span>
</span>
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
<button class="ld-btn ld-btn--quiet vr__print" type="button" @click="printPoster()">
<UIcon name="i-lucide-printer" />
<span>Imprimer</span>
</button>
</div>
<div class="vr__who">
<span>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</span>
<LdAvatarStack :people="voterEntries" :size="28" :max="8" />
</div>
</header>
<!-- Mode atelier -->
<div v-if="workshop" class="vr__workshop">
<UIcon name="i-lucide-users-round" />
<span>{{ WORKSHOP_MODE }} saisir pour</span>
<select v-model="asId" class="vr__workshop-select">
<option value="">moi-même</option>
<option v-for="entry in voterEntries" :key="entry.person.id" :value="entry.person.id">
{{ entry.person.displayName }}
</option>
</select>
</div>
<p v-if="session.status === 'open' && !inList" class="vr__consultative">
Tu n'es pas dans la liste arrêtée ta voix est consultative.
</p>
<!-- S'instruire, puis se prononcer -->
<VoteInstruct :decision="decision" :secret="secret" />
<VoteConsent
v-if="method === 'consent'"
:decision="decision" :session="session"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteNuanced
v-else-if="method === 'nuanced'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteBinary
v-else-if="method === 'binary'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteParametric
v-else-if="method === 'parametric' && decision.paramSpec"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
:is-steward="isSteward"
@adopted="celebrate()"
/>
<VoteElection
v-else-if="method === 'election'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<!-- Transversal : re-vote et discrétion -->
<p class="vr__privacy">
<UIcon name="i-lucide-shield" />
<span>{{ VOTE_PRIVACY }}</span>
</p>
<VoteMyHistory v-if="col.me && voterId === col.me.id" :session="session" :voter-id="col.me.id" />
</div>
<!-- Affiche de session A4 -->
<div class="print-only vr__poster">
<h1>{{ decision.title }}</h1>
<p v-if="decision.body" class="vr__poster-body">{{ decision.body }}</p>
<hr>
<p><strong>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</strong></p>
<p v-if="!secret">{{ voterEntries.map(e => e.person.displayName).join(' · ') }}</p>
<p>{{ posterRule }}</p>
<p><strong>Échéance :</strong> {{ closesAtLong }}</p>
<p><strong>Comment participer :</strong> ouvre la salle de vote « {{ decision.title }} » dans libreDecision, ou confie ton geste en présence (mode atelier).</p>
<p class="vr__poster-seal"></p>
</div>
</template>
<!-- Garde-fous -->
<div v-else-if="!decision" class="ld-card vr__empty">
<p>Cette décision est introuvable.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Toutes les décisions</NuxtLink>
</div>
<div v-else class="ld-card vr__empty">
<p>Cette décision n'a pas encore de session de vote.</p>
<NuxtLink :to="`/decisions/${decisionId}`" class="ld-btn ld-btn--ghost">Fiche décision</NuxtLink>
</div>
<!-- Micro-célébration aucune fanfare -->
<Transition name="vr-toast">
<div v-if="toast" class="vr__toast">
<span class="ld-stamp"> {{ ADOPTED_STAMP }}</span>
<span>{{ toast }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.vote-room { max-width: 46rem; width: 100%; margin: 0 auto; }
.vote-room__inner { display: flex; flex-direction: column; gap: 1rem; }
.vr__head { display: flex; flex-direction: column; gap: 0.6rem; }
.vr__back {
display: inline-flex; align-items: center; gap: 0.35rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-decoration: none;
}
.vr__back:hover { color: var(--mood-accent); }
.vr__title-row { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
.vr__title {
margin: 0; font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 800;
line-height: 1.25; letter-spacing: -0.01em; flex: 1; min-width: 0;
}
.vr__pills { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.vr__protocol {
font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent);
background: var(--mood-accent-soft); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__secret {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-status-fige);
background: var(--mood-status-fige-bg); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__print { margin-left: auto; padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
.vr__who {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem 0.9rem;
font-size: 0.9375rem; font-weight: 600; color: var(--mood-text-muted);
}
.vr__workshop {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;
padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-secondary) 10%, var(--mood-surface));
color: var(--mood-secondary); font-size: 0.875rem; font-weight: 700;
}
.vr__workshop-select {
min-height: 2.25rem; padding: 0.25rem 0.6rem; font-size: 0.875rem; font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.vr__consultative {
margin: 0; padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre);
font-size: 0.875rem; font-weight: 600;
}
.vr__privacy {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; color: var(--mood-text-muted);
}
.vr__empty {
padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;
text-align: center; font-weight: 600;
}
.vr__empty p { margin: 0; }
.vr__toast {
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
z-index: 50; display: flex; align-items: center; gap: 0.75rem;
background: var(--mood-surface); color: var(--mood-text);
padding: 0.75rem 1.25rem; border-radius: var(--r-card);
box-shadow: var(--shadow-raised); font-weight: 600; font-size: 0.9375rem;
max-width: min(92vw, 30rem);
}
.vr-toast-enter-active, .vr-toast-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; }
.vr-toast-enter-from, .vr-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* Affiche A4 — noir sur blanc, sans ambiance */
.vr__poster { color: #111; font-size: 12pt; line-height: 1.5; }
.vr__poster h1 { font-size: 22pt; margin: 0 0 8pt; }
.vr__poster hr { border: none; border-top: 1pt solid #111; margin: 8pt 0; }
.vr__poster-body { white-space: pre-wrap; }
.vr__poster-seal { font-size: 28pt; text-align: right; margin-top: 16pt; }
</style>
+269 -561
View File
@@ -1,612 +1,320 @@
<script setup lang="ts">
/**
* Decisions — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
* /decisions — le registre v2. Pills d'état cliquables (mapping unique),
* pills courtes de route, filtres (cercle/tag/me concerne/poids/gravées/
* consignées), recherche locale. Zéro bouton de création : on crée par la
* capture — l'état vide le rappelle.
*/
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
import type { Decision, DecisionRoute, Weight } from '~/types/domain'
import { ROUTE_SHORT, STATUS_LABELS, FROZEN_LABEL, CAPTURE_PLACEHOLDER } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, fold, type DisplayStatus } from '~/components/decisions/decisionUi'
// Toolbox state
const showConsentModal = ref(false)
const selectedMethod = ref<string | null>(null)
const col = useCollectiveStore()
const consentSteps = [
'Présenter la proposition clairement (2 min)',
'Tour de clarification — questions de compréhension uniquement',
'Tour de réaction — chacun réagit brièvement',
'Porteur amende si nécessaire',
'Tour d\'objections — silence = consentement',
'Lever les objections valides par amendement',
'Adopter ou reporter',
// ── Filtres ──
const query = ref('')
const statusFilter = ref<DisplayStatus | null>(null)
const routeFilter = ref<DecisionRoute | null>(null)
const filterCircle = ref<string | null>(null)
const filterTag = ref<string | null>(null)
const filterMine = ref(false)
const filterWeight = ref<Weight | null>(null)
const filterEngraved = ref(false)
const filterRecorded = ref(false)
const latestSession = (decisionId: string) =>
col.sessions
.filter(s => s.decisionId === decisionId)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
const shownStatus = (d: Decision): DisplayStatus => displayStatus(d, latestSession(d.id))
// ── Pills d'état — l'ordre du cycle, comptées, masquées à zéro ──
const STATUS_ORDER: DisplayStatus[] = [
'draft', 'advice', 'objection', 'framing', 'voting', 'frozen',
'adopted', 'closed', 'rejected', 'revoked', 'transmitted',
]
const statusPills = computed(() =>
STATUS_ORDER
.map(status => ({
status,
label: status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status],
count: col.decisions.filter(d => shownStatus(d) === status).length,
}))
.filter(pill => pill.count > 0),
)
function handleMethodSelect(method: string) {
selectedMethod.value = method
if (method.toLowerCase().includes('consentement')) {
showConsentModal.value = true
}
else if (method.toLowerCase().includes('avis')) {
// Navigate to advice process guide in mandates toolbox
navigateTo('/mandates')
}
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routePills = computed(() =>
ROUTES
.map(route => ({ route, count: col.decisions.filter(d => d.route === route).length }))
.filter(pill => pill.count > 0),
)
// ── Liste filtrée puis triée : en cours par échéance, terminées ensuite ──
const meId = computed(() => col.me?.id ?? null)
const ACTIVE: DisplayStatus[] = ['draft', 'advice', 'objection', 'framing', 'voting', 'frozen']
function concernsMe(d: Decision): boolean {
if (!meId.value) return false
if (d.authorId === meId.value) return true
return col.concerns.some(c => c.decisionId === d.id && c.personId === meId.value)
}
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
decisions.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
])
/** Map for the voting pill — include qualification/review under "En vote". */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft'],
voting: ['qualification', 'review', 'voting'],
executed: ['executed'],
closed: ['closed'],
function deadlineOf(d: Decision): string {
return d.windowEndsAt ?? latestSession(d.id)?.closesAt ?? d.createdAt
}
/** Filtered and sorted decisions. */
const filteredDecisions = computed(() => {
let list = [...decisions.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const statuses = statusGroupMap[activeStatus.value]
list = list.filter(d => statuses.includes(d.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
const filtered = computed(() => {
const needle = fold(query.value.trim())
return col.decisions.filter((d) => {
if (statusFilter.value && shownStatus(d) !== statusFilter.value) return false
if (routeFilter.value && d.route !== routeFilter.value) return false
if (filterCircle.value && !d.scope.circleIds.includes(filterCircle.value)) return false
if (filterTag.value && !d.tags.includes(filterTag.value)) return false
if (filterMine.value && !concernsMe(d)) return false
if (filterWeight.value && d.weight !== filterWeight.value) return false
if (filterEngraved.value && !d.engraving) return false
if (filterRecorded.value && d.route !== 'record') return false
if (needle.length > 0 && !fold(d.title).includes(needle)
&& !d.tags.some(t => fold(t).includes(needle))) return false
return true
})
})
const sorted = computed(() => {
const active = filtered.value
.filter(d => ACTIVE.includes(shownStatus(d)))
.sort((a, b) => deadlineOf(a).localeCompare(deadlineOf(b)))
const settled = filtered.value
.filter(d => !ACTIVE.includes(shownStatus(d)))
.sort((a, b) => (b.decidedAt ?? b.updatedAt).localeCompare(a.decidedAt ?? a.updatedAt))
return [...active, ...settled]
})
const hasAny = computed(() => col.decisions.length > 0)
function toggleStatus(status: DisplayStatus) {
statusFilter.value = statusFilter.value === status ? null : status
}
function toggleRoute(route: DecisionRoute) {
routeFilter.value = routeFilter.value === route ? null : route
}
</script>
<template>
<SectionLayout
title="Décisions"
subtitle="Processus de décision collectifs"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher une décision..."
/>
<!-- ld-v2 -->
<div class="reg">
<header class="reg__header">
<div>
<h1 class="reg__title">Décisions</h1>
<p class="reg__subtitle">le registre chaque décision a son URL, pour toujours</p>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<NuxtLink
v-if="auth.isAuthenticated"
to="/decisions/new"
class="action-btn"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouvelle</span>
<NuxtLink to="/decisions/observatoire" class="reg__observatory">
<UIcon name="i-lucide-telescope" />
<span>L'Observatoire</span>
</NuxtLink>
</template>
</header>
<!-- Main content: decision list -->
<template #default>
<!-- Error state -->
<div v-if="decisions.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ decisions.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="decisions.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDecisions.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par état">
<button
v-for="pill in statusPills"
:key="pill.status"
type="button"
class="status-pill is-clickable"
:class="[`status-${pill.status}`, { active: statusFilter === pill.status }]"
@click="toggleStatus(pill.status)"
>
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
<p>Aucune décision trouvée</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<span>{{ pill.label }}</span>
<span class="reg__count">{{ pill.count }}</span>
</button>
</div>
<!-- Decision cards -->
<div v-else class="space-y-3">
<div
v-for="decision in filteredDecisions"
:key="decision.id"
class="decision-card"
@click="navigateTo(`/decisions/${decision.id}`)"
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par chemin">
<button
v-for="pill in routePills"
:key="pill.route"
type="button"
class="reg__route-pill"
:class="{ 'reg__route-pill--on': routeFilter === pill.route }"
@click="toggleRoute(pill.route)"
>
{{ ROUTE_SHORT[pill.route] }}
</button>
</div>
<div v-if="hasAny" class="reg__tools">
<div class="reg__search">
<UIcon name="i-lucide-search" class="reg__search-icon" />
<input
v-model="query"
type="search"
placeholder="Chercher une décision…"
aria-label="Chercher une décision"
>
<div class="decision-card__header">
<div class="decision-card__title-block">
<h3 class="decision-card__title">
{{ decision.title }}
</h3>
<p v-if="decision.description" class="decision-card__description">
{{ decision.description }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<div class="decision-card__meta">
<span class="decision-card__type-badge">
{{ typeLabel(decision.decision_type) }}
</span>
<span
v-if="decision.decision_type === 'runtime_upgrade'"
class="decision-card__onchain-badge"
>
<UIcon name="i-lucide-link" class="text-xs" />
on-chain
</span>
<span class="decision-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
</span>
<span class="decision-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(decision.created_at) }}
</span>
</div>
<!-- Protocol link for runtime_upgrade -->
<NuxtLink
v-if="decision.decision_type === 'runtime_upgrade'"
to="/protocols"
class="decision-card__protocol-link"
@click.stop
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
<span>Protocole : Soumission Runtime Upgrade</span>
<UIcon name="i-lucide-arrow-right" class="text-xs" />
</NuxtLink>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Context mapper -->
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
<ContextMapper @use="handleMethodSelect" />
</ToolboxSection>
<!-- Vote inertiel WoT -->
<ToolboxVignette
title="Vote inertiel WoT"
:bullets="[
'Seuil adaptatif à la participation',
'Faible participation → quasi-unanimité',
'Formule g1vote — tracé on-chain',
]"
:actions="[
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
]"
<DecisionRegistryFilters
v-model:circle-id="filterCircle"
v-model:tag="filterTag"
v-model:mine="filterMine"
v-model:weight="filterWeight"
v-model:engraved="filterEngraved"
v-model:recorded="filterRecorded"
/>
</div>
<!-- Consentement sociocratique -->
<ToolboxVignette
title="Consentement sociocratique"
:bullets="[
'Aucune objection grave = adopté',
'Rapide pour petits groupes',
'Distingue préférence et objection',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
]"
/>
<div v-if="sorted.length > 0" class="reg__list">
<DecisionRegistryCard v-for="d in sorted" :key="d.id" :decision="d" />
</div>
<!-- Advice process -->
<ToolboxVignette
title="Processus d'avis (Laloux)"
:bullets="[
'Décisions urgentes : < 2h',
'Consultant experts + impactés',
'Responsabilise le porteur',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- Modal consent guide -->
<UModal v-model:open="showConsentModal">
<template #content>
<div class="decision-modal">
<h3 class="decision-modal__title">Consentement sociocratique</h3>
<p class="decision-modal__text">
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune
pas une simple préférence.
<div v-else class="ld-card reg__empty">
<template v-if="!hasAny">
<span class="reg__empty-well">井</span>
<p class="reg__empty-title">Aucune décision pour l'instant.</p>
<p class="reg__empty-line">
Rien ne se crée ici : tout part de la capture
« {{ CAPTURE_PLACEHOLDER }} » sur Aujourd'hui.
</p>
<div class="decision-modal__steps">
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
<div class="decision-modal__step-num">{{ i + 1 }}</div>
<div class="decision-modal__step-text">{{ step }}</div>
</div>
</div>
<p class="decision-modal__ref">Référence : "La Sociocracie" Gerard Endenburg, Brian Robertson (Holacracy)</p>
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
</div>
</template>
</UModal>
<NuxtLink to="/" class="ld-btn ld-btn--ghost">
<UIcon name="i-lucide-sun-medium" />
<span>Aller à Aujourd'hui</span>
</NuxtLink>
</template>
<template v-else>
<p class="reg__empty-title">Rien ne correspond à ces filtres.</p>
<p class="reg__empty-line">Élargis la recherche ou décide par la capture, sur Aujourd'hui.</p>
</template>
</div>
</div>
</template>
<style scoped>
.decision-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.decision-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.decision-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.decision-card:active {
transform: translateY(0);
}
.decision-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.decision-card__title-block {
flex: 1;
min-width: 0;
}
.decision-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.decision-card__title {
font-size: 1.0625rem;
}
}
.decision-card__description {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.decision-card__description {
font-size: 0.875rem;
}
}
.decision-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.decision-card__meta {
gap: 0.5rem;
}
}
.decision-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.decision-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.decision-card__date {
font-size: 0.8125rem;
}
}
.decision-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.decision-card__onchain-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.decision-card__protocol-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 10%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
transition: transform 0.12s ease, box-shadow 0.12s ease;
width: fit-content;
}
.decision-card__protocol-link:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
/* Decision modal */
.decision-modal {
padding: 1.25rem;
.reg {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
@media (min-width: 640px) {
.decision-modal { padding: 2rem; gap: 1.25rem; }
.reg__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.decision-modal__title {
font-size: 1.125rem;
.reg__title {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
color: var(--mood-text);
margin: 0;
letter-spacing: -0.02em;
}
.decision-modal__text {
font-size: 0.875rem;
.reg__subtitle {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.reg__observatory {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.375rem 1rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
font-weight: 700;
text-decoration: none;
transition: transform 0.1s ease;
}
.reg__observatory:hover { transform: translateY(-1px); }
.reg__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reg__count {
font-size: 0.75rem;
opacity: 0.75;
}
.reg__route-pill {
min-height: 2.25rem;
padding: 0.25rem 0.875rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s ease;
}
.reg__route-pill:hover { transform: translateY(-1px); color: var(--mood-text); }
.reg__route-pill--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.decision-modal__steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.decision-modal__step {
.reg__tools {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.decision-modal__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
.reg__search {
position: relative;
flex: 1;
min-width: 14rem;
}
.decision-modal__step-text {
font-size: 0.875rem;
color: var(--mood-text);
padding-top: 0.125rem;
line-height: 1.5;
.reg__search input {
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem 0.5rem 2.5rem;
font-size: 0.9375rem;
box-shadow: var(--shadow-card);
}
.decision-modal__ref {
font-size: 0.75rem;
.reg__search-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--mood-text-muted);
font-style: italic;
margin: 0;
pointer-events: none;
}
.decision-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
align-self: flex-end;
transition: transform 0.1s ease;
.reg__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reg__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
text-align: center;
}
.reg__empty-well {
font-size: 2rem;
font-weight: 800;
color: var(--mood-accent);
opacity: 0.5;
transform: rotate(-10deg);
}
.reg__empty-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.reg__empty-line {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
max-width: 28rem;
}
.decision-modal__close:hover { transform: translateY(-1px); }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
<script setup lang="ts">
// <!-- ld-v2 --> /decisions/observatoire — L'Observatoire (Δ12) : comment nous
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
// faits comptés, des barres sobres aux couleurs du mood — jamais un score.
import type { DecisionRoute, Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
} from '~/lexicon'
import { formatDay } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const decisionsStore = useDecisionsStore()
const reviewLabels = REVIEW_VERDICTS
// ── Filtres : personne / cercle / tag / période ──
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
const since = computed(() =>
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
)
const filtered = computed(() => col.decisions.filter(d =>
(fPerson.value === '' || d.authorId === fPerson.value)
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
&& (fTag.value === '' || d.tags.includes(fTag.value))
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
))
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
// ── Autonomie : ratio par route, maturité, temps médians, participation ──
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'advice', 'collective', 'record']
const routeBars = computed(() => {
const total = Math.max(1, engaged.value.length)
return ROUTES.map((r) => {
const n = engaged.value.filter(d => d.route === r).length
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
})
})
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
function median(xs: number[]): number {
if (xs.length === 0) return 0
const s = [...xs].sort((a, b) => a - b)
const mid = Math.floor(s.length / 2)
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
}
const medianBars = computed(() => ROUTES.map((r) => {
const days = filtered.value
.filter(d => d.route === r && d.decidedAt !== undefined)
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
const m = median(days)
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
}))
const participation = computed(() => {
const ids = new Set(filtered.value.map(d => d.id))
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
})
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
// ── Ressources PAR UNITÉ — l'huile et l'eau, jamais additionnées ──
const resourceLines = computed(() => {
const byUnit = new Map<string, { total: number; count: number }>()
for (const d of engaged.value) {
if (!d.resources?.amount) continue
const unit = d.resources.unit ?? 'sans unité'
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
line.total += d.resources.amount
line.count += 1
byUnit.set(unit, line)
}
return [...byUnit.entries()].map(([unit, l]) => ({
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
}))
})
// ── Épreuves du réel ──
const now = new Date().toISOString()
const reviewsDue = computed(() => filtered.value
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
filtered.value.filter(d => d.review?.verdict === v).length
// ── Révoquées, consignations, maturation ──
const revoked = computed(() => filtered.value
.filter(d => d.status === 'revoked')
.map(d => ({
decision: d,
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
})))
const records = computed(() => filtered.value
.filter(d => d.route === 'record')
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
const maturations = computed(() => {
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
for (const r of records.value) {
const tags = [...new Set(r.tags)].sort()
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
const key = `${tags[i]}|${tags[j]}`
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
entry.ids.add(r.id)
pairs.set(key, entry)
}
}
}
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
.sort((a, b) => b.ids.size - a.ids.size)
const seen = new Set<string>()
return ripe.filter((p) => {
const sig = [...p.ids].sort().join(',')
if (seen.has(sig)) return false
seen.add(sig)
return true
}).slice(0, 4)
})
// ── Élagage & décisions jamais revues ──
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
const unusedProtocols = computed(() => col.protocols.filter(p =>
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
const idleMandates = computed(() => col.mandates.filter(m =>
m.status === 'active' && m.startsAt <= sixMonthsAgo
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
const neverReviewed = computed(() => {
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
return filtered.value.filter(d =>
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
&& d.decidedAt !== undefined && d.decidedAt <= limit)
})
</script>
<template>
<!-- ld-v2 -->
<div class="obs">
<header class="obs__header">
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
</header>
<!-- Filtres -->
<div class="obs__filters">
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
<option value="">Toutes les personnes</option>
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
</select>
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
<option value="">Tous les cercles</option>
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
<option value="">Tous les tags</option>
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
</select>
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
<option value="">Depuis toujours</option>
<option value="30">30 derniers jours</option>
<option value="90">90 derniers jours</option>
<option value="365">Cette année</option>
</select>
</div>
<!-- Autonomie -->
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
<ObservatoryBars :items="routeBars" />
<div class="obs__tiles">
<ObservatoryStat
label="Maturité"
:value="`${recordedCount} / ${tooledCount}`"
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
/>
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
</div>
<h3 class="obs__h3">Temps médians par route</h3>
<ObservatoryBars :items="medianBars" />
<h3 class="obs__h3">Dérogations au chemin</h3>
<div class="obs__tiles">
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
</div>
</ObservatorySection>
<!-- Ressources par unité -->
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
<ul v-if="resourceLines.length" class="obs__list">
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
</li>
</ul>
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
</ObservatorySection>
<!-- Épreuves du réel -->
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
<div class="obs__tiles">
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
</div>
<ul v-if="reviewsDue.length" class="obs__list">
<li v-for="r in reviewsDue" :key="r.id">
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
<span class="obs__row-title">{{ r.title }}</span>
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
</NuxtLink>
</li>
</ul>
</ObservatorySection>
<!-- Révoquées ce qu'on en a appris -->
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
<ul v-if="revoked.length" class="obs__list">
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
<span class="obs__row-title">{{ entry.decision.title }}</span>
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
</NuxtLink>
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
<UIcon name="i-lucide-link" />
la décision qui l'a révoquée
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant — aucune révoquée sur la période.</p>
</ObservatorySection>
<!-- Consignations -->
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs consigné ici, tel quel">
<ul v-if="records.length" class="obs__list">
<li v-for="d in records" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
<span class="obs__row-title">{{ d.title }}</span>
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
<p class="obs__card-title">
<UIcon name="i-lucide-sprout" />
{{ MATURATION_CARD }}
</p>
<p class="obs__muted">
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
</p>
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
Protocoliser
</NuxtLink>
</div>
</ObservatorySection>
<!-- Élagage & relances -->
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue pas un enterrement silencieux">
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
</div>
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
<UIcon name="i-lucide-key-round" />
voir la fiche — une revue s'impose peut-être
</NuxtLink>
</div>
<template v-if="neverReviewed.length">
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
<ul class="obs__list">
<li v-for="d in neverReviewed" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
<span class="obs__row-title">{{ d.title }}</span>
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
</NuxtLink>
</li>
</ul>
</template>
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
Rien à élaguer tout ce qui existe sert encore.
</p>
</ObservatorySection>
</div>
</template>
<style scoped>
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.obs__select {
flex: 1 1 10rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-input-bg);
border: none;
border-radius: var(--r-input);
box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.obs__h3 {
margin: 0.375rem 0 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.obs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
flex-wrap: wrap;
}
.obs__row:hover { transform: translateY(-1px); }
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
.obs__learned {
margin: 0;
padding-left: 0.75rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.obs__chain {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
padding-left: 0.75rem;
}
.obs__chain:hover { text-decoration: underline; }
.obs__card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
}
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
.obs__card-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
</style>
-814
View File
@@ -1,814 +0,0 @@
<script setup lang="ts">
/**
* Document detail page — full structured view with:
* - Genesis block (source files, repos, forum synthesis, formula trigger)
* - Sectioned items grouped by section_tag
* - Mini vote boards per item
* - Inertia sliders per section
* - Permanent vote signage
* - Tuto overlay
*/
import type { DocumentItem, ItemVersion } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const archiving = ref(false)
onMounted(async () => {
await documents.fetchBySlug(slug.value)
})
onUnmounted(() => {
documents.clearCurrent()
})
watch(slug, async (newSlug) => {
if (newSlug) {
await documents.fetchBySlug(newSlug)
}
})
// ─── Section grouping ──────────────────────────────────────────
interface Section {
tag: string
label: string
icon: string
inertiaPreset: string
items: DocumentItem[]
}
const SECTION_META: Record<string, { label: string; icon: string }> = {
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
}
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
const sections = computed((): Section[] => {
const grouped: Record<string, DocumentItem[]> = {}
const ungrouped: DocumentItem[] = []
for (const item of documents.items) {
const tag = item.section_tag
if (tag) {
if (!grouped[tag]) grouped[tag] = []
grouped[tag].push(item)
} else {
ungrouped.push(item)
}
}
const result: Section[] = []
for (const tag of SECTION_ORDER) {
if (grouped[tag]) {
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
const firstItem = grouped[tag][0]
result.push({
tag,
label: meta.label,
icon: meta.icon,
inertiaPreset: firstItem?.inertia_preset || 'standard',
items: grouped[tag],
})
}
}
// Ungrouped items
if (ungrouped.length > 0) {
result.push({
tag: '_other',
label: 'Autres',
icon: 'i-lucide-file-text',
inertiaPreset: 'standard',
items: ungrouped,
})
}
return result
})
const totalItems = computed(() => documents.items.length)
// ─── Helpers ───────────────────────────────────────────────────
const typeLabel = (docType: string) => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
function handlePropose(item: DocumentItem) {
navigateTo(`/documents/${slug.value}/items/${item.id}`)
}
async function archiveToSanctuary() {
archiving.value = true
try {
await documents.archiveDocument(slug.value)
} catch {
// Error handled in store
} finally {
archiving.value = false
}
}
// ─── View mode (editorial vs preview) ────────────────────────
type ViewMode = 'editorial' | 'preview'
type PreviewMode = 'current' | 'projected'
const viewMode = ref<ViewMode>('editorial')
const previewMode = ref<PreviewMode>('current')
const versionsLoaded = ref(false)
async function activatePreview() {
viewMode.value = 'preview'
if (!versionsLoaded.value && documents.items.length > 0) {
const itemIds = documents.items.map(i => i.id)
await documents.fetchAllItemVersions(slug.value, itemIds)
versionsLoaded.value = true
}
}
/** Map item_id → the active version under vote (or null). */
const activeVersionByItem = computed((): Record<string, ItemVersion | null> => {
const map: Record<string, ItemVersion | null> = {}
for (const item of documents.items) {
const versions = documents.allItemVersions[item.id] || []
map[item.id] = versions.find(v => v.status === 'vote')
|| versions.find(v => v.status === 'proposed')
|| null
}
return map
})
const hasProjectedChanges = computed(() =>
Object.values(activeVersionByItem.value).some(v => v !== null),
)
// ─── Active section (scroll spy) ──────────────────────────────
const activeSection = ref<string | null>(null)
function scrollToSection(tag: string) {
// Expand the section if collapsed
if (collapsedSections.value[tag]) {
collapsedSections.value[tag] = false
}
nextTick(() => {
const el = document.getElementById(`section-${tag}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
}
})
}
// ─── Collapsible sections ────────────────────────────────────
// First 2 sections open by default, rest collapsed
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (newSections) => {
if (newSections.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
newSections.forEach((s, i) => {
map[s.tag] = i >= 2 // collapsed if index >= 2
})
collapsedSections.value = map
}
}, { immediate: true })
function toggleSection(tag: string) {
collapsedSections.value[tag] = !collapsedSections.value[tag]
}
</script>
<template>
<div class="doc-page">
<!-- Back link -->
<div class="doc-page__nav">
<UButton
to="/documents"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux documents"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="documents.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
</div>
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
</template>
<!-- Document detail -->
<template v-else-if="documents.current">
<!-- HEADER -->
<div class="doc-page__header">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="doc-page__title">
{{ documents.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2 flex-wrap">
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
v{{ documents.current.version }}
</span>
<span class="text-sm" style="color: var(--mood-text-muted)">
{{ totalItems }} items
</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<DocumentTuto />
<UButton
v-if="auth.isAuthenticated && documents.current.status === 'active'"
label="Archiver"
icon="i-lucide-archive"
color="primary"
variant="soft"
size="sm"
:loading="archiving"
@click="archiveToSanctuary"
/>
</div>
</div>
<!-- Description -->
<p v-if="documents.current.description" class="doc-page__desc">
{{ documents.current.description }}
</p>
</div>
<!-- METADATA -->
<div class="doc-page__meta">
<div class="doc-page__meta-grid">
<div>
<p class="doc-page__meta-label">Créé le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Mis à jour le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Ancrage IPFS</p>
<div class="mt-1">
<IPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
<div v-if="documents.current.chain_anchor">
<p class="doc-page__meta-label">Ancrage on-chain</p>
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</div>
<!-- GENESIS BLOCK -->
<GenesisBlock
v-if="documents.current.genesis_json"
:genesis-json="documents.current.genesis_json"
/>
<!-- VIEW MODE TOGGLE -->
<div class="doc-page__view-toggle">
<div class="doc-page__view-tabs">
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'editorial' }"
@click="viewMode = 'editorial'"
>
<UIcon name="i-lucide-layout-list" class="text-sm" />
Vue structurée
</button>
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'preview' }"
@click="activatePreview"
>
<UIcon name="i-lucide-file-text" class="text-sm" />
Aperçu document
<span v-if="documents.loadingVersions" class="doc-page__view-loading">
<UIcon name="i-lucide-loader-circle" class="text-xs animate-spin" />
</span>
</button>
</div>
<!-- Preview sub-mode (shown only in preview mode) -->
<Transition name="fade">
<div v-if="viewMode === 'preview'" class="doc-page__preview-modes">
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'current' }"
@click="previewMode = 'current'"
>
<UIcon name="i-lucide-circle-check" class="text-xs" />
En vigueur
</button>
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'projected' }"
:disabled="!hasProjectedChanges"
:title="!hasProjectedChanges ? 'Aucun vote en cours sur ce document' : 'Simuler les votes en cours'"
@click="previewMode = 'projected'"
>
<UIcon name="i-lucide-flask-conical" class="text-xs" />
Selon les votes
<span v-if="hasProjectedChanges" class="doc-page__preview-dot" />
</button>
</div>
</Transition>
</div>
<!-- DOCUMENT PREVIEW -->
<Transition name="fade">
<DocumentPreview
v-if="viewMode === 'preview'"
:document="documents.current"
:items="documents.items"
:mode="previewMode"
:version-map="activeVersionByItem"
/>
</Transition>
<!-- SECTION NAVIGATOR -->
<div v-if="sections.length > 1 && viewMode === 'editorial'" class="doc-page__section-nav">
<button
v-for="section in sections"
:key="section.tag"
class="doc-page__section-pill"
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" class="text-xs" />
{{ section.label }}
<span class="doc-page__section-count">{{ section.items.length }}</span>
</button>
</div>
<!-- SECTIONS WITH ITEMS -->
<div v-if="viewMode === 'editorial'" class="doc-page__sections">
<div
v-for="section in sections"
:key="section.tag"
:id="`section-${section.tag}`"
class="doc-page__section"
>
<!-- Section header (clickable toggle) -->
<button
class="doc-page__section-header"
@click="toggleSection(section.tag)"
>
<div class="flex items-center gap-2">
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
<h2 class="doc-page__section-title">
{{ section.label }}
</h2>
<UBadge variant="subtle" color="neutral" size="xs">
{{ section.items.length }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<InertiaSlider :preset="section.inertiaPreset" compact mini />
<UIcon
name="i-lucide-chevron-down"
class="doc-page__section-chevron"
:class="{ 'doc-page__section-chevron--open': !collapsedSections[section.tag] }"
/>
</div>
</button>
<!-- Protocol link for qualification section -->
<NuxtLink
v-if="section.tag === 'qualification' && !collapsedSections[section.tag]"
to="/protocols"
class="doc-page__protocol-link"
>
<UIcon name="i-lucide-git-branch" class="text-sm" />
<div>
<span class="doc-page__protocol-link-label">Protocole lié</span>
<span class="doc-page__protocol-link-name">Embarquement Forgeron</span>
</div>
<UIcon name="i-lucide-arrow-right" class="text-sm doc-page__protocol-link-arrow" />
</NuxtLink>
<!-- Items (collapsible) -->
<Transition name="section-collapse">
<div v-show="!collapsedSections[section.tag]" class="doc-page__section-items">
<EngagementCard
v-for="item in section.items"
:key="item.id"
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
</div>
</Transition>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__nav {
margin-bottom: -0.5rem;
}
/* Header */
.doc-page__header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.doc-page__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
line-height: 1.2;
}
@media (min-width: 640px) {
.doc-page__title {
font-size: 1.875rem;
}
}
.doc-page__desc {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin-top: 0.25rem;
}
/* Metadata */
.doc-page__meta {
padding: 1rem 1.25rem;
background: var(--mood-surface);
border-radius: 14px;
}
.doc-page__meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.doc-page__meta-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.doc-page__meta-label {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.doc-page__meta-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
/* Section navigator */
.doc-page__section-nav {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.doc-page__section-nav::-webkit-scrollbar {
display: none;
}
.doc-page__section-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
background: var(--mood-surface);
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.15s;
border: none;
}
.doc-page__section-pill:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
color: var(--mood-text);
}
.doc-page__section-pill--active {
background: var(--mood-accent);
color: white;
}
.doc-page__section-count {
font-size: 0.625rem;
font-weight: 800;
opacity: 0.7;
}
/* Sections */
.doc-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.doc-page__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
scroll-margin-top: 4rem;
}
.doc-page__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
width: 100%;
background: none;
cursor: pointer;
user-select: none;
transition: opacity 0.15s;
}
.doc-page__section-header:hover {
opacity: 0.85;
}
.doc-page__section-chevron {
font-size: 1rem;
color: var(--mood-text-muted);
transform: rotate(-90deg);
transition: transform 0.25s ease;
flex-shrink: 0;
}
.doc-page__section-chevron--open {
transform: rotate(0deg);
}
.doc-page__section-title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.doc-page__section-title {
font-size: 1.125rem;
}
}
.doc-page__section-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Protocol link */
.doc-page__protocol-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
border-radius: 14px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
color: var(--mood-tertiary, var(--mood-accent));
}
.doc-page__protocol-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.doc-page__protocol-link-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.doc-page__protocol-link-name {
display: block;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.doc-page__protocol-link-arrow {
margin-left: auto;
opacity: 0.3;
transition: opacity 0.12s;
}
.doc-page__protocol-link:hover .doc-page__protocol-link-arrow {
opacity: 1;
}
/* View mode toggle */
.doc-page__view-toggle {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.doc-page__view-tabs {
display: flex;
gap: 4px;
background: var(--mood-surface);
padding: 4px;
border-radius: 14px;
align-self: flex-start;
}
.doc-page__view-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.doc-page__view-tab:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.doc-page__view-tab--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.doc-page__view-loading {
display: inline-flex;
align-items: center;
margin-left: 2px;
}
.doc-page__preview-modes {
display: flex;
gap: 4px;
align-self: flex-start;
}
.doc-page__preview-mode {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-surface);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.doc-page__preview-mode:hover:not(:disabled) {
color: var(--mood-text);
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.doc-page__preview-mode:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.doc-page__preview-mode--active {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-mode--active:hover:not(:disabled) {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mood-warning, #f59e0b);
position: absolute;
top: 4px;
right: 4px;
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Section collapse transition */
.section-collapse-enter-active,
.section-collapse-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.section-collapse-enter-from,
.section-collapse-leave-to {
opacity: 0;
max-height: 0;
}
.section-collapse-enter-to,
.section-collapse-leave-from {
opacity: 1;
}
</style>
@@ -1,328 +0,0 @@
<script setup lang="ts">
import type { DocumentItem, VersionProposal } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const itemId = computed(() => route.params.itemId as string)
const currentItem = computed((): DocumentItem | undefined => {
return documents.items.find(i => i.id === itemId.value)
})
// Modal state for proposing a modification
const showProposeModal = ref(false)
const proposedText = ref('')
const rationale = ref('')
const proposing = ref(false)
// Loading versions
const versionsLoading = ref(false)
onMounted(async () => {
// Fetch document + items if not already loaded
if (!documents.current || documents.current.slug !== slug.value) {
await documents.fetchBySlug(slug.value)
}
// Fetch versions for this item
versionsLoading.value = true
await documents.fetchItemVersions(slug.value, itemId.value)
versionsLoading.value = false
})
onUnmounted(() => {
documents.versions = []
})
watch([slug, itemId], async ([newSlug, newItemId]) => {
if (newSlug && newItemId) {
if (!documents.current || documents.current.slug !== newSlug) {
await documents.fetchBySlug(newSlug)
}
versionsLoading.value = true
await documents.fetchItemVersions(newSlug, newItemId)
versionsLoading.value = false
}
})
function openProposeModal() {
if (currentItem.value) {
proposedText.value = currentItem.value.current_text
}
rationale.value = ''
showProposeModal.value = true
}
async function submitProposal() {
proposing.value = true
try {
const data: VersionProposal = {
proposed_text: proposedText.value,
rationale: rationale.value || null,
}
await documents.proposeVersion(slug.value, itemId.value, data)
showProposeModal.value = false
proposedText.value = ''
rationale.value = ''
} catch {
// Error handled in store
} finally {
proposing.value = false
}
}
async function handleAcceptVersion(versionId: string) {
try {
await documents.acceptVersion(slug.value, itemId.value, versionId)
// Refresh item data
await documents.fetchBySlug(slug.value)
} catch {
// Error handled in store
}
}
async function handleRejectVersion(versionId: string) {
try {
await documents.rejectVersion(slug.value, itemId.value, versionId)
} catch {
// Error handled in store
}
}
const itemTypeLabel = (itemType: string): string => {
switch (itemType) {
case 'clause': return 'Clause'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Section'
default: return itemType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div class="space-y-6">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-gray-500">
<NuxtLink to="/documents" class="hover:text-primary transition-colors">
Documents
</NuxtLink>
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<NuxtLink
v-if="documents.current"
:to="`/documents/${slug}`"
class="hover:text-primary transition-colors"
>
{{ documents.current.title }}
</NuxtLink>
<USkeleton v-else class="h-4 w-32" />
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<span v-if="currentItem" class="text-gray-900 dark:text-white font-medium">
Item {{ currentItem.position }}
</span>
<USkeleton v-else class="h-4 w-20" />
</nav>
<!-- Loading state -->
<template v-if="documents.loading && !currentItem">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error && !currentItem">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
</template>
<!-- Item not found -->
<template v-else-if="!currentItem && !documents.loading">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-file-x" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Item introuvable</p>
<UButton
:to="`/documents/${slug}`"
label="Retour au document"
variant="soft"
color="primary"
class="mt-4"
/>
</div>
</UCard>
</template>
<!-- Item detail -->
<template v-else-if="currentItem">
<!-- Item header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UBadge variant="solid" color="primary" size="sm">
{{ currentItem.position }}
</UBadge>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(currentItem.item_type) }}
</UBadge>
<UBadge
v-if="currentItem.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
</div>
<h1
v-if="currentItem.title"
class="text-2xl font-bold text-gray-900 dark:text-white"
>
{{ currentItem.title }}
</h1>
</div>
<!-- Action buttons -->
<div v-if="auth.isAuthenticated" class="flex items-center gap-2">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
color="primary"
variant="soft"
@click="openProposeModal"
/>
</div>
</div>
<!-- Current text -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-file-text" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
</div>
<MarkdownRenderer :content="currentItem.current_text" />
<div class="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400">
<span>Cree le {{ formatDate(currentItem.created_at) }}</span>
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
</div>
</div>
</UCard>
<!-- Error banner -->
<UCard v-if="documents.error">
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
<!-- Version history -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Historique des versions ({{ documents.versions.length }})
</h2>
<template v-if="versionsLoading">
<div class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-32 w-full" />
</div>
</template>
<template v-else-if="documents.versions.length === 0">
<UCard>
<div class="text-center py-6">
<UIcon name="i-lucide-git-branch" class="text-3xl text-gray-400 mb-2" />
<p class="text-gray-500 text-sm">Aucune version proposee pour cet item</p>
<p class="text-xs text-gray-400 mt-1">
Connectez-vous pour proposer une modification
</p>
</div>
</UCard>
</template>
<div v-else class="space-y-4">
<ItemVersionDiff
v-for="version in documents.versions"
:key="version.id"
:version="version"
@accept="handleAcceptVersion"
@reject="handleRejectVersion"
/>
</div>
</div>
</template>
<!-- Propose modification modal -->
<UModal v-model:open="showProposeModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Proposer une modification
</h3>
<p class="text-sm text-gray-500">
Modifiez le texte ci-dessous et fournissez une justification pour votre proposition.
</p>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Texte propose
</label>
<UTextarea
v-model="proposedText"
:rows="8"
placeholder="Saisissez le nouveau texte..."
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Justification
</label>
<UTextarea
v-model="rationale"
:rows="3"
placeholder="Expliquez les raisons de cette modification..."
class="w-full"
/>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showProposeModal = false"
/>
<UButton
label="Soumettre la proposition"
icon="i-lucide-send"
color="primary"
:loading="proposing"
:disabled="!proposedText.trim()"
@click="submitProposal"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
-690
View File
@@ -1,690 +0,0 @@
<script setup lang="ts">
/**
* Documents de reference — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
*/
import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const inertiaLevels = [
{
id: 'light',
name: 'Léger',
color: 'teal',
params: 'B=0.05, G=0.1',
desc: 'Modification facile. Majorité simple suffit avec bonne participation.',
example: 'Clarifications rédactionnelles, notes de bas de page.',
},
{
id: 'standard',
name: 'Standard',
color: 'accent',
params: 'B=0.1, G=0.2',
desc: 'Seuil adaptatif standard. La formule g1vote dans son paramétrage habituel.',
example: 'Articles de fond, engagements opérationnels.',
},
{
id: 'strong',
name: 'Fort',
color: 'secondary',
params: 'B=0.15, G=0.3',
desc: 'Forte résistance. Faible participation → quasi-unanimité requise.',
example: 'Principes fondateurs, formules de vote, critères WoT.',
},
{
id: 'very-strong',
name: 'Très fort',
color: 'error',
params: 'B=0.2, G=0.4',
desc: 'Protection maximale. Seule une forte mobilisation peut modifier.',
example: 'Clause de licence, identité du projet, droits des membres.',
},
]
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
// New document modal state
const showNewDocModal = ref(false)
const newDoc = ref<DocumentCreate>({
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
})
const creating = ref(false)
const newDocTypeOptions = [
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Règlement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
documents.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
])
/** Filtered and sorted documents. */
const filteredDocuments = computed(() => {
let list = [...documents.list]
// Filter by status
if (activeStatus.value) {
list = list.filter(d => d.status === activeStatus.value)
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
/** Toolbox vignettes from protocols. */
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
watch(() => newDoc.value.title, (title) => {
if (title) {
newDoc.value.slug = generateSlug(title)
}
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
}
async function createDocument() {
creating.value = true
try {
const doc = await documents.createDocument(newDoc.value)
showNewDocModal.value = false
if (doc) {
navigateTo(`/documents/${doc.slug}`)
}
}
catch {
// Error handled in store
}
finally {
creating.value = false
}
}
</script>
<template>
<SectionLayout
title="Documents de référence"
subtitle="Textes fondateurs sous vote permanent de la communauté"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher un document..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button
v-if="auth.isAuthenticated"
class="action-btn"
@click="openNewDocModal"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</button>
</template>
<!-- Main content: document list -->
<template #default>
<!-- Error state -->
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ documents.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="documents.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDocuments.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
<p>Aucun document trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Document cards -->
<div v-else class="space-y-3">
<div
v-for="doc in filteredDocuments"
:key="doc.id"
class="doc-card"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="doc-card__header">
<h3 class="doc-card__title">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<div class="doc-card__meta">
<span class="doc-card__type-badge">
{{ typeLabel(doc.doc_type) }}
</span>
<span class="doc-card__version">v{{ doc.version }}</span>
<span class="doc-card__items">
<UIcon name="i-lucide-list" class="text-xs" />
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
</span>
<span class="doc-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(doc.updated_at) }}
</span>
</div>
<p v-if="doc.description" class="doc-card__description">
{{ doc.description }}
</p>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Inertia guide -->
<ToolboxSection title="Niveaux d'inertie" icon="i-lucide-sliders-horizontal">
<div class="inertia-guide">
<div v-for="level in inertiaLevels" :key="level.id" class="inertia-level">
<div class="inertia-level__header">
<span class="inertia-level__name" :class="`inertia-level__name--${level.color}`">
{{ level.name }}
</span>
<span class="inertia-level__params">{{ level.params }}</span>
</div>
<p class="inertia-level__desc">{{ level.desc }}</p>
<p class="inertia-level__example">{{ level.example }}</p>
</div>
</div>
<NuxtLink to="/protocols/formulas" class="toolbox-link-btn">
<UIcon name="i-lucide-calculator" />
Simuler les formules
</NuxtLink>
</ToolboxSection>
<!-- Structure document -->
<ToolboxVignette
title="Structure d'un document"
:bullets="[
'Items = clauses individuelles',
'Sections = groupes thématiques',
'Chaque clause : vote indépendant',
'Genesis block : traçabilité d\'origine',
]"
:actions="[
{ label: 'Nouveau doc', icon: 'i-lucide-file-plus', emit: 'new', primary: true },
]"
@action="e => e === 'new' && openNewDocModal()"
/>
<!-- Sanctuaire -->
<ToolboxVignette
title="Sanctuaire IPFS"
:bullets="[
'Document adopté → archivé on-chain',
'Hash IPFS + system.remark Duniter',
'Immuable, vérifiable, décentralisé',
]"
:actions="[
{ label: 'Sanctuaire', icon: 'i-lucide-archive', to: '/sanctuary', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- New document modal -->
<UModal v-model:open="showNewDocModal">
<template #content>
<div class="p-4 sm:p-6 space-y-4">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau document de référence
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Titre
</label>
<UInput
v-model="newDoc.title"
placeholder="Ex: Licence G1"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Slug (identifiant URL)
</label>
<UInput
v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Type de document
</label>
<USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Décrivez brièvement ce document..."
class="w-full"
/>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showNewDocModal = false"
/>
<UButton
label="Créer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
@click="createDocument"
/>
</div>
</div>
</template>
</UModal>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.doc-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.doc-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.doc-card:active {
transform: translateY(0);
}
.doc-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.doc-card__title {
font-size: 1.0625rem;
}
}
.doc-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.doc-card__meta {
gap: 0.5rem;
}
}
.doc-card__version {
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
}
.doc-card__items {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.doc-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.doc-card__date {
font-size: 0.8125rem;
}
}
.doc-card__description {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.doc-card__description {
font-size: 0.875rem;
}
}
/* Inertia guide */
.inertia-guide {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inertia-level {
background: var(--mood-surface);
border-radius: 10px;
padding: 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.inertia-level__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.inertia-level__name {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia-level__name--teal { color: var(--mood-success); }
.inertia-level__name--accent { color: var(--mood-accent); }
.inertia-level__name--secondary { color: var(--mood-secondary, var(--mood-accent)); }
.inertia-level__name--error { color: var(--mood-error); }
.inertia-level__params {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 1px 6px;
border-radius: 8px;
}
.inertia-level__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.inertia-level__example {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin: 0;
font-style: italic;
opacity: 0.8;
}
.toolbox-link-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
text-decoration: none;
cursor: pointer;
align-self: flex-start;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.toolbox-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
.doc-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
/**
* /donnees — local-first assumé : tes données, ton fichier, ta machine.
* Sections : collectifs locaux · import · jeux de démonstration · identité ·
* mode atelier · niveaux de preuve. Note synchro en pied.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import { PROOF_LOCAL, SYNC_NOTE, WORKSHOP_MODE } from '~/lexicon'
const store = useCollectiveStore()
// ── Jeux de démonstration — désactivés si déjà présents ──────
const seedBusy = ref<SeedName | null>(null)
const seedIssues = ref<string[]>([])
const hasSeed = (slug: string) => store.index.some(entry => entry.slug === slug)
async function loadSeed(name: SeedName) {
if (seedBusy.value) return
seedBusy.value = name
seedIssues.value = []
const result = await store.loadSeed(name)
seedIssues.value = result.issues.filter(i => i.level === 'error').map(i => i.message)
seedBusy.value = null
}
// ── Mode atelier — flag local lu par les écrans de vote ──────
const workshop = ref(false)
onMounted(() => {
workshop.value = localStorage.getItem('ld2-workshop') === '1'
})
function toggleWorkshop() {
workshop.value = !workshop.value
localStorage.setItem('ld2-workshop', workshop.value ? '1' : '0')
}
</script>
<template>
<!-- ld-v2 -->
<div class="donnees">
<header class="donnees__head">
<h1 class="donnees__title">Données locales</h1>
<p class="donnees__sub">Tes données, ton fichier, ta machine.</p>
</header>
<DataCollectivesCard />
<DataImportCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-sprout" /> Jeux de démonstration
</h2>
<p class="data-card__text">
Deux collectifs complets, importés par le même chemin qu'un fichier à toi.
</p>
<div class="seed-row">
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('duniter-g1') || seedBusy !== null"
@click="loadSeed('duniter-g1')"
>
<UIcon name="i-lucide-coins" />
{{ hasSeed('duniter-g1') ? 'Duniter Ğ1 déjà ' : 'Explorer Duniter Ğ1' }}
</button>
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('atelier-du-canal') || seedBusy !== null"
@click="loadSeed('atelier-du-canal')"
>
<UIcon name="i-lucide-hammer" />
{{ hasSeed('atelier-du-canal') ? "L'Atelier du Canal déjà " : "Explorer l'Atelier du Canal" }}
</button>
</div>
<ul v-if="seedIssues.length" class="seed-issues">
<li v-for="msg in seedIssues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
</section>
<DataIdentityCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-users-round" />
<span class="cap-word">{{ WORKSHOP_MODE }}</span>
</h2>
<div class="workshop">
<p class="data-card__text">
En présence, une seule machine circule : chaque saisie garde qui a saisi,
pour qui, et la date de l'atelier.
</p>
<button
type="button"
class="switch"
:class="{ 'switch--on': workshop }"
role="switch"
:aria-checked="workshop"
:aria-label="WORKSHOP_MODE"
@click="toggleWorkshop"
>
<span class="switch__thumb" />
</button>
</div>
</section>
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-shield-check" /> Niveaux de preuve
</h2>
<ul class="proof-list">
<li class="proof-row">
<UIcon name="i-lucide-fingerprint" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name cap-word">{{ PROOF_LOCAL }}</span>
<span class="proof-row__desc">
Chaque gravure calcule l'empreinte du texte décidé, vérifiable sur ta machine.
</span>
</span>
<span class="status-pill status-adopted">disponible</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-globe" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">IPFS</span>
<span class="proof-row__desc">Une copie de l'empreinte répliquée entre pairs.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-link" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">Chaîne Duniter</span>
<span class="proof-row__desc">L'empreinte ancrée dans la chaîne, pour de bon.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
</ul>
</section>
<p class="donnees__sync">{{ SYNC_NOTE }}</p>
</div>
</template>
<style scoped>
.donnees {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
width: min(100%, 46rem);
margin-inline: auto;
padding: clamp(1rem, 3vw, 2rem) clamp(0.875rem, 3vw, 1.5rem) 4.5rem;
}
.donnees__head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.donnees__title {
margin: 0;
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--mood-text);
}
.donnees__sub {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--mood-text-muted);
}
.cap-word {
display: inline-block;
}
.cap-word::first-letter {
text-transform: uppercase;
}
.seed-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.seed-issues {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.875rem;
font-weight: 600;
}
.seed-issues li {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.workshop {
display: flex;
align-items: center;
gap: 1rem;
}
.switch {
position: relative;
width: 3.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
border-radius: var(--r-pill);
cursor: pointer;
background: var(--mood-input-border);
transition: background 0.15s ease;
}
.switch--on {
background: var(--mood-accent);
}
.switch__thumb {
position: absolute;
top: 50%;
left: 0.3rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
background: var(--mood-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transform: translateY(-50%);
transition: left 0.15s ease;
}
.switch--on .switch__thumb {
left: calc(100% - 1.45rem);
}
.proof-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.proof-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
border-radius: var(--r-input);
background: var(--mood-bg);
}
.proof-row--off {
opacity: 0.55;
}
.proof-row__icon {
font-size: 1.25rem;
flex-shrink: 0;
color: var(--mood-accent);
}
.proof-row--off .proof-row__icon {
color: var(--mood-text-muted);
}
.proof-row__main {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.proof-row__name {
font-weight: 700;
color: var(--mood-text);
}
.proof-row__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.donnees__sync {
margin: 0;
text-align: center;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
@media (max-width: 480px) {
.proof-row {
flex-wrap: wrap;
}
}
</style>
File diff suppressed because it is too large Load Diff
-565
View File
@@ -1,565 +0,0 @@
<script setup lang="ts">
const auth = useAuthStore()
const router = useRouter()
const { $api } = useApi()
const address = ref('')
const step = ref<'input' | 'challenge' | 'signing' | 'success'>('input')
const errorMessage = ref('')
// Dev profiles
interface DevProfile {
address: string
display_name: string
wot_status: string
is_smith: boolean
is_techcomm: boolean
}
const devProfiles = ref<DevProfile[]>([])
const devLoading = ref(false)
async function loadDevProfiles() {
try {
devProfiles.value = await $api<DevProfile[]>('/auth/dev/profiles')
} catch {
// Not in dev mode or endpoint unavailable
}
}
function statusLabel(p: DevProfile): string {
const parts: string[] = []
parts.push(p.wot_status === 'member' ? 'Membre WoT' : 'Observateur')
if (p.is_smith) parts.push('Forgeron')
if (p.is_techcomm) parts.push('ComTech')
return parts.join(' · ')
}
function statusColor(p: DevProfile): string {
if (p.is_techcomm) return 'var(--mood-info, #3b82f6)'
if (p.is_smith) return 'var(--mood-warning, #f59e0b)'
if (p.wot_status === 'member') return 'var(--mood-success, #22c55e)'
return 'var(--mood-text-muted, #888)'
}
async function loginAsProfile(p: DevProfile) {
devLoading.value = true
address.value = p.address
errorMessage.value = ''
step.value = 'challenge'
try {
step.value = 'signing'
// Dev mode: bypass extension — backend accepte toute signature pour les profils dev
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
step.value = 'success'
setTimeout(() => router.push('/'), 800)
} catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur connexion dev'
step.value = 'input'
} finally {
devLoading.value = false
}
}
async function handleLogin() {
if (!address.value.trim()) {
errorMessage.value = 'Veuillez entrer votre adresse Duniter'
return
}
errorMessage.value = ''
step.value = 'challenge'
try {
step.value = 'signing'
await auth.login(address.value.trim())
step.value = 'success'
setTimeout(() => {
router.push('/')
}, 800)
} catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur lors de la connexion'
step.value = 'input'
}
}
const steps = computed(() => [
{ label: 'Adresse', done: step.value !== 'input' },
{ label: 'Challenge', done: step.value === 'signing' || step.value === 'success' },
{ label: 'Signature', done: step.value === 'success' },
{ label: 'OK', done: false },
])
const activeStepIndex = computed(() => {
switch (step.value) {
case 'input': return 0
case 'challenge': return 1
case 'signing': return 2
case 'success': return 3
default: return 0
}
})
const isProtoMode = computed(() => devProfiles.value.length > 0)
onMounted(() => {
if (auth.isAuthenticated) {
router.push('/')
}
loadDevProfiles()
})
</script>
<template>
<div class="login-page">
<div class="login-card">
<!-- Logo -->
<div class="login-card__header">
<div class="login-card__logo">
<UIcon name="i-lucide-gavel" class="login-card__logo-icon" />
</div>
<h1 class="login-card__title">Connexion</h1>
<p class="login-card__subtitle">
Duniter V2 · Ed25519
</p>
</div>
<!-- Steps indicator -->
<div class="login-steps">
<div
v-for="(s, i) in steps"
:key="i"
class="login-step"
:class="{
'login-step--done': s.done,
'login-step--active': i === activeStepIndex,
}"
>
<div class="login-step__dot">
<UIcon v-if="s.done" name="i-lucide-check" />
<span v-else class="login-step__num">{{ i + 1 }}</span>
</div>
<span class="login-step__label">{{ s.label }}</span>
</div>
</div>
<!-- Input -->
<div class="login-card__field">
<label class="login-card__label">Adresse Duniter (SS58)</label>
<input
v-model="address"
type="text"
class="login-card__input"
placeholder="5GrwvaEF5zXb26Fz9rcQpDWS57Ct..."
:disabled="auth.loading || step !== 'input'"
@keydown.enter="handleLogin"
/>
</div>
<!-- Error -->
<div v-if="errorMessage || auth.error" class="login-card__error">
<UIcon name="i-lucide-alert-circle" />
<span>{{ errorMessage || auth.error }}</span>
</div>
<!-- Success -->
<div v-if="step === 'success'" class="login-card__success">
<UIcon name="i-lucide-check-circle" />
<span>Connecte. Redirection...</span>
</div>
<!-- Mode prototype : profils démo -->
<template v-if="isProtoMode">
<div class="proto-panel">
<div class="proto-panel__header">
<UIcon name="i-lucide-flask-conical" />
<span>Mode prototype sélectionnez un profil</span>
</div>
<div class="proto-panel__profiles">
<button
v-for="p in devProfiles"
:key="p.address"
class="dev-profile"
:disabled="devLoading || step === 'success'"
@click="loginAsProfile(p)"
>
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
<div class="dev-profile__info">
<span class="dev-profile__name">{{ p.display_name }}</span>
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
</div>
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button>
</div>
<p class="proto-panel__note">
Authentification trustWallet à venir intégration librodrome
</p>
</div>
</template>
<!-- Mode production : formulaire + extension -->
<template v-else>
<button
class="login-card__btn"
:disabled="!address.trim() || step === 'success' || auth.loading"
@click="handleLogin"
>
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-log-in" />
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique.
</p>
</template>
</div>
</div>
</template>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
.login-page {
min-height: 70vh;
padding: 2rem 1rem;
}
}
.login-card {
width: 100%;
max-width: 26rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
@media (min-width: 640px) {
.login-card {
gap: 1.5rem;
}
}
.login-card__header {
text-align: center;
}
.login-card__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
font-size: 1.75rem;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 14px;
margin-bottom: 1rem;
transform: rotate(-10deg);
}
.login-card__logo-icon {
transform: scaleX(-1);
}
.login-card__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin-top: 0.375rem;
}
@media (min-width: 640px) {
.login-card__subtitle {
font-size: 0.9375rem;
}
}
.login-card__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
@media (min-width: 640px) {
.login-card__title {
font-size: 1.75rem;
}
}
/* Steps */
.login-steps {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding: 0.75rem 0;
}
.login-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
flex: 1;
}
.login-step__dot {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
transition: all 0.2s ease;
}
.login-step--active .login-step__dot {
color: var(--mood-accent);
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.login-step--done .login-step__dot {
background: var(--mood-success);
color: white;
}
.login-step__num {
font-size: 0.75rem;
}
.login-step__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-align: center;
}
.login-step--active .login-step__label {
color: var(--mood-accent);
font-weight: 700;
}
/* Field */
.login-card__field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.login-card__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.login-card__input {
width: 100%;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, 'Cascadia Code', monospace;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
/* Prevent iOS zoom on focus */
-webkit-text-size-adjust: 100%;
}
@media (min-width: 640px) {
.login-card__input {
padding: 0.75rem 1rem;
font-size: 0.9375rem;
}
}
.login-card__input:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.login-card__input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-card__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
/* Messages */
.login-card__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error);
background: rgba(196, 43, 43, 0.08);
border-radius: 12px;
}
.login-card__success {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-success);
background: rgba(24, 132, 59, 0.08);
border-radius: 12px;
}
/* Button */
.login-card__btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
padding: 1rem 1.25rem;
font-size: 1rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
min-height: 3rem;
}
@media (min-width: 640px) {
.login-card__btn {
padding: 0.875rem 1.25rem;
font-size: 1.0625rem;
}
}
.login-card__btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--mood-shadow);
}
.login-card__btn:active:not(:disabled) {
transform: translateY(0);
}
.login-card__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Proto panel */
.proto-panel {
border-radius: 16px;
padding: 1rem;
background: var(--mood-accent-soft);
box-shadow: 0 2px 12px var(--mood-shadow, rgba(0,0,0,0.06));
}
.proto-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.proto-panel__profiles {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-panel__note {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
opacity: 0.7;
text-align: center;
}
.dev-profile {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
text-align: left;
}
.dev-profile:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 12px var(--mood-shadow, rgba(0,0,0,0.08));
}
.dev-profile:active:not(:disabled) {
transform: translateY(0);
}
.dev-profile:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dev-profile__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
.dev-profile__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.dev-profile__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.dev-profile__status {
font-size: 0.6875rem;
color: var(--mood-text-muted);
font-weight: 600;
}
.dev-profile__addr {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
opacity: 0.6;
flex-shrink: 0;
}
/* Note */
.login-card__note {
text-align: center;
font-size: 0.8125rem;
color: var(--mood-text-muted);
opacity: 0.7;
}
</style>
-436
View File
@@ -1,436 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const mandates = useMandatesStore()
const { $api } = useApi()
const mandateId = computed(() => route.params.id as string)
onMounted(async () => {
await mandates.fetchById(mandateId.value)
})
onUnmounted(() => {
mandates.clearCurrent()
})
watch(mandateId, async (newId) => {
if (newId) await mandates.fetchById(newId)
})
// --- Helpers ---
const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t)
function formatDate(d: string | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status))
const canRevoke = computed(() => mandates.current?.status === 'active')
const isDraft = computed(() => mandates.current?.status === 'draft')
// --- Advance ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
}
// --- Identity search (shared for assign + edit) ---
interface IdentityResult { id: string; address: string; display_name: string | null }
function useIdentitySearch() {
const query = ref('')
const results = ref<IdentityResult[]>([])
const searching = ref(false)
const selectedId = ref<string | null>(null)
const selectedLabel = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
async function search(q: string) {
if (q.length < 2) { results.value = []; return }
searching.value = true
try {
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { results.value = [] } finally { searching.value = false }
}
function onInput(q: string) {
query.value = q
selectedId.value = null
if (timer) clearTimeout(timer)
timer = setTimeout(() => search(q), 300)
}
function select(i: IdentityResult) {
selectedId.value = i.id
selectedLabel.value = i.display_name || i.address
query.value = i.display_name || i.address
results.value = []
}
function reset() {
query.value = ''
results.value = []
selectedId.value = null
selectedLabel.value = ''
}
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
}
// --- Assign mandatee ---
const showAssignModal = ref(false)
const assigning = ref(false)
const assignSearch = useIdentitySearch()
async function handleAssign() {
if (!assignSearch.selectedId.value) return
assigning.value = true
try {
await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value)
showAssignModal.value = false
assignSearch.reset()
} catch { /* store holds error */ } finally { assigning.value = false }
}
function openAssign() {
assignSearch.reset()
showAssignModal.value = true
}
// --- Revoke ---
const showRevokeConfirm = ref(false)
const revoking = ref(false)
async function handleRevoke() {
revoking.value = true
try {
await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false
} catch { /* store holds error */ } finally { revoking.value = false }
}
// --- Edit ---
const showEditModal = ref(false)
const editData = ref({ title: '', origin_id: null as string | null, description: '' })
const editOriginSearch = useIdentitySearch()
const saving = ref(false)
function openEdit() {
if (!mandates.current) return
editData.value = {
title: mandates.current.title,
origin_id: mandates.current.origin_id,
description: mandates.current.description ?? '',
}
if (mandates.current.origin_display_name) {
editOriginSearch.query.value = mandates.current.origin_display_name
editOriginSearch.selectedId.value = mandates.current.origin_id
} else {
editOriginSearch.reset()
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await mandates.update(mandateId.value, {
title: editData.value.title,
origin_id: editOriginSearch.selectedId.value ?? editData.value.origin_id,
description: editData.value.description || null,
})
showEditModal.value = false
} catch { /* store holds error */ } finally { saving.value = false }
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
async function handleDelete() {
deleting.value = true
try {
await mandates.delete(mandateId.value)
navigateTo('/mandates')
} catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false }
}
</script>
<template>
<div class="space-y-6">
<div>
<UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" />
</div>
<template v-if="mandates.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
</div>
</div>
</template>
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ mandates.error }}</p>
</div>
</UCard>
</template>
<template v-else-if="mandates.current">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge>
<StatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
<div class="flex items-center gap-2">
<UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" />
<UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" />
<UButton icon="i-lucide-pen-line" label="Modifier" variant="soft" color="neutral" size="sm" @click="openEdit" />
<UButton v-if="canRevoke" icon="i-lucide-shield-off" label="Revoquer" variant="soft" color="error" size="sm" @click="showRevokeConfirm = true" />
<UButton v-if="isDraft" icon="i-lucide-trash-2" label="Supprimer" variant="soft" color="error" size="sm" @click="showDeleteConfirm = true" />
</div>
</div>
<!-- Error feedback -->
<div v-if="mandates.error" class="text-sm text-red-500 bg-red-50 dark:bg-red-950 px-4 py-2 rounded-lg">
{{ mandates.error }}
</div>
<UCard v-if="mandates.current.description">
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p>
</div>
</UCard>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template>
<template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}</span></template>
<template v-else><span class="text-gray-400 italic">Non assigne</span></template>
</p>
</div>
<div>
<p class="text-gray-500">Origine</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.origin_display_name">{{ mandates.current.origin_display_name }}</template>
<template v-else><span class="text-gray-400 italic">Non renseigné</span></template>
</p>
</div>
<div>
<p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p>
</div>
<div>
<p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p>
</div>
</div>
</UCard>
<UCard>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p>
</div>
</div>
</UCard>
<!-- Steps -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2>
<MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" />
</div>
</template>
<!-- Modal : Assigner un mandataire -->
<UModal v-model:open="showAssignModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Rechercher un membre <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
:value="assignSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Nom ou adresse Duniter…"
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="assignSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in assignSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="assignSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
:disabled="!assignSearch.selectedId.value || assigning"
@click="handleAssign"
>
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
Assigner
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Révoquer -->
<UModal v-model:open="showRevokeConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
:disabled="revoking"
@click="handleRevoke"
>
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
Revoquer
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Modifier -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<input v-model="editData.title" type="text" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Origine</label>
<div class="relative">
<input
:value="editOriginSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Rechercher un membre…"
@input="editOriginSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="editOriginSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in editOriginSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="editOriginSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<span>{{ r.display_name || r.address }}</span>
</button>
</div>
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea v-model="editData.description" rows="4" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
:disabled="!editData.title?.trim() || saving"
@click="saveEdit"
>
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
Enregistrer
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Supprimer -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
:disabled="deleting"
@click="handleDelete"
>
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
Supprimer
</button>
</div>
</div>
</template>
</UModal>
</div>
</template>
-637
View File
@@ -1,637 +0,0 @@
<script setup lang="ts">
/**
* Mandats — page index.
*
* Utilise SectionLayout avec status filters, recherche,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
* État vide enrichi avec onboarding expliquant le concept de mandat.
*/
import type { MandateCreate } from '~/stores/mandates'
const mandates = useMandatesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
// Create mandate modal state
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comité technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalisé', value: 'custom' },
]
const newMandate = ref<MandateCreate>({
title: '',
description: '',
mandate_type: 'techcomm',
})
const creating = ref(false)
onMounted(async () => {
await Promise.all([
mandates.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
])
/** Map for status group filtering. */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft', 'candidacy'],
voting: ['voting'],
active: ['active', 'reporting'],
closed: ['completed', 'revoked'],
}
/** Filtered and sorted mandates. */
const filteredMandates = computed(() => {
let list = [...mandates.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const allowedStatuses = statusGroupMap[activeStatus.value]!
list = list.filter(m => allowedStatuses.includes(m.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(m => m.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'statutory': return 'Statutaire'
case 'functional': return 'Fonctionnel'
case 'techcomm': return 'Comité technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalisé'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
async function handleCreate() {
creating.value = true
try {
const mandate = await mandates.create(newMandate.value)
showCreateModal.value = false
newMandate.value = { title: '', description: '', mandate_type: 'techcomm' }
if (mandate) {
navigateTo(`/mandates/${mandate.id}`)
}
}
catch {
// Error handled by store
}
finally {
creating.value = false
}
}
</script>
<template>
<SectionLayout
title="Mandats"
subtitle="Un contexte, un objectif, une durée, une ou plusieurs nominations ; par défaut : nomination d'un binôme."
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher un mandat..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<NuxtLink
v-if="auth.isAuthenticated"
to="/mandates/new"
class="action-btn"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</NuxtLink>
</template>
<!-- Main content: mandates list -->
<template #default>
<!-- Error state -->
<div v-if="mandates.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ mandates.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="mandates.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Onboarding empty state -->
<div
v-else-if="mandates.list.length === 0 && !activeStatus && !searchQuery"
class="mandate-onboarding"
>
<div class="mandate-onboarding__icon">
<UIcon name="i-lucide-user-check" class="text-3xl" />
</div>
<h3 class="mandate-onboarding__title">
Qu'est-ce qu'un mandat ?
</h3>
<p class="mandate-onboarding__text">
Un mandat définit un contexte, un objectif et une durée pour une mission de gouvernance.
Il peut porter sur le comité technique, les forgerons, ou tout rôle spécifique de la communauté.
</p>
<p class="mandate-onboarding__text">
Par défaut, un mandat nomme un binôme pour assurer la continuité.
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
</p>
<div class="mandate-onboarding__actions">
<UButton
v-if="auth.isAuthenticated"
to="/mandates/new"
label="Créer un premier mandat"
icon="i-lucide-plus"
color="primary"
size="sm"
/>
<UButton
to="/protocols"
label="Découvrir les protocoles"
variant="outline"
size="sm"
icon="i-lucide-wrench"
/>
</div>
</div>
<!-- Filtered empty state -->
<div
v-else-if="filteredMandates.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
<p>Aucun mandat trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Mandate cards -->
<div v-else class="space-y-3">
<div
v-for="mandate in filteredMandates"
:key="mandate.id"
class="mandate-card"
@click="navigateTo(`/mandates/${mandate.id}`)"
>
<div class="mandate-card__header">
<div class="mandate-card__title-block">
<h3 class="mandate-card__title">
{{ mandate.title }}
</h3>
<p v-if="mandate.description" class="mandate-card__description">
{{ mandate.description }}
</p>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<div class="mandate-card__meta">
<span class="mandate-card__type-badge">
{{ typeLabel(mandate.mandate_type) }}
</span>
<span class="mandate-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ mandate.steps.length }} étape{{ mandate.steps.length !== 1 ? 's' : '' }}
</span>
<span v-if="mandate.mandatee_id" class="mandate-card__mandatee">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="mandate-card__dates">
<span>Début : {{ formatDate(mandate.starts_at) }}</span>
<span>Fin : {{ formatDate(mandate.ends_at) }}</span>
</div>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Sociocratic election guide -->
<ToolboxSection title="Nomination & Élection" icon="i-lucide-users">
<SocioElection />
</ToolboxSection>
<!-- Mandat cycle -->
<ToolboxVignette
title="Cycle de mandat"
:bullets="[
'1. Ouverture + définition du rôle',
'2. Candidatures (auto ou par pairs)',
'3. Élection sociocratique',
'4. Période active + rapports',
'5. Renouvellement ou clôture',
]"
:actions="[
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
]"
@action="e => e === 'create' && navigateTo('/mandates/new')"
/>
<!-- Révocation -->
<ToolboxVignette
title="Révocation"
:bullets="[
'Initiée par 3 membres ou plus',
'Vote communautaire ordinaire',
'Bilan de clôture obligatoire',
]"
:actions="[
{ label: 'Voir', icon: 'i-lucide-shield-off', emit: 'revoke' },
]"
/>
</template>
</SectionLayout>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-4 sm:p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Titre <span style="color: var(--mood-error);">*</span>
</label>
<UInput
v-model="newMandate.title"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Type de mandat <span style="color: var(--mood-error);">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4" style="border-top: 1px solid var(--mood-border);">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Créer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template>
</UModal>
</template>
<style scoped>
.mandate-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.mandate-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.mandate-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.mandate-card:active {
transform: translateY(0);
}
.mandate-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.mandate-card__title-block {
flex: 1;
min-width: 0;
}
.mandate-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.mandate-card__title {
font-size: 1.0625rem;
}
}
.mandate-card__description {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.mandate-card__description {
font-size: 0.875rem;
}
}
.mandate-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.mandate-card__meta {
gap: 0.5rem;
}
}
.mandate-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mandate-card__mandatee {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mandate-card__dates {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
}
@media (min-width: 640px) {
.mandate-card__dates {
gap: 1rem;
font-size: 0.8125rem;
}
}
/* Onboarding empty state */
.mandate-onboarding {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2rem 1.25rem;
background: var(--mood-surface);
border-radius: 20px;
}
@media (min-width: 640px) {
.mandate-onboarding {
gap: 1rem;
padding: 3rem 2rem;
}
}
.mandate-onboarding__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.5rem;
}
@media (min-width: 640px) {
.mandate-onboarding__icon {
width: 4rem;
height: 4rem;
font-size: 1.75rem;
}
}
.mandate-onboarding__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
}
@media (min-width: 640px) {
.mandate-onboarding__title {
font-size: 1.375rem;
}
}
.mandate-onboarding__text {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
max-width: 32rem;
}
@media (min-width: 640px) {
.mandate-onboarding__text {
font-size: 0.9375rem;
}
}
.mandate-onboarding__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.mandate-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
</style>
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/[id] — le pouvoir confié, visible et reprenable.
// Origine cliquable (le mandat EST né d'une décision), faits comptés (Δ7),
// feux de la rampe, révocation en un geste au protocole d'origine.
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_REVOKE, NOMINATION_LABELS } from '~/lexicon'
import {
MANDATE_STATUS_LABELS,
MANDATE_STATUS_PILL,
} from '~/components/mandates/mandateUi'
const route = useRoute()
const col = useCollectiveStore()
const mandate = computed(() =>
col.mandates.find(m => m.id === String(route.params.id)),
)
const holder = computed(() =>
col.people.find(p => p.id === mandate.value?.holderId),
)
const originDecision = computed(() =>
col.decisions.find(d => d.id === mandate.value?.originDecisionId),
)
const domainCircles = computed(() =>
(mandate.value?.domain.circleIds ?? [])
.map(id => col.circles.find(c => c.id === id))
.filter(c => c !== undefined),
)
const electorCircle = computed(() =>
col.circles.find(c => c.id === mandate.value?.electorCircleId),
)
const revokeTo = computed(() =>
mandate.value ? `/decider?mandat=${mandate.value.id}&chain=revocation` : '/decider',
)
</script>
<template>
<!-- ld-v2 -->
<div v-if="mandate" class="mfiche">
<NuxtLink to="/mandats" class="mfiche__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<!-- En-tête : le pouvoir, explicite -->
<header class="mfiche__head ld-card">
<div class="mfiche__title-row">
<h1 class="mfiche__title">{{ mandate.title }}</h1>
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
{{ MANDATE_STATUS_LABELS[mandate.status] }}
</span>
</div>
<div v-if="holder" class="mfiche__holder">
<LdAvatarStack :people="[{ person: holder }]" :size="38" />
<div>
<p class="mfiche__holder-name">{{ holder.displayName }}</p>
<p class="mfiche__holder-role">titulaire</p>
</div>
</div>
<dl class="mfiche__meta">
<div class="mfiche__meta-row">
<dt>Origine</dt>
<dd>
<NuxtLink
v-if="originDecision"
:to="`/decisions/${originDecision.id}`"
class="mfiche__origin"
>
<UIcon name="i-lucide-scale" />
{{ originDecision.title }}
</NuxtLink>
<span v-else class="mfiche__muted">décision fondatrice hors de ce fichier</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Domaine</dt>
<dd class="mfiche__chips">
<span v-for="c in domainCircles" :key="c.id" class="mfiche__chip">
<UIcon name="i-lucide-circle-dashed" />
{{ c.name }}
</span>
<span v-for="tag in mandate.domain.tags" :key="tag" class="mfiche__tag">
#{{ tag }}
</span>
<span v-if="!domainCircles.length && !mandate.domain.tags.length" class="mfiche__muted">
domaine non précisé
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Cercle électeur</dt>
<dd>
<span class="mfiche__chip">
<UIcon name="i-lucide-users-round" />
{{ electorCircle?.name ?? '—' }}
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Nomination</dt>
<dd>{{ NOMINATION_LABELS[mandate.nominationMethod] }}</dd>
</div>
<div class="mfiche__meta-row mfiche__meta-row--period">
<dt>Période</dt>
<dd>
<MandatePeriodBar :starts-at="mandate.startsAt" :ends-at="mandate.endsAt" />
</dd>
</div>
</dl>
</header>
<!-- Exercice du mandat faits comptés -->
<MandateExercise :mandate="mandate" />
<!-- Feux de la rampe -->
<MandateSpotlights :mandate="mandate" />
<!-- Révocation en un geste jamais improvisée -->
<section v-if="mandate.status === 'active'" class="mfiche__revoke ld-card">
<NuxtLink :to="revokeTo" class="ld-btn ld-btn--ghost mfiche__revoke-btn">
<UIcon name="i-lucide-key-square" />
{{ MANDATE_REVOKE }}
</NuxtLink>
<p class="mfiche__revoke-note">
La révocation suit le protocole de nomination d'origine
({{ NOMINATION_LABELS[mandate.nominationMethod] }}), avec le cercle électeur
« {{ electorCircle?.name ?? '' }} » comme périmètre jamais plus coûteuse
que la nomination.
</p>
</section>
</div>
<div v-else class="mfiche mfiche--missing">
<p class="ld-card mfiche__missing-card">
Ce mandat est introuvable dans ce collectif.
<NuxtLink to="/mandats" class="mfiche__origin">Revenir aux mandats</NuxtLink>
</p>
</div>
</template>
<style scoped>
.mfiche {
max-width: 46rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.mfiche__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
align-self: flex-start;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
}
.mfiche__back:hover { color: var(--mood-text); }
.mfiche__head {
padding: 1.375rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mfiche__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mfiche__title {
margin: 0;
font-size: clamp(1.25rem, 3.5vw, 1.625rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.01em;
}
.mfiche__holder { display: flex; align-items: center; gap: 0.75rem; }
.mfiche__holder-name { margin: 0; font-size: 1rem; font-weight: 700; }
.mfiche__holder-role { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__meta { margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.mfiche__meta-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.75rem;
align-items: baseline;
}
.mfiche__meta-row dt {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.mfiche__meta-row dd { margin: 0; font-size: 0.9375rem; min-width: 0; }
.mfiche__meta-row--period dd { padding-top: 0.25rem; }
@media (max-width: 479px) {
.mfiche__meta-row { grid-template-columns: 1fr; gap: 0.125rem; }
}
.mfiche__origin {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.mfiche__origin:hover { text-decoration: underline; }
.mfiche__chips { display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: center; }
.mfiche__chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
}
.mfiche__tag { font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__muted { color: var(--mood-text-muted); font-style: italic; font-size: 0.875rem; }
.mfiche__revoke {
padding: 1.125rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.mfiche__revoke-btn { align-self: flex-start; color: var(--mood-status-revoque); }
.mfiche__revoke-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
}
.mfiche--missing { padding-top: 2rem; }
.mfiche__missing-card {
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats — la carte des pouvoirs confiés : qui peut décider
// quoi, jusqu'à quand. Cartes par statut, compteurs bruts (Δ7 : aucune jauge).
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATES_TITLE, MANDATE_CLAIM } from '~/lexicon'
import { MANDATE_STATUS_LABELS } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const ORDER: Mandate['status'][] = ['active', 'proposed', 'expired', 'revoked']
const groups = computed(() =>
ORDER
.map(status => ({
status,
label: MANDATE_STATUS_LABELS[status],
items: col.mandates
.filter(m => m.status === status)
.sort((a, b) => (a.endsAt < b.endsAt ? -1 : 1)),
}))
.filter(g => g.items.length > 0),
)
</script>
<template>
<!-- ld-v2 -->
<div class="mpage">
<header class="mpage__header">
<div>
<h1 class="mpage__title">{{ MANDATES_TITLE }}</h1>
<p class="mpage__sub">qui peut décider quoi, jusqu'à quand</p>
</div>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</header>
<template v-if="groups.length">
<section v-for="group in groups" :key="group.status" class="mpage__group">
<h2 class="mpage__group-title">
{{ group.label }}
<span class="mpage__count">{{ group.items.length }}</span>
</h2>
<div class="mpage__grid">
<MandateCard v-for="m in group.items" :key="m.id" :mandate="m" />
</div>
</section>
</template>
<div v-else class="mpage__empty ld-card">
<UIcon name="i-lucide-key-round" class="mpage__empty-icon" />
<p class="mpage__empty-title">Aucun pouvoir confié pour l'instant.</p>
<p class="mpage__empty-text">
Un mandat naît d'une décision : un rôle, un domaine, une durée bornée
et des comptes à rendre.
</p>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.mpage {
max-width: 64rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.mpage__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.mpage__title {
margin: 0;
font-size: clamp(1.375rem, 3.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.01em;
}
.mpage__sub {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.mpage__group { display: flex; flex-direction: column; gap: 0.875rem; }
.mpage__group-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.mpage__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.mpage__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.mpage__grid { grid-template-columns: 1fr 1fr; }
}
.mpage__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 3rem 1.5rem;
text-align: center;
}
.mpage__empty-icon { font-size: 2rem; color: var(--mood-accent); }
.mpage__empty-title { margin: 0; font-size: 1.0625rem; font-weight: 700; }
.mpage__empty-text {
margin: 0 0 0.625rem;
max-width: 26rem;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.55;
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/nouveau — wizard 3 étapes porté du v1 (la structure
// à pépite : pills de progression, cartes cliquables, transitions), branché sur
// le pivot : la nomination EST une décision (createsMandate, route collective).
// Pré-remplissage par query ?titre=&tags= (suggestion R6 du chemin).
import type { Id, MandateDraft, NominationMethod, TriageInput } from '~/types/domain'
import type { ApplyPathEdits } from '~/stores/decisions'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { MANDATE_CLAIM, NOMINATION_LABELS } from '~/lexicon'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
const STEPS = ['Rôle et domaine', 'Nomination', 'Durée et comptes'] as const
const step = ref(0)
// ── Étape 1 : rôle et domaine ──
const title = ref(typeof route.query.titre === 'string' ? route.query.titre : '')
const mission = ref('')
const circleIds = ref<Id[]>([])
const tags = ref<string[]>(
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: [],
)
const tagInput = ref('')
function toggleCircle(id: Id): void {
circleIds.value = circleIds.value.includes(id)
? circleIds.value.filter(c => c !== id)
: [...circleIds.value, id]
}
function addTag(): void {
const tag = tagInput.value.trim().replace(/^#/, '').toLowerCase()
if (tag && !tags.value.includes(tag)) tags.value.push(tag)
tagInput.value = ''
}
function removeTag(tag: string): void {
tags.value = tags.value.filter(t => t !== tag)
}
// ── Étape 2 : modalité de nomination (6) ──
const method = ref<NominationMethod | null>(null)
// ── Étape 3 : durée bornée, cadence, cercle électeur ──
const durationDays = ref(180)
const withReports = ref(true)
const reportEveryDays = ref(90)
const electorCircleId = ref<Id>(col.current?.collective.rootCircleId ?? '')
const canNext = computed(() => {
if (step.value === 0) return title.value.trim().length > 0
if (step.value === 1) return method.value !== null
return durationDays.value >= 1 && electorCircleId.value !== ''
})
const electorCircle = computed(() => col.circles.find(c => c.id === electorCircleId.value))
const domainNames = computed(() =>
circleIds.value
.map(id => col.circles.find(c => c.id === id)?.name)
.filter(n => n !== undefined),
)
/** La modalité choisie résout le protocole via le Pacte (repli : consentement). */
function protocolFor(m: NominationMethod): Id | undefined {
const byRange = col.settings?.protocolByRange
if (!byRange) return undefined
if (m === 'election-no-candidate') return byRange.election ?? byRange.consent
if (m === 'nuanced-vote') return byRange.nuanced ?? byRange.consent
return byRange.consent // ratification par consentement (auto-désignation, tirage, rotation)
}
// ── Sortie : une Decision createsMandate, route collective ──
const submitting = ref(false)
const error = ref<string | null>(null)
function submit(): void {
if (!method.value || submitting.value) return
submitting.value = true
error.value = null
const captured = decisions.capture(`Confier le mandat « ${title.value.trim()} »`)
if ('ok' in captured) { error.value = captured.reason; submitting.value = false; return }
const scope = {
selfOnly: false,
// Le cercle électeur EN PREMIER : il devient le périmètre d'élection et de révocation.
circleIds: [electorCircleId.value, ...circleIds.value.filter(id => id !== electorCircleId.value)],
personIds: [] as Id[],
}
const input: TriageInput = {
title: captured.title,
tags: tags.value,
scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}
const path = decisions.runTriage(input)
if ('ok' in path) { error.value = path.reason; submitting.value = false; return }
const draft: MandateDraft = {
title: title.value.trim(),
domainCircleIds: [...circleIds.value],
domainTags: [...tags.value],
durationDays: durationDays.value,
...(withReports.value ? { reportEveryDays: reportEveryDays.value } : {}),
}
const protocolId = protocolFor(method.value)
const edits: ApplyPathEdits = {
route: 'collective',
scope,
reversibility: 'costly',
weight: 'binding',
tags: [...tags.value],
visibility: 'collective',
createsMandate: draft,
...(mission.value.trim() ? { body: mission.value.trim() } : {}),
...(protocolId !== undefined ? { protocolId } : {}),
}
const applied = decisions.applyPath(captured, path, edits)
if ('ok' in applied) { error.value = applied.reason; submitting.value = false; return }
navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="mwiz">
<nav class="mwiz__nav">
<button v-if="step > 0" type="button" class="mwiz__back" @click="step--">
<UIcon name="i-lucide-arrow-left" />
Retour
</button>
<NuxtLink v-else to="/mandats" class="mwiz__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<div class="mwiz__progress">
<template v-for="(label, i) in STEPS" :key="label">
<span v-if="i > 0" class="mwiz__sep"></span>
<span
class="mwiz__pill"
:class="{ 'mwiz__pill--active': step === i, 'mwiz__pill--done': step > i }"
>
{{ i + 1 }} · {{ label }}
</span>
</template>
</div>
</nav>
<Transition name="slide-fade" mode="out-in">
<!-- ÉTAPE 1 : rôle et domaine -->
<div v-if="step === 0" key="role" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">{{ MANDATE_CLAIM }}</h1>
<p class="mwiz__sub">Un rôle nommé, un domaine explicite le pouvoir se voit.</p>
</header>
<label class="mwiz__label" for="mwiz-title">Titre du rôle</label>
<input
id="mwiz-title"
v-model="title"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Trésorerie, Animation du jardin…"
>
<label class="mwiz__label" for="mwiz-mission">La mission, en une phrase</label>
<input
id="mwiz-mission"
v-model="mission"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Tenir les comptes et rendre la caisse lisible par tous."
>
<p class="mwiz__label">Cercles du domaine</p>
<div class="mwiz__chips">
<button
v-for="c in col.circles"
:key="c.id"
type="button"
class="mwiz__chip"
:class="{ 'mwiz__chip--on': circleIds.includes(c.id) }"
@click="toggleCircle(c.id)"
>
{{ c.name }}
</button>
</div>
<label class="mwiz__label" for="mwiz-tag">Tags du domaine</label>
<div class="mwiz__chips">
<span v-for="tag in tags" :key="tag" class="mwiz__chip mwiz__chip--on">
#{{ tag }}
<button type="button" class="mwiz__chip-x" :aria-label="`Retirer ${tag}`" @click="removeTag(tag)">
<UIcon name="i-lucide-x" />
</button>
</span>
<input
id="mwiz-tag"
v-model="tagInput"
type="text"
class="mwiz__input mwiz__input--tag"
placeholder="ajouter un tag ⏎"
@keydown.enter.prevent="addTag"
@blur="addTag"
>
</div>
</div>
<!-- ÉTAPE 2 : modalité de nomination -->
<div v-else-if="step === 1" key="nomination" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Comment nommer ?</h1>
<p class="mwiz__sub">Six modalités, chacune avec sa pédagogie le cercle électeur tranchera.</p>
</header>
<MandateNominationPicker v-model="method" />
</div>
<!-- ÉTAPE 3 : durée bornée, cadence, cercle électeur -->
<div v-else key="terms" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Borné, redevable</h1>
<p class="mwiz__sub">Un mandat a toujours une fin et des comptes à rendre avant.</p>
</header>
<label class="mwiz__label" for="mwiz-duration">Durée du mandat (jours)</label>
<div class="mwiz__row">
<input
id="mwiz-duration"
v-model.number="durationDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
max="730"
>
<input
v-model.number="durationDays"
type="range"
class="mwiz__slider"
min="7"
max="730"
step="1"
aria-label="Durée du mandat en jours"
>
</div>
<label class="mwiz__check">
<input v-model="withReports" type="checkbox" class="mwiz__checkbox">
<span>Avec rapports réguliers</span>
</label>
<div v-if="withReports" class="mwiz__row">
<label class="mwiz__label mwiz__label--inline" for="mwiz-cadence">tous les</label>
<input
id="mwiz-cadence"
v-model.number="reportEveryDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
:max="durationDays"
>
<span class="mwiz__unit">jours</span>
</div>
<label class="mwiz__label" for="mwiz-elector">Cercle électeur il nomme, il révoque</label>
<select id="mwiz-elector" v-model="electorCircleId" class="mwiz__input">
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div class="mwiz__recap">
<p class="mwiz__recap-title">{{ title }}</p>
<p v-if="mission" class="mwiz__recap-line">{{ mission }}</p>
<p class="mwiz__recap-line">
{{ method ? NOMINATION_LABELS[method] : '—' }} ·
{{ durationDays }} jours<template v-if="withReports"> · rapport tous les {{ reportEveryDays }} jours</template>
</p>
<p v-if="domainNames.length || tags.length" class="mwiz__recap-line">
Domaine : {{ [...domainNames, ...tags.map(t => `#${t}`)].join(' · ') }}
</p>
<p class="mwiz__recap-note">
La nomination est une décision elle part au vote du cercle
« {{ electorCircle?.name ?? '—' }} ».
</p>
</div>
<p v-if="error" class="mwiz__error">{{ error }}</p>
</div>
</Transition>
<div class="mwiz__actions">
<button
v-if="step < 2"
type="button"
class="ld-btn"
:disabled="!canNext"
@click="step++"
>
Continuer
<UIcon name="i-lucide-arrow-right" />
</button>
<button
v-else
type="button"
class="ld-btn"
:disabled="!canNext || !method || submitting"
@click="submit"
>
<UIcon name="i-lucide-scale" />
{{ submitting ? 'Ouverture…' : 'Soumettre au cercle électeur' }}
</button>
</div>
</div>
</template>
<style scoped>
.mwiz { max-width: 46rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
.mwiz__nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.mwiz__back {
display: inline-flex; align-items: center; gap: 0.375rem;
font-size: 0.875rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-decoration: none;
}
.mwiz__back:hover { color: var(--mood-text); }
.mwiz__progress { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; flex-wrap: wrap; }
.mwiz__sep { color: var(--mood-text-muted); font-size: 0.75rem; }
.mwiz__pill {
font-size: 0.75rem; font-weight: 600; padding: 0.25rem 0.625rem;
border-radius: var(--r-pill); color: var(--mood-text-muted);
background: var(--mood-surface); white-space: nowrap;
}
.mwiz__pill--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__pill--done { background: var(--mood-accent-soft); color: var(--mood-accent); }
.mwiz__step { display: flex; flex-direction: column; gap: 0.75rem; }
.mwiz__header { margin-bottom: 0.5rem; }
.mwiz__title { margin: 0 0 0.25rem; font-size: clamp(1.25rem, 3vw, 1.625rem); font-weight: 800; }
.mwiz__sub { margin: 0; font-size: 0.9375rem; color: var(--mood-text-muted); }
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
.mwiz__label--inline { margin-top: 0; }
.mwiz__input {
width: 100%; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
color: var(--mood-text); background: var(--mood-input-bg); border: none;
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.mwiz__input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.mwiz__input--num { width: 6rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.mwiz__input--tag { width: 11rem; padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.mwiz__row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.mwiz__slider { flex: 1; min-width: 10rem; accent-color: var(--mood-accent); }
.mwiz__unit { font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.mwiz__chip {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.875rem; font-size: 0.875rem; font-weight: 600;
color: var(--mood-text-muted); background: var(--mood-surface);
border-radius: var(--r-pill); box-shadow: var(--shadow-card);
cursor: pointer; transition: transform 0.1s ease;
}
.mwiz__chip:hover { transform: translateY(-1px); }
.mwiz__chip--on { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__chip-x { display: inline-flex; background: none; color: inherit; cursor: pointer; padding: 0; }
.mwiz__check {
display: flex; align-items: center; gap: 0.625rem; margin-top: 0.375rem;
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
}
.mwiz__checkbox { width: 1.125rem; height: 1.125rem; accent-color: var(--mood-accent); }
.mwiz__recap {
margin-top: 0.75rem; padding: 1.125rem 1.25rem; border-radius: var(--r-card);
background: var(--mood-surface); box-shadow: var(--shadow-card);
display: flex; flex-direction: column; gap: 0.375rem;
}
.mwiz__recap-title { margin: 0; font-size: 1.0625rem; font-weight: 800; }
.mwiz__recap-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__recap-note { margin: 0.375rem 0 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-accent); }
.mwiz__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.mwiz__actions { display: flex; justify-content: flex-end; }
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
.slide-fade-leave-to { opacity: 0; transform: translateX(-20px); }
</style>
-180
View File
@@ -1,180 +0,0 @@
<script setup lang="ts">
/**
* Protocol detail page.
*
* Displays full protocol information including name, type, description,
* mode params, formula config, and links to the formula simulator.
*/
const route = useRoute()
const protocols = useProtocolsStore()
const votes = useVotesStore()
const protocolId = computed(() => route.params.id as string)
onMounted(async () => {
await protocols.fetchProtocolById(protocolId.value)
})
const protocol = computed(() => protocols.currentProtocol)
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuance'
default: return voteType
}
}
const voteTypeColor = (voteType: string) => {
switch (voteType) {
case 'binary': return 'primary'
case 'nuanced': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
/** Build simulator URL with prefilled params. */
const simulatorLink = computed(() => {
if (!protocol.value?.mode_params) return '/protocols/formulas'
return `/protocols/formulas`
})
</script>
<template>
<div class="space-y-8">
<!-- Header with back link -->
<div class="flex items-center gap-3">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Detail du protocole
</h1>
</div>
<!-- Loading -->
<template v-if="protocols.loading">
<div class="space-y-3">
<USkeleton class="h-12 w-3/4" />
<USkeleton class="h-6 w-1/2" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error -->
<template v-else-if="protocols.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ protocols.error }}</p>
</div>
</UCard>
</template>
<!-- Protocol detail -->
<template v-else-if="protocol">
<!-- Protocol header card -->
<UCard>
<div class="space-y-4">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ protocol.name }}
</h2>
<p v-if="protocol.description" class="text-gray-600 dark:text-gray-400 mt-1">
{{ protocol.description }}
</p>
<p class="text-xs text-gray-500 mt-2">
Cree le {{ formatDate(protocol.created_at) }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle">
Meta-gouverne
</UBadge>
</div>
</div>
</div>
</UCard>
<!-- Mode params -->
<UCard v-if="protocol.mode_params">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres du mode
</h3>
</template>
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</UCard>
<!-- Formula config -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Configuration de la formule : {{ protocol.formula_config.name }}
</h3>
<NuxtLink :to="simulatorLink">
<UButton variant="outline" size="sm" icon="i-lucide-calculator">
Simuler
</UButton>
</NuxtLink>
</div>
</template>
<FormulaDisplay :formula-config="protocol.formula_config" />
</UCard>
<!-- Meta-governance info -->
<UCard v-if="protocol.is_meta_governed">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield" class="text-2xl text-amber-500" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
Protocole meta-gouverne
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les modifications de ce protocole sont soumises au vote selon ses propres regles.
</p>
</div>
</div>
</UCard>
<!-- Related vote sessions -->
<UCard v-if="votes.sessions && votes.sessions.length > 0">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Sessions de vote utilisant ce protocole
</h3>
</template>
<div class="space-y-2">
<div
v-for="session in votes.sessions"
:key="session.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">
{{ session.id.slice(0, 8) }}...
</span>
<StatusBadge :status="session.status" type="vote" class="ml-2" />
</div>
<div class="text-sm text-gray-500">
{{ session.votes_total }} votes
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>
-304
View File
@@ -1,304 +0,0 @@
<script setup lang="ts">
/**
* Formula simulator page.
*
* Allows interactive adjustment of formula parameters with live threshold
* computation, a visual gauge, and a table of thresholds at various
* participation levels.
*/
import type { FormulaConfig } from '~/stores/protocols'
import { encodeModeParams } from '~/utils/mode-params'
const { computeThreshold, computeRequiredRatio, computeInertiaFactor } = useVoteFormula()
/** Default formula config for the simulator. */
const formulaConfig = ref<FormulaConfig>({
id: 'simulator',
name: 'Simulateur',
description: null,
duration_days: 30,
majority_pct: 50,
base_exponent: 0.1,
gradient_exponent: 0.2,
constant_base: 0,
smith_exponent: null,
techcomm_exponent: null,
nuanced_min_participants: null,
nuanced_threshold_pct: null,
created_at: new Date().toISOString(),
})
/** Simulation inputs. */
const wotSize = ref(7224)
const simulatedVotes = ref(120)
const simulatedFor = ref(97)
/** Computed threshold for current params. */
const threshold = computed(() => {
try {
return computeThreshold(wotSize.value, simulatedVotes.value, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
} catch {
return 0
}
})
/** Computed required ratio. */
const requiredRatio = computed(() => {
return computeRequiredRatio(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
})
/** Computed inertia factor. */
const inertiaFactor = computed(() => {
return computeInertiaFactor(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.gradient_exponent,
)
})
/** Simulated votes against. */
const simulatedAgainst = computed(() => {
return Math.max(0, simulatedVotes.value - simulatedFor.value)
})
/** Mode params string for current config. */
const modeParamsString = computed(() => {
try {
return encodeModeParams({
duration_days: formulaConfig.value.duration_days,
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
smith_exponent: formulaConfig.value.smith_exponent,
techcomm_exponent: formulaConfig.value.techcomm_exponent,
})
} catch {
return ''
}
})
/** Table of thresholds at various participation levels. */
const participationLevels = [10, 50, 100, 200, 500, 1000, 3000, 5000, 7000]
const thresholdTable = computed(() => {
return participationLevels
.filter(n => n <= wotSize.value)
.map(totalVotes => {
let t: number
let ratio: number
try {
t = computeThreshold(wotSize.value, totalVotes, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
ratio = computeRequiredRatio(
totalVotes,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
} catch {
t = 0
ratio = 0
}
const participation = ((totalVotes / wotSize.value) * 100).toFixed(2)
return {
totalVotes,
threshold: t,
ratio: (ratio * 100).toFixed(1),
participation,
}
})
})
/** Keep simulatedFor within bounds when simulatedVotes changes. */
watch(simulatedVotes, (newTotal) => {
if (simulatedFor.value > newTotal) {
simulatedFor.value = newTotal
}
})
</script>
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<div class="flex items-center gap-3 mb-2">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Simulateur de formule de seuil
</h1>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Ajustez les parametres de la formule et observez le seuil calcule en temps reel.
</p>
</div>
<!-- Current mode params -->
<UCard v-if="modeParamsString">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mode params :</span>
<ModeParamsDisplay :mode-params="modeParamsString" />
</div>
</UCard>
<!-- Formula editor -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres de la formule
</h2>
</template>
<FormulaEditor v-model="formulaConfig" />
</UCard>
<!-- Simulation inputs -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Simulation
</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- WoT size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Taille du corpus WoT (W)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ wotSize }}</span>
</div>
<UInput v-model.number="wotSize" type="number" :min="1" :max="100000" />
</div>
<!-- Total votes -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes totaux (T)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ simulatedVotes }}</span>
</div>
<URange v-model="simulatedVotes" :min="0" :max="wotSize" :step="1" />
</div>
<!-- Votes for -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes pour
</label>
<span class="text-sm font-mono font-bold text-green-600">{{ simulatedFor }}</span>
</div>
<URange v-model="simulatedFor" :min="0" :max="simulatedVotes" :step="1" />
</div>
</div>
</UCard>
<!-- Results -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Resultat
</h2>
</template>
<div class="space-y-6">
<!-- Threshold gauge -->
<ThresholdGauge
:votes-for="simulatedFor"
:votes-against="simulatedAgainst"
:threshold="threshold"
:wot-size="wotSize"
/>
<!-- Key metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Seuil requis</p>
<p class="text-2xl font-bold text-primary">{{ threshold }}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Ratio requis</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ (requiredRatio * 100).toFixed(1) }}%
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Facteur d'inertie</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ inertiaFactor.toFixed(4) }}
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Participation</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ ((simulatedVotes / wotSize) * 100).toFixed(2) }}%
</p>
</div>
</div>
</div>
</UCard>
<!-- Formula display -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Formule
</h2>
</template>
<FormulaDisplay :formula-config="formulaConfig" show-explanation />
</UCard>
<!-- Threshold table -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Seuils par niveau de participation
</h2>
</template>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votes totaux (T)</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Participation</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Ratio requis</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Seuil (votes pour)</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in thresholdTable"
:key="row.totalVotes"
class="border-b border-gray-100 dark:border-gray-800"
:class="row.totalVotes === simulatedVotes ? 'bg-primary-50 dark:bg-primary-900/20' : ''"
>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{{ row.totalVotes }}</td>
<td class="px-4 py-3 text-gray-600">{{ row.participation }}%</td>
<td class="px-4 py-3 text-gray-600">{{ row.ratio }}%</td>
<td class="px-4 py-3 font-mono font-bold text-primary">{{ row.threshold }}</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</template>
File diff suppressed because it is too large Load Diff
-445
View File
@@ -1,445 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const { $api } = useApi()
interface SanctuaryEntryDetail {
id: string
entry_type: string
reference_id: string
title: string | null
content_hash: string
ipfs_cid: string | null
chain_tx_hash: string | null
chain_block: number | null
metadata_json: string | null
created_at: string
}
interface VerifyResult {
match: boolean
message: string
}
const entryId = computed(() => route.params.id as string)
const entry = ref<SanctuaryEntryDetail | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const verifying = ref(false)
const verifyResult = ref<VerifyResult | null>(null)
const copied = ref<string | null>(null)
async function loadEntry() {
loading.value = true
error.value = null
try {
entry.value = await $api<SanctuaryEntryDetail>(`/sanctuary/${entryId.value}`)
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Entree introuvable'
} finally {
loading.value = false
}
}
async function verifyIntegrity() {
verifying.value = true
verifyResult.value = null
try {
verifyResult.value = await $api<VerifyResult>(
`/sanctuary/${entryId.value}/verify`,
)
} catch (err: any) {
verifyResult.value = {
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = false
}
}
async function copyToClipboard(text: string, field: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = field
setTimeout(() => { copied.value = null }, 2000)
} catch {
// Clipboard API not available
}
}
onMounted(() => {
loadEntry()
})
watch(entryId, () => {
loadEntry()
})
const typeLabel = (entryType: string): string => {
switch (entryType) {
case 'document': return 'Document'
case 'decision': return 'Decision'
case 'vote_result': return 'Resultat de vote'
default: return entryType
}
}
const typeColor = (entryType: string): string => {
switch (entryType) {
case 'document': return 'primary'
case 'decision': return 'success'
case 'vote_result': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const parsedMetadata = computed(() => {
if (!entry.value?.metadata_json) return null
try {
return JSON.parse(entry.value.metadata_json)
} catch {
return null
}
})
const formattedMetadata = computed(() => {
if (!parsedMetadata.value) return null
return JSON.stringify(parsedMetadata.value, null, 2)
})
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/sanctuary"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour au sanctuaire"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Entry detail -->
<template v-else-if="entry">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UIcon name="i-lucide-shield-check" class="text-2xl text-primary" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h1>
</div>
<div class="flex items-center gap-3">
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle">
{{ typeLabel(entry.entry_type) }}
</UBadge>
<span class="text-sm text-gray-500">
{{ formatDate(entry.created_at) }}
</span>
</div>
</div>
<!-- Verify button -->
<UButton
label="Verifier l'integrite"
icon="i-lucide-shield-check"
color="primary"
:loading="verifying"
@click="verifyIntegrity"
/>
</div>
<!-- Verification result -->
<UCard v-if="verifyResult">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-2xl' : 'text-red-500 text-2xl'"
/>
<div>
<p
:class="verifyResult.match
? 'text-green-700 dark:text-green-400 font-semibold'
: 'text-red-700 dark:text-red-400 font-semibold'"
>
{{ verifyResult.match ? 'Integrite verifiee avec succes' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500 mt-1">{{ verifyResult.message }}</p>
</div>
</div>
</UCard>
<!-- SHA-256 Hash -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Hash SHA-256</h2>
</div>
<UButton
:icon="copied === 'hash' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'hash' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.content_hash, 'hash')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.content_hash }}
</p>
</div>
</div>
</UCard>
<!-- IPFS CID -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hard-drive" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">IPFS CID</h2>
</div>
<UButton
v-if="entry.ipfs_cid"
:icon="copied === 'ipfs' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'ipfs' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.ipfs_cid!, 'ipfs')"
/>
</div>
<template v-if="entry.ipfs_cid">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.ipfs_cid }}
</p>
</div>
<a
:href="`${IPFS_GATEWAY}${entry.ipfs_cid}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<UIcon name="i-lucide-external-link" />
<span>Ouvrir sur la passerelle IPFS</span>
</a>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'epinglage IPFS
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Chain Anchor -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-link" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Ancrage On-chain</h2>
</div>
<UButton
v-if="entry.chain_tx_hash"
:icon="copied === 'chain' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'chain' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.chain_tx_hash!, 'chain')"
/>
</div>
<template v-if="entry.chain_tx_hash">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 space-y-2">
<div>
<p class="text-xs text-gray-500 mb-1">TX Hash</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.chain_tx_hash }}
</p>
</div>
<div v-if="entry.chain_block">
<p class="text-xs text-gray-500 mb-1">Numero de bloc</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300">
#{{ entry.chain_block.toLocaleString('fr-FR') }}
</p>
</div>
</div>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'ancrage on-chain via system.remark
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Verification status -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-check-circle" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Statut de verification</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.content_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-gray-50 dark:bg-gray-800/50'"
>
<UIcon
:name="entry.content_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.content_hash ? 'text-green-500' : 'text-gray-400'"
/>
<div>
<p class="text-sm font-medium" :class="entry.content_hash ? 'text-green-700 dark:text-green-400' : 'text-gray-500'">
Hash SHA-256
</p>
<p class="text-xs text-gray-500">
{{ entry.content_hash ? 'Calcule' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.ipfs_cid
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.ipfs_cid ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
IPFS
</p>
<p class="text-xs text-gray-500">
{{ entry.ipfs_cid ? 'Epingle' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.chain_tx_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.chain_tx_hash ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
On-chain
</p>
<p class="text-xs text-gray-500">
{{ entry.chain_tx_hash ? 'Ancre' : 'En attente' }}
</p>
</div>
</div>
</div>
</div>
</UCard>
<!-- Metadata JSON -->
<UCard v-if="entry.metadata_json">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-braces" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Metadonnees</h2>
</div>
<UButton
v-if="entry.metadata_json"
:icon="copied === 'metadata' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'metadata' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.metadata_json!, 'metadata')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 overflow-x-auto">
<pre class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ formattedMetadata || entry.metadata_json }}</pre>
</div>
</div>
</UCard>
<!-- Reference info -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Reference</h2>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Identifiant</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.id }}</p>
</div>
<div>
<p class="text-gray-500">Reference (document/decision)</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.reference_id }}</p>
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>
-193
View File
@@ -1,193 +0,0 @@
<script setup lang="ts">
import type { SanctuaryEntryOut } from '~/components/sanctuary/SanctuaryEntry.vue'
const { $api } = useApi()
const entries = ref<SanctuaryEntryOut[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filterType = ref<string | undefined>(undefined)
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Document', value: 'document' },
{ label: 'Decision', value: 'decision' },
{ label: 'Resultat de vote', value: 'vote_result' },
]
// Verification state
const verifying = ref<string | null>(null)
const verifyResult = ref<{ id: string; match: boolean; message: string } | null>(null)
async function loadEntries() {
loading.value = true
error.value = null
try {
const query: Record<string, string> = {}
if (filterType.value) query.entry_type = filterType.value
entries.value = await $api<SanctuaryEntryOut[]>('/sanctuary/', { query })
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
} finally {
loading.value = false
}
}
async function handleVerify(id: string) {
verifying.value = id
verifyResult.value = null
try {
const result = await $api<{ match: boolean; message: string }>(
`/sanctuary/${id}/verify`,
)
verifyResult.value = { id, ...result }
} catch (err: any) {
verifyResult.value = {
id,
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = null
}
}
onMounted(() => {
loadEntries()
})
watch(filterType, () => {
loadEntries()
})
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Sanctuaire
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Archive immuable : documents et decisions ancres sur IPFS avec preuve on-chain via system.remark
</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="typeOptions"
placeholder="Type d'entree"
class="w-56"
/>
</div>
<!-- Verification result banner -->
<UCard v-if="verifyResult">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-xl' : 'text-red-500 text-xl'"
/>
<div>
<p :class="verifyResult.match ? 'text-green-700 dark:text-green-400 font-medium' : 'text-red-700 dark:text-red-400 font-medium'">
{{ verifyResult.match ? 'Integrite verifiee' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500">{{ verifyResult.message }}</p>
</div>
</div>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="verifyResult = null"
/>
</div>
</UCard>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-48 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="entries.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-archive" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune entree dans le sanctuaire pour le moment</p>
<p class="text-xs text-gray-400 mt-1">
Les documents et decisions adoptes seront automatiquement archives ici
</p>
</div>
</UCard>
</template>
<!-- Entries list using SanctuaryEntry component -->
<template v-else>
<div class="space-y-4">
<div v-for="entry in entries" :key="entry.id" class="relative">
<SanctuaryEntry
:entry="entry"
@verify="handleVerify"
/>
<!-- Loading overlay for verification -->
<div
v-if="verifying === entry.id"
class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center rounded-lg"
>
<div class="flex items-center gap-2 text-sm text-gray-500">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
<span>Verification en cours...</span>
</div>
</div>
</div>
</div>
</template>
<!-- Info card -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="text-primary" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Processus d'archivage
</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-gray-500">
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">1</span>
<span>Le contenu est hache en SHA-256 pour garantir son integrite</span>
</div>
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">2</span>
<span>Le document est epingle sur IPFS (Kubo) pour le stockage distribue</span>
</div>
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">3</span>
<span>Le hash est ancre on-chain via system.remark sur Duniter V2</span>
</div>
</div>
</div>
</UCard>
</div>
</template>
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
/**
* /textes/[slug] le document vivant : sections repliables + scroll-spy
* (sommaire latéral desktop, dropdown mobile), clauses (pastille d'inertie
* câblée, mini-jauge de session réelle, statut), détail dépliable, vue
* projetée, provenance. Spécifique Pacte : valeurs en clair + effet immédiat,
* clause d'inertie protégée, A1 en tête, préambule boussoles.
*/
import type { Clause } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
buildClauseView,
formatInt,
liveClauseGauges,
sectionMeta,
type ClauseSessionGauge,
type ClauseView,
type ClauseViewCtx,
} from '~/components/texts/textsModel'
const route = useRoute()
const store = useCollectiveStore()
const doc = computed(() =>
store.docs.find(d => d.slug === String(route.params.slug)) ?? null)
const isPact = computed(() => doc.value?.role === 'pact')
// Clauses & sections (A1 en tête : l'ordre des positions fait foi)
const docClauses = computed(() =>
store.clauses
.filter(c => c.docId === doc.value?.id)
.sort((a, b) => a.position - b.position))
interface SectionGroup { tag: string; label: string; icon: string; clauses: Clause[] }
const sections = computed((): SectionGroup[] => {
const groups = new Map<string, Clause[]>()
for (const clause of docClauses.value) {
const list = groups.get(clause.section) ?? []
list.push(clause)
groups.set(clause.section, list)
}
return [...groups.entries()].map(([tag, clauses]) => ({ tag, ...sectionMeta(tag), clauses }))
})
// Assemblage des vues de clauses (pur, dans textsModel)
const gauges = computed(() => {
const map = new Map<string, ClauseSessionGauge>()
for (const gauge of liveClauseGauges(docClauses.value, store.decisions, store.sessions, store.votes)) {
map.set(gauge.clauseId, gauge)
}
return map
})
const viewCtx = computed((): ClauseViewCtx => ({
versions: store.versions,
decisions: store.decisions,
people: store.people,
protocols: store.protocols,
settings: store.settings,
gauges: gauges.value,
isPact: isPact.value,
memberCount: store.people.length,
}))
const viewByClause = computed(() => new Map<string, ClauseView>(
docClauses.value.map(c => [c.id, buildClauseView(c, viewCtx.value)]),
))
function viewOf(clause: Clause): ClauseView {
return viewByClause.value.get(clause.id) ?? buildClauseView(clause, viewCtx.value)
}
// Dépliage clause + repli sections
const expandedClauseId = ref<string | null>(null)
function toggleClause(id: string) {
expandedClauseId.value = expandedClauseId.value === id ? null : id
}
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (list) => {
if (list.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
list.forEach((s, i) => { map[s.tag] = i >= 2 })
collapsedSections.value = map
}
}, { immediate: true })
// Vue projetée : le document si les votes en cours passaient
const projected = ref(false)
function projectedContent(clause: Clause): { content: string; changed: boolean } {
const view = viewOf(clause)
const proposed = view.proposed[0]
return proposed
? { content: proposed.version.content, changed: true }
: { content: view.current?.content ?? '', changed: false }
}
const projectedChanges = computed(() =>
docClauses.value.filter(c => viewOf(c).proposed.length > 0).length)
// Sommaire + scroll-spy
const activeSection = ref<string | null>(null)
let observer: IntersectionObserver | null = null
function observeSections() {
observer?.disconnect()
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) activeSection.value = entry.target.id.replace('sec-', '')
}
}, { rootMargin: '-15% 0px -70% 0px' })
for (const section of sections.value) {
const el = document.getElementById(`sec-${section.tag}`)
if (el) observer.observe(el)
}
}
onMounted(() => nextTick(observeSections))
watch([sections, projected], () => nextTick(observeSections))
onUnmounted(() => observer?.disconnect())
function scrollToSection(tag: string) {
if (collapsedSections.value[tag]) collapsedSections.value[tag] = false
nextTick(() => {
document.getElementById(`sec-${tag}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
})
}
</script>
<template>
<!-- ld-v2 -->
<div class="doc-page">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet doc-page__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<p v-if="!doc" class="doc-page__missing">
Ce texte est introuvable il a peut-être été remplacé ou retiré.
</p>
<template v-else>
<!-- En-tête -->
<header class="doc-page__header">
<div class="doc-page__badges">
<span v-if="isPact" class="doc-page__pact-badge">{{ PACT_BADGE }}</span>
<span v-else class="doc-page__ref-badge">document de référence</span>
</div>
<h1 class="doc-page__title">{{ doc.title }}</h1>
<p class="doc-page__desc">{{ isPact ? PACT_SUBTITLE : doc.description }}</p>
<div v-if="isPact" class="doc-page__compass">
<UIcon name="i-lucide-compass" />
<span>Autonomie</span><span class="doc-page__compass-dot">·</span>
<span>Équilibre</span><span class="doc-page__compass-dot">·</span>
<span>Liaison</span>
</div>
<div class="doc-page__meta">
<span class="doc-page__meta-chip">{{ formatInt(docClauses.length) }} clauses</span>
<span v-if="gauges.size" class="doc-page__meta-chip doc-page__meta-chip--vote">
{{ gauges.size }} session{{ gauges.size > 1 ? 's' : '' }} en cours
</span>
<button
type="button"
class="doc-page__projected"
:class="{ 'doc-page__projected--on': projected }"
:disabled="projectedChanges === 0"
:title="projectedChanges === 0 ? 'Aucune version proposée sur ce texte' : ''"
@click="projected = !projected"
>
<UIcon name="i-lucide-telescope" />
{{ projected ? 'Revenir au texte en vigueur' : 'Le document si les votes en cours passaient' }}
</button>
</div>
</header>
<div class="doc-page__body">
<!-- Sommaire : latéral en desktop, dropdown en mobile -->
<nav class="doc-page__toc" aria-label="Sommaire">
<select
class="doc-page__toc-select"
:value="activeSection ?? ''"
@change="scrollToSection(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Aller à une section</option>
<option v-for="section in sections" :key="section.tag" :value="section.tag">
{{ section.label }} ({{ section.clauses.length }})
</option>
</select>
<ul class="doc-page__toc-list">
<li v-for="section in sections" :key="section.tag">
<button
type="button"
class="doc-page__toc-item"
:class="{ 'doc-page__toc-item--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" />
<span class="doc-page__toc-label">{{ section.label }}</span>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
</button>
</li>
</ul>
</nav>
<!-- Les sections -->
<div class="doc-page__sections">
<section
v-for="section in sections"
:id="`sec-${section.tag}`"
:key="section.tag"
class="doc-page__section"
>
<button
type="button"
class="doc-page__section-head"
@click="collapsedSections[section.tag] = !collapsedSections[section.tag]"
>
<UIcon :name="section.icon" class="doc-page__section-icon" />
<h2 class="doc-page__section-title">{{ section.label }}</h2>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
<UIcon
name="i-lucide-chevron-down"
class="doc-page__chevron"
:class="{ 'doc-page__chevron--open': !collapsedSections[section.tag] }"
/>
</button>
<div v-show="!collapsedSections[section.tag]" class="doc-page__clauses">
<!-- Vue projetée : lecture, votes appliqués -->
<template v-if="projected">
<article
v-for="clause in section.clauses"
:key="clause.id"
class="doc-page__projected-clause"
:class="{ 'doc-page__projected-clause--changed': projectedContent(clause).changed }"
>
<p class="doc-page__clause-head">
<span class="doc-page__clause-code">{{ clause.code }}</span>
<span class="doc-page__clause-name">{{ clause.title }}</span>
<span v-if="projectedContent(clause).changed" class="doc-page__changed-chip">
si le vote passe
</span>
</p>
<MarkdownRenderer
:content="projectedContent(clause).content"
class="doc-page__projected-text"
/>
</article>
</template>
<!-- Vue structurée : la boucle vote texte -->
<template v-else>
<ClauseCard
v-for="clause in section.clauses"
:key="clause.id"
:view="viewOf(clause)"
:expanded="expandedClauseId === clause.id"
@toggle="toggleClause(clause.id)"
/>
</template>
</div>
</section>
<TextProvenance v-if="doc.provenance" :provenance="doc.provenance" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.1rem;
max-width: 64rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.doc-page__missing { font-size: 0.9375rem; color: var(--mood-text-muted); font-style: italic; }
.doc-page__header { display: flex; flex-direction: column; gap: 0.45rem; }
.doc-page__badges { display: flex; gap: 0.4rem; }
.doc-page__pact-badge,
.doc-page__ref-badge {
font-size: 0.6875rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 3px 10px; border-radius: var(--r-pill);
}
.doc-page__pact-badge { background: var(--mood-accent); color: var(--mood-accent-text); }
.doc-page__ref-badge {
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-page__title {
font-size: clamp(1.35rem, 4.5vw, 1.8rem); font-weight: 800;
letter-spacing: -0.02em; color: var(--mood-text); line-height: 1.2;
}
.doc-page__desc { font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.55; }
.doc-page__compass {
display: inline-flex; align-items: center; gap: 0.45rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 10%, transparent);
padding: 0.3rem 0.9rem; border-radius: var(--r-pill);
}
.doc-page__compass-dot { opacity: 0.5; }
.doc-page__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.2rem; }
.doc-page__meta-chip {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-page__meta-chip--vote { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.doc-page__projected {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.75rem; font-weight: 700; padding: 4px 12px; border-radius: var(--r-pill);
background: var(--mood-surface); color: var(--mood-text-muted);
box-shadow: var(--shadow-card); cursor: pointer;
}
.doc-page__projected:disabled { opacity: 0.45; cursor: not-allowed; }
.doc-page__projected--on {
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
box-shadow: 0 0 0 2px var(--mood-status-fige);
}
.doc-page__body { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }
@media (min-width: 1024px) { .doc-page__body { grid-template-columns: 15rem 1fr; } }
.doc-page__toc { display: flex; flex-direction: column; gap: 0.5rem; }
.doc-page__toc-select {
padding: 0.55rem 0.8rem;
font-size: 0.875rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.doc-page__toc-list { display: none; }
@media (min-width: 1024px) {
.doc-page__toc-select { display: none; }
.doc-page__toc { position: sticky; top: 4.5rem; }
.doc-page__toc-list { display: flex; flex-direction: column; gap: 2px; }
}
.doc-page__toc-item {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.45rem 0.7rem; border-radius: var(--r-input);
font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-align: left;
}
.doc-page__toc-item:hover { background: var(--mood-accent-soft); color: var(--mood-text); }
.doc-page__toc-item--active { background: var(--mood-accent-soft); color: var(--mood-accent); font-weight: 700; }
.doc-page__toc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-page__toc-count { font-size: 0.6875rem; font-weight: 800; opacity: 0.6; }
.doc-page__sections { display: flex; flex-direction: column; gap: 1.4rem; min-width: 0; }
.doc-page__section { scroll-margin-top: 4.5rem; }
.doc-page__section-head {
display: flex; align-items: center; gap: 0.55rem; width: 100%;
padding: 0.5rem 0; background: none; cursor: pointer;
box-shadow: 0 2px 0 color-mix(in srgb, var(--mood-accent) 14%, transparent);
}
.doc-page__section-icon { color: var(--mood-accent); }
.doc-page__section-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.doc-page__chevron { margin-left: auto; color: var(--mood-text-muted); transform: rotate(-90deg); transition: transform 0.2s ease; }
.doc-page__chevron--open { transform: rotate(0); }
.doc-page__clauses { display: flex; flex-direction: column; gap: 0.6rem; padding-top: 0.7rem; }
.doc-page__projected-clause { padding: 0.6rem 0.9rem; border-radius: var(--r-input); }
.doc-page__projected-clause--changed { background: color-mix(in srgb, var(--mood-status-fige) 8%, transparent); }
.doc-page__clause-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.doc-page__clause-code {
font-family: monospace; font-size: 0.75rem; font-weight: 800;
color: var(--mood-accent); background: var(--mood-accent-soft);
padding: 2px 8px; border-radius: 7px;
}
.doc-page__clause-name { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.doc-page__changed-chip {
font-size: 0.6875rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
}
.doc-page__projected-text { font-size: 0.875rem; }
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* /textes/formules l'Atelier des formules : la pédagogie du seuil,
* seule maison des lettres W/T/M/B/G/C. Query params ?W=&T=&M=&B=&G=&C=(&S=)
* pré-règlent l'établi les liens « comprendre ce seuil » arrivent ici.
*/
import { BINARY_DESCRIPTION, BINARY_COST, METHOD_LABELS } from '~/lexicon'
import type { AtelierInit } from '~/components/texts/textsModel'
const route = useRoute()
function numParam(name: string): number | undefined {
const raw = route.query[name]
const value = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(value) ? value : undefined
}
const initial = computed((): AtelierInit => {
const init: AtelierInit = {}
const W = numParam('W')
const T = numParam('T')
const M = numParam('M')
const B = numParam('B')
const G = numParam('G')
const C = numParam('C')
const S = numParam('S')
if (W !== undefined) init.W = W
if (T !== undefined) init.T = T
if (M !== undefined) init.M = M
if (B !== undefined) init.B = B
if (G !== undefined) init.G = G
if (C !== undefined) init.C = C
if (S !== undefined) init.S = S
return init
})
</script>
<template>
<!-- ld-v2 -->
<div class="atelier">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet atelier__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<header class="atelier__header">
<h1 class="atelier__title">L'Atelier des formules</h1>
<p class="atelier__subtitle">
La pédagogie du seuil c'est ici, et seulement ici, que vivent les lettres.
Partout ailleurs, l'outil parle français.
</p>
</header>
<!-- L'établi -->
<AtelierFormulaLab :key="JSON.stringify(initial)" :initial="initial" />
<!-- Le pour/contre, présenté pour ce qu'il est -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--binary">
<UIcon name="i-lucide-scale" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.binary }}</h2>
<p class="atelier__card-sub">{{ BINARY_DESCRIPTION }}</p>
</div>
</div>
<p class="atelier__prose">
Cette formule inertielle est un héritage de la Toile de Confiance Ğ1 : quand
des milliers de membres sont éligibles et que peu s'expriment, elle exige
presque l'unanimité ; quand la participation monte, le seuil descend vers la
majorité cible. C'est une protection remarquable pour les très grands corps
et un outil d'exception : son coût reste « {{ BINARY_COST }} ».
Pour la vie courante d'un collectif, le consentement, le vote nuancé et le
réglage collectif font mieux vivre le désaccord.
</p>
</section>
<!-- Le Réglage collectif : la médiane basse expliquée -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--parametric">
<UIcon name="i-lucide-sliders-horizontal" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.parametric }}</h2>
<p class="atelier__card-sub">décider au curseur la médiane basse, expliquée en jouant</p>
</div>
</div>
<p class="atelier__prose">
Quand la question est un nombre un montant, un taux, une répartition
voter pour ou contre appauvrit la question. Chacun pose son curseur, et le
collectif retient la médiane basse. Bouge les cinq curseurs-votes ci-dessous :
tire un vote vers l'extrême, la médiane bouge à peine.
</p>
<AtelierMedianDemo />
</section>
</div>
</template>
<style scoped>
.atelier {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.atelier__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.atelier__header { display: flex; flex-direction: column; gap: 0.35rem; }
.atelier__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.atelier__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.55;
max-width: 38rem;
}
.atelier__card {
padding: clamp(1rem, 3vw, 1.4rem);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.atelier__card-head { display: flex; align-items: center; gap: 0.8rem; }
.atelier__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
font-size: 1.3rem;
flex-shrink: 0;
}
.atelier__card-icon--binary {
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
color: var(--mood-secondary);
}
.atelier__card-icon--parametric {
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
color: var(--mood-tertiary);
}
.atelier__card-title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
.atelier__card-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.atelier__prose {
font-size: 0.875rem;
line-height: 1.65;
color: var(--mood-text);
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
/**
* /textes la bibliothèque des documents sous vote permanent.
* Pacte épinglé en tête, cartes documents (clauses, sessions en cours,
* provenance résumée), lien vers l'Atelier des formules, filtre gravées .
*/
import type { TextDoc } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
engravedCount,
liveClauseGauges,
type ClauseSessionGauge,
} from '~/components/texts/textsModel'
const store = useCollectiveStore()
interface DocEntry {
doc: TextDoc
clauseCount: number
gauges: ClauseSessionGauge[]
engraved: number
}
function entryFor(doc: TextDoc): DocEntry {
const clauses = store.clauses.filter(c => c.docId === doc.id)
return {
doc,
clauseCount: clauses.length,
gauges: liveClauseGauges(clauses, store.decisions, store.sessions, store.votes),
engraved: engravedCount(clauses, store.decisions),
}
}
const pactEntry = computed((): DocEntry | null =>
store.pactDoc ? entryFor(store.pactDoc) : null)
const referenceEntries = computed((): DocEntry[] =>
store.docs
.filter(d => d.id !== store.pactDoc?.id)
.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
.map(entryFor))
// Filtre gravées
const onlyEngraved = ref(false)
const visibleEntries = computed(() =>
onlyEngraved.value
? referenceEntries.value.filter(entry => entry.engraved > 0)
: referenceEntries.value)
const pactVisible = computed(() =>
pactEntry.value !== null && (!onlyEngraved.value || pactEntry.value.engraved > 0))
const liveSessionTotal = computed(() =>
(pactEntry.value?.gauges.length ?? 0)
+ referenceEntries.value.reduce((n, entry) => n + entry.gauges.length, 0))
</script>
<template>
<!-- ld-v2 -->
<div class="texts-page">
<header class="texts-page__header">
<div>
<h1 class="texts-page__title">Textes</h1>
<p class="texts-page__subtitle">la bibliothèque des documents sous vote permanent</p>
</div>
<button
type="button"
class="texts-page__filter"
:class="{ 'texts-page__filter--on': onlyEngraved }"
@click="onlyEngraved = !onlyEngraved"
>
<span class="texts-page__filter-seal"></span>
gravées
</button>
</header>
<p v-if="liveSessionTotal > 0" class="texts-page__pulse">
<UIcon name="i-lucide-activity" />
{{ liveSessionTotal }} session{{ liveSessionTotal > 1 ? 's' : '' }} de vote en cours
sur ces textes le document vit.
</p>
<!-- Le Pacte, épinglé -->
<section v-if="pactVisible && pactEntry" class="texts-page__pact">
<div class="texts-page__pact-banner">
<UIcon name="i-lucide-pin" class="texts-page__pin" />
<span class="texts-page__pact-badge">{{ PACT_BADGE }}</span>
<span class="texts-page__pact-subtitle">{{ PACT_SUBTITLE }}</span>
</div>
<TextDocCard
:doc="pactEntry.doc"
:clause-count="pactEntry.clauseCount"
:gauges="pactEntry.gauges"
:engraved="pactEntry.engraved"
pinned
/>
</section>
<!-- Les documents de référence -->
<section v-if="visibleEntries.length" class="texts-page__grid">
<TextDocCard
v-for="entry in visibleEntries"
:key="entry.doc.id"
:doc="entry.doc"
:clause-count="entry.clauseCount"
:gauges="entry.gauges"
:engraved="entry.engraved"
/>
</section>
<p v-else-if="onlyEngraved" class="texts-page__empty">
Aucun texte gravé pour l'instant la gravure vient avec les décisions qui comptent.
</p>
<p v-else-if="!pactEntry" class="texts-page__empty">
Aucun texte ici pour l'instant les documents naissent des décisions.
</p>
<!-- L'Atelier des formules -->
<NuxtLink to="/textes/formules" class="texts-page__atelier ld-card ld-card--hover">
<span class="texts-page__atelier-icon">
<UIcon name="i-lucide-flask-conical" />
</span>
<span class="texts-page__atelier-text">
<span class="texts-page__atelier-title">L'Atelier des formules</span>
<span class="texts-page__atelier-sub">
la pédagogie du seuil pourquoi 94 pour quand 120 votent sur 7 224
</span>
</span>
<UIcon name="i-lucide-arrow-right" class="texts-page__atelier-arrow" />
</NuxtLink>
</div>
</template>
<style scoped>
.texts-page {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.texts-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.texts-page__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.texts-page__subtitle { font-size: 0.875rem; color: var(--mood-text-muted); margin-top: 2px; }
.texts-page__filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.35rem 1rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
background: var(--mood-surface);
color: var(--mood-text-muted);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.texts-page__filter:hover { transform: translateY(-1px); }
.texts-page__filter--on {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
box-shadow: 0 0 0 2px var(--mood-status-vigueur);
}
.texts-page__filter-seal { font-size: 1rem; font-weight: 800; }
.texts-page__pulse {
display: inline-flex;
align-items: center;
gap: 0.45rem;
align-self: flex-start;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-vote);
background: var(--mood-status-vote-bg);
padding: 0.35rem 0.9rem;
border-radius: var(--r-pill);
}
.texts-page__pact { display: flex; flex-direction: column; gap: 0.5rem; }
.texts-page__pact-banner {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.texts-page__pin { color: var(--mood-accent); font-size: 0.95rem; }
.texts-page__pact-badge {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.texts-page__pact-subtitle {
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.texts-page__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
}
@media (min-width: 768px) { .texts-page__grid { grid-template-columns: 1fr 1fr; } }
.texts-page__empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-style: italic;
padding: 1rem 0;
}
.texts-page__atelier {
display: flex;
align-items: center;
gap: 0.9rem;
padding: clamp(0.9rem, 3vw, 1.2rem);
text-decoration: none;
color: inherit;
}
.texts-page__atelier-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.3rem;
flex-shrink: 0;
}
.texts-page__atelier-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.texts-page__atelier-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.texts-page__atelier-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.texts-page__atelier-arrow {
margin-left: auto;
color: var(--mood-text-muted);
flex-shrink: 0;
}
</style>
-452
View File
@@ -1,452 +0,0 @@
<script setup lang="ts">
/**
* Tools page lists tools grouped by main section.
* Each section shows relevant tools for Documents, Decisions, Mandates, Protocols.
*/
interface Tool {
label: string
icon: string
description: string
to?: string
status: 'ready' | 'soon'
}
interface ToolSection {
key: string
title: string
icon: string
color: string
tools: Tool[]
}
const expandSocio = ref(false)
const sections: ToolSection[] = [
{
key: 'documents',
title: 'Documents de référence',
icon: 'i-lucide-book-open',
color: 'var(--mood-accent)',
tools: [
{ label: 'Modules', icon: 'i-lucide-puzzle', description: 'Structurer un document en sections et clauses modulaires', to: '/documents', status: 'ready' },
{ label: 'Votes permanents', icon: 'i-lucide-infinity', description: 'Chaque clause est sous vote permanent, modifiable à tout moment', status: 'ready' },
{ label: 'Inertie de remplacement', icon: 'i-lucide-sliders-horizontal', description: 'Régler la difficulté de modification par section (standard, haute, très haute)', to: '/protocols/formulas', status: 'ready' },
{ label: 'Contre-propositions', icon: 'i-lucide-pen-line', description: 'Soumettre un texte alternatif soumis au vote de la communauté', status: 'ready' },
{ label: 'Ancrage IPFS', icon: 'i-lucide-hard-drive', description: 'Archiver les documents validés sur IPFS avec preuve on-chain', status: 'soon' },
],
},
{
key: 'decisions',
title: 'Décisions et consultation d\'avis',
icon: 'i-lucide-scale',
color: 'var(--mood-secondary, var(--mood-accent))',
tools: [
{ label: 'Vote majoritaire WoT', icon: 'i-lucide-check-circle', description: 'Seuil adaptatif par la toile de confiance, formule g1vote', to: '/protocols/formulas', status: 'ready' },
{ label: 'Vote quadratique', icon: 'i-lucide-square-stack', description: 'Pondération dégressive pour éviter la concentration de pouvoir', status: 'soon' },
{ label: 'Vote nuancé 6 niveaux', icon: 'i-lucide-bar-chart-3', description: 'De Tout à fait contre à Tout à fait pour, avec seuil de satisfaction', status: 'ready' },
{ label: 'Mandature', icon: 'i-lucide-user-check', description: 'Élection et nomination en binôme avec transparence', status: 'ready' },
{ label: 'Multi-critères', icon: 'i-lucide-layers', description: 'Combinaison WoT + Smith + TechComm, tous doivent passer', to: '/protocols/formulas', status: 'ready' },
],
},
{
key: 'mandats',
title: 'Mandats et nominations',
icon: 'i-lucide-user-check',
color: 'var(--mood-success)',
tools: [
{ label: 'Ouverture', icon: 'i-lucide-door-open', description: 'Définir une mission, son périmètre, sa durée et ses objectifs', status: 'ready' },
{ label: 'Nomination', icon: 'i-lucide-users', description: 'Élection en binôme : un titulaire + un suppléant', status: 'ready' },
{ label: 'Transparence', icon: 'i-lucide-eye', description: 'Rapports d\'activité périodiques soumis au vote', status: 'ready' },
{ label: 'Clôture', icon: 'i-lucide-lock', description: 'Fin de mandat avec bilan ou révocation anticipée par vote', status: 'ready' },
],
},
{
key: 'protocoles',
title: 'Protocoles et fonctionnement',
icon: 'i-lucide-settings',
color: 'var(--mood-tertiary, var(--mood-accent))',
tools: [
{ label: 'Simulateur de formules', icon: 'i-lucide-calculator', description: 'Tester les paramètres de seuil WoT en temps réel', to: '/protocols/formulas', status: 'ready' },
{ label: 'Méta-gouvernance', icon: 'i-lucide-shield', description: 'Les formules elles-mêmes sont soumises au vote', status: 'ready' },
{ label: 'Workflows n8n', icon: 'i-lucide-workflow', description: 'Automatisations optionnelles (notifications, alertes, relances)', status: 'soon' },
{ label: 'Protocoles opérationnels', icon: 'i-lucide-git-branch', description: 'Processus multi-étapes réutilisables (embarquement, upgrade)', to: '/protocols', status: 'ready' },
],
},
]
</script>
<template>
<div class="tools-page">
<!-- Back link -->
<div class="tools-page__nav">
<UButton
to="/"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour à l'accueil"
size="sm"
/>
</div>
<!-- Header -->
<div class="tools-page__header">
<h1 class="tools-page__title">
<UIcon name="i-lucide-wrench" class="tools-page__title-icon" />
Boîte à outils
</h1>
<p class="tools-page__subtitle">
Tous les outils de décision collective, organisés par section
</p>
</div>
<!-- Tool sections -->
<div class="tools-page__sections">
<div
v-for="section in sections"
:key="section.key"
class="tools-section"
:style="{ '--section-color': section.color }"
>
<div class="tools-section__header">
<UIcon :name="section.icon" class="tools-section__icon" />
<h2 class="tools-section__title">{{ section.title }}</h2>
<span class="tools-section__count">{{ section.tools.length }}</span>
</div>
<div class="tools-section__grid">
<NuxtLink
v-for="tool in section.tools.filter(t => t.to)"
:key="tool.label"
:to="tool.to!"
class="tool-card"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
<UIcon name="i-lucide-chevron-right" class="tool-card__arrow" />
</NuxtLink>
<div
v-for="tool in section.tools.filter(t => !t.to)"
:key="tool.label"
class="tool-card"
:class="{ 'tool-card--soon': tool.status === 'soon' }"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
<span v-if="tool.status === 'soon'" class="tool-card__badge">bientôt</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
</div>
</div>
<!-- Election sociocratique modalité d'élection, accessible depuis mandats -->
<div v-if="section.key === 'mandats'" class="socio-expand">
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
<div class="socio-expand__icon">
<UIcon name="i-lucide-users" class="text-sm" />
</div>
<div class="socio-expand__info">
<span class="socio-expand__title">Élection sociocratique</span>
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
</div>
<span class="socio-expand__tag">Modalité d'élection</span>
<UIcon
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="socio-expand__toggle"
/>
</button>
<Transition name="socio-expand">
<div v-if="expandSocio" class="socio-expand__content">
<SocioElection />
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tools-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.tools-page__nav {
margin-bottom: -0.5rem;
}
.tools-page__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tools-page__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
@media (min-width: 640px) {
.tools-page__title {
font-size: 1.875rem;
}
}
.tools-page__title-icon {
color: var(--mood-accent);
}
.tools-page__subtitle {
font-size: 0.9375rem;
color: var(--mood-text-muted);
font-weight: 500;
}
/* Sections */
.tools-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.tools-section__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tools-section__icon {
font-size: 1.125rem;
color: var(--section-color);
}
.tools-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.tools-section__count {
font-size: 0.6875rem;
font-weight: 700;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
padding: 2px 8px;
border-radius: 20px;
}
/* Tool cards */
.tools-section__grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tool-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border-radius: 14px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.tool-card--soon {
opacity: 0.6;
cursor: default;
}
.tool-card--soon:hover {
transform: none;
box-shadow: none;
}
.tool-card__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
font-size: 0.875rem;
}
.tool-card__body {
flex: 1;
min-width: 0;
}
.tool-card__head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.tool-card__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.tool-card__badge {
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.tool-card__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0.125rem 0 0;
}
.tool-card__arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
margin-top: 0.375rem;
transition: all 0.12s;
}
.tool-card:hover .tool-card__arrow {
opacity: 1;
color: var(--section-color);
}
/* --- Élection sociocratique expandable --- */
.socio-expand {
margin-top: 0.5rem;
border-radius: 16px;
overflow: hidden;
background: var(--mood-surface);
}
.socio-expand__trigger {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 1.125rem;
cursor: pointer;
background: none;
text-align: left;
transition: background 0.12s;
}
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
.socio-expand__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
}
.socio-expand__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.socio-expand__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.socio-expand__meta {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.socio-expand__tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 480px) {
.socio-expand__tag { display: none; }
}
.socio-expand__toggle {
flex-shrink: 0;
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.socio-expand__content {
padding: 0 1rem 1rem;
}
.socio-expand-enter-active,
.socio-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.socio-expand-enter-from,
.socio-expand-leave-to {
max-height: 0;
opacity: 0;
}
.socio-expand-enter-to,
.socio-expand-leave-from {
max-height: 2000px;
opacity: 1;
}
</style>