forked from yvv/decision
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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à là' : '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à là" : "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>
|
||||
+281
-785
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user