forked from yvv/decision
v2 : nettoyage des orphelins v1 + documentation livrable
- 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox, stores auth/documents/groups/mandates/organizations/protocols/votes, composables api/notifications/formula/websocket, utils doublons du moteur) - nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2 - README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2 - Build zéro erreur, 342/342 tests verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,73 @@
|
||||
# libreDecision
|
||||
|
||||
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
||||
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
||||
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
|
||||
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
|
||||
|
||||
## Protocole de début de session
|
||||
|
||||
1. `git pull --rebase origin main`
|
||||
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
||||
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
||||
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
||||
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
||||
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
package manager : npm ; port **3002** strict
|
||||
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
|
||||
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
|
||||
en prévision
|
||||
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
app/
|
||||
components/ # composants Vue
|
||||
layouts/ # layouts Nuxt
|
||||
pages/ # routing file-based (9 pages sprint 1)
|
||||
composables/ # (1 composable sprint 1)
|
||||
stores/ # 5 Pinia stores (auth, ...)
|
||||
assets/css/
|
||||
moods.css # système de palettes (.mood-* sur <html>)
|
||||
utils/ # (2 utils sprint 1)
|
||||
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
||||
backend/
|
||||
app/
|
||||
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
||||
services/ # 6 services
|
||||
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
||||
models/ # 14 tables SQLAlchemy
|
||||
alembic/versions/ # migrations
|
||||
tests/ # 186 tests (63 intégration TDD sprint 1)
|
||||
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
||||
docker/
|
||||
docker-compose.yml # postgres + backend + frontend + ipfs
|
||||
backend.Dockerfile
|
||||
frontend.Dockerfile
|
||||
docs/content/ # 7 docs dev + 8 docs user
|
||||
public/
|
||||
hexagram-tsing.svg # sceau 井 (embossed)
|
||||
hexagram-tsing-flat.svg
|
||||
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||
data/
|
||||
persistence.ts # IndexedDB local-first
|
||||
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||
stores/
|
||||
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||
# onboarding/ common/ (primitives Ld*)
|
||||
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||
# creer (layout bare) · donnees
|
||||
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||
types/domain.ts # le noyau du modèle
|
||||
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||
docker/ # compose + Dockerfiles
|
||||
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||
```
|
||||
|
||||
## Données runtime
|
||||
|
||||
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
||||
- **ipfs-data** : volume Docker IPFS kubo
|
||||
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
||||
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||
- `.env` racine : secrets backend — jamais commité
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && . venv/bin/activate
|
||||
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
|
||||
pytest tests/ -v
|
||||
alembic upgrade head
|
||||
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
|
||||
|
||||
# Frontend
|
||||
# Frontend (tout le dev v2)
|
||||
cd frontend && npm run dev # :3002
|
||||
npm run build
|
||||
npm test # vitest — 342 tests, référence à maintenir
|
||||
npm run build # zéro erreur exigé avant commit
|
||||
|
||||
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
|
||||
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
|
||||
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
|
||||
|
||||
# Docker
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
@@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up
|
||||
## Conventions / pièges
|
||||
|
||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
||||
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
||||
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
||||
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
||||
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
||||
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||
des identifiants de code, jamais des textes affichés
|
||||
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
|
||||
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
|
||||
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
|
||||
dérogation asymétrique (alourdir libre, alléger motivé)
|
||||
- **Budget 400 lignes max par page** (`app/pages/`)
|
||||
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
|
||||
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
|
||||
`canTransition(decision, to, ctx)`
|
||||
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
|
||||
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
|
||||
lettres W/T/M/B/G/C et de KaTeX
|
||||
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
|
||||
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
|
||||
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
|
||||
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
|
||||
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
||||
|
||||
+68
-29
@@ -1,52 +1,91 @@
|
||||
# Contribuer à libreDecision
|
||||
|
||||
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
|
||||
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
|
||||
|
||||
## Environnement
|
||||
|
||||
```bash
|
||||
# Backend (Python 3.11+)
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||
|
||||
# Frontend (Node 20+)
|
||||
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run dev # http://localhost:3002 — port strict, jamais de fallback
|
||||
npm test # vitest — 342 tests
|
||||
npm run build # doit passer sans erreur avant tout commit
|
||||
|
||||
# Backend v1 (optionnel — conservé pour la future synchro)
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
||||
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
||||
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
||||
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
||||
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
||||
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||
toujours la personne ou le collectif — jamais la formule ni le système.
|
||||
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
|
||||
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
|
||||
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
|
||||
code, jamais à l'écran.
|
||||
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
|
||||
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
|
||||
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
|
||||
|
||||
## Architecture toolbox
|
||||
## Invariants doctrinaux (non négociables)
|
||||
|
||||
Chaque section expose une `<SectionLayout>` avec :
|
||||
- Contenu principal (slot `#default`)
|
||||
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
||||
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||
de re-vote n'est visible que de son auteur.
|
||||
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
|
||||
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
|
||||
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
|
||||
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
|
||||
(`protocolByRange.consent`), tout repli de résolution y mène.
|
||||
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
|
||||
+ fenêtre d'objection.
|
||||
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
|
||||
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
|
||||
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
|
||||
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
|
||||
vitest pur.
|
||||
|
||||
Composants toolbox :
|
||||
- `ToolboxSection` : accordéon collapsible générique
|
||||
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
||||
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
||||
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
||||
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
||||
## Ajouter une modalité de vote
|
||||
|
||||
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||
`engine/index.ts`.
|
||||
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
|
||||
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
|
||||
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
|
||||
`pages/decisions/[id]/vote.vue`.
|
||||
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
|
||||
|
||||
## Ajouter un gabarit de collectif
|
||||
|
||||
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
|
||||
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
|
||||
(« Notre finalité »), préambule Autonomie–Équilibre–Liaison proposé et modifiable, cercles
|
||||
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && pytest tests/ -v
|
||||
cd frontend && npm test
|
||||
```
|
||||
|
||||
186 tests, zéro dette technique acceptée depuis le sprint 1.
|
||||
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
|
||||
- `tests/stores/` — la façade decisions sous Pinia nu.
|
||||
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
|
||||
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
|
||||
- `tests/lexicon.spec.ts` — l'anti-lexique.
|
||||
|
||||
## Formule de vote inertiel
|
||||
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
|
||||
toute fonctionnalité de moteur arrive avec les siens.
|
||||
|
||||
`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)`
|
||||
## Build
|
||||
|
||||
Voir `docs/content/dev/` pour la documentation complète.
|
||||
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
|
||||
Vérifier mobile + desktop (zéro régression CSS).
|
||||
|
||||
@@ -1,32 +1,113 @@
|
||||
# libreDecision
|
||||
|
||||
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
||||
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
|
||||
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
|
||||
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
|
||||
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
|
||||
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
|
||||
|
||||
## Manifeste
|
||||
|
||||
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
|
||||
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
|
||||
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
|
||||
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
|
||||
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
|
||||
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
|
||||
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
|
||||
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
|
||||
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
|
||||
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
|
||||
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
|
||||
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
|
||||
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
|
||||
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
|
||||
|
||||
## Les 6 routes de décision
|
||||
|
||||
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
|
||||
décision vers l'une de six routes :
|
||||
|
||||
| Route | Geste |
|
||||
|---|---|
|
||||
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
|
||||
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
|
||||
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
|
||||
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
|
||||
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
|
||||
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
|
||||
|
||||
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
|
||||
et une fenêtre d'objection.
|
||||
|
||||
## Les 5 modalités
|
||||
|
||||
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
|
||||
le Pacte.
|
||||
|
||||
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
|
||||
universel : tout collectif en a un (invariant).
|
||||
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
|
||||
histogramme visible, commentaire obligatoire sur les niveaux bas.
|
||||
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
|
||||
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
|
||||
automatisme.
|
||||
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
|
||||
nomination, vote blanc, jamais de départage par l'outil).
|
||||
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
|
||||
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
|
||||
formules (`/textes/formules`) reste son école.
|
||||
|
||||
## Local-first
|
||||
|
||||
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
|
||||
une clé par collectif).
|
||||
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
|
||||
table.
|
||||
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
|
||||
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
|
||||
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
|
||||
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
|
||||
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
|
||||
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
|
||||
coopérative, commune, monnaie libre, institution symétrique.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
||||
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
||||
- **Auth** : Duniter V2 Ed25519 challenge-response
|
||||
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
||||
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
npm ; port **3002**
|
||||
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
|
||||
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
|
||||
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3002
|
||||
npm test # vitest — 342 tests
|
||||
npm run build # build de production
|
||||
```
|
||||
|
||||
## Sections
|
||||
## Structure v2
|
||||
|
||||
- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process)
|
||||
- **Mandats** : élection sociocratique, cycle de mandat, révocation
|
||||
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
||||
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
||||
```
|
||||
frontend/app/
|
||||
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
|
||||
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
|
||||
composables/ # useMood, useFeed, useSearch
|
||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
|
||||
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
|
||||
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
|
||||
lexicon.ts # source unique des libellés FR + test anti-lexique
|
||||
types/domain.ts # le noyau du modèle
|
||||
docs/dev/BLUEPRINT-V2.md # la référence produit
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Licence
|
||||
|
||||
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
|
||||
Logiciel libre. Le fichier LICENSE sera posé avant la première publication.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,373 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SectionLayout — Mise en page pour sections.
|
||||
*
|
||||
* Desktop (≥1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
|
||||
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
|
||||
*/
|
||||
|
||||
export interface StatusFilter {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
cssClass?: string
|
||||
}
|
||||
|
||||
export interface ToolboxItem {
|
||||
title: string
|
||||
description: string
|
||||
actions: Array<{
|
||||
label: string
|
||||
to?: string
|
||||
onClick?: () => void
|
||||
}>
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
statuses: StatusFilter[]
|
||||
toolboxItems?: ToolboxItem[]
|
||||
activeStatus?: string | null
|
||||
toolboxTitle?: string
|
||||
}>(),
|
||||
{
|
||||
subtitle: undefined,
|
||||
toolboxItems: undefined,
|
||||
activeStatus: null,
|
||||
toolboxTitle: 'Boîte à outils',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeStatus': [status: string | null]
|
||||
}>()
|
||||
|
||||
const toolboxOpen = ref(false)
|
||||
|
||||
const statusCssMap: Record<string, string> = {
|
||||
draft: 'status-prepa',
|
||||
qualification: 'status-prepa',
|
||||
candidacy: 'status-prepa',
|
||||
voting: 'status-vote',
|
||||
review: 'status-vote',
|
||||
active: 'status-vigueur',
|
||||
executed: 'status-vigueur',
|
||||
completed: 'status-vigueur',
|
||||
closed: 'status-clos',
|
||||
archived: 'status-clos',
|
||||
revoked: 'status-clos',
|
||||
reporting: 'status-vote',
|
||||
}
|
||||
|
||||
function getStatusClass(status: StatusFilter): string {
|
||||
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
|
||||
}
|
||||
|
||||
function toggleStatus(statusId: string) {
|
||||
if (props.activeStatus === statusId) {
|
||||
emit('update:activeStatus', null)
|
||||
}
|
||||
else {
|
||||
emit('update:activeStatus', statusId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section">
|
||||
<!-- Header -->
|
||||
<div class="section__header">
|
||||
<div class="section__header-left">
|
||||
<h1 class="section__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<!-- Mobile toolbox trigger -->
|
||||
<button
|
||||
class="section__toolbox-fab lg:hidden"
|
||||
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
|
||||
@click="toolboxOpen = true"
|
||||
>
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
<span>Outils</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: content + toolbox -->
|
||||
<div class="section__body">
|
||||
<div class="section__main">
|
||||
<!-- Status pills -->
|
||||
<div v-if="statuses.length > 0" class="section__pills">
|
||||
<button
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
type="button"
|
||||
class="status-pill"
|
||||
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
|
||||
@click="toggleStatus(status.id)"
|
||||
>
|
||||
{{ status.label }}
|
||||
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.search" class="section__search">
|
||||
<slot name="search" />
|
||||
</div>
|
||||
<div class="section__content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop toolbox sidebar (≥1024px) -->
|
||||
<aside class="section__toolbox">
|
||||
<div class="section__toolbox-head">
|
||||
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
|
||||
<span>{{ toolboxTitle }}</span>
|
||||
</div>
|
||||
<div class="section__toolbox-body">
|
||||
<div v-if="$slots.toolbox">
|
||||
<slot name="toolbox" />
|
||||
</div>
|
||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="(item, idx) in toolboxItems"
|
||||
:key="idx"
|
||||
:title="item.title"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__toolbox-empty">
|
||||
Aucun outil disponible
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Mobile toolbox: USlideover from right -->
|
||||
<USlideover
|
||||
v-model:open="toolboxOpen"
|
||||
side="right"
|
||||
:title="toolboxTitle"
|
||||
:ui="{
|
||||
width: 'max-w-sm',
|
||||
header: { padding: 'p-4' },
|
||||
body: { padding: 'p-4' },
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="section__toolbox-slideover">
|
||||
<div v-if="$slots.toolbox">
|
||||
<slot name="toolbox" />
|
||||
</div>
|
||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="(item, idx) in toolboxItems"
|
||||
:key="idx"
|
||||
:title="item.title"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__toolbox-empty">
|
||||
Aucun outil disponible
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section { gap: 1.5rem; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section__header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__title { font-size: 1.75rem; }
|
||||
}
|
||||
|
||||
.section__subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__subtitle { font-size: 1rem; }
|
||||
}
|
||||
|
||||
/* Mobile toolbox trigger */
|
||||
.section__toolbox-fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section__toolbox-fab:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.section__toolbox-fab--active {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
/* Body layout */
|
||||
.section__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section__body {
|
||||
grid-template-columns: 1fr 30rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Status pills */
|
||||
.section__pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.section__pills::-webkit-scrollbar { display: none; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__pills {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.section__pill-count {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section__search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.section__search { flex-direction: column; }
|
||||
}
|
||||
|
||||
.section__content { min-height: 12rem; }
|
||||
|
||||
/* Desktop toolbox sidebar */
|
||||
.section__toolbox {
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
align-self: start;
|
||||
flex-direction: column;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
box-shadow: 0 4px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section__toolbox { display: flex; }
|
||||
}
|
||||
|
||||
.section__toolbox-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.875rem 1rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section__toolbox-head-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.section__toolbox-body {
|
||||
padding: 0 0.75rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section__toolbox-empty {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Slideover content */
|
||||
.section__toolbox-slideover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,252 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const orgsStore = useOrganizationsStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const active = computed(() => orgsStore.active)
|
||||
const organizations = computed(() => orgsStore.organizations)
|
||||
|
||||
function selectOrg(slug: string) {
|
||||
orgsStore.setActive(slug)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="ws">
|
||||
<button
|
||||
class="ws__trigger"
|
||||
:class="{ 'ws__trigger--open': isOpen }"
|
||||
:disabled="orgsStore.loading || !active"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
|
||||
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
||||
<UIcon :name="active.icon || 'i-lucide-building'" />
|
||||
</div>
|
||||
<span class="ws__name">{{ active?.name ?? '…' }}</span>
|
||||
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
||||
<div class="ws__dropdown-header">
|
||||
Espace de travail
|
||||
</div>
|
||||
<div class="ws__items">
|
||||
<button
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="ws__item"
|
||||
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
||||
@click="selectOrg(org.slug)"
|
||||
>
|
||||
<div
|
||||
class="ws__item-icon"
|
||||
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
||||
>
|
||||
<UIcon :name="org.icon || 'i-lucide-building'" />
|
||||
</div>
|
||||
<div class="ws__item-info">
|
||||
<span class="ws__item-name">{{ org.name }}</span>
|
||||
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
||||
</div>
|
||||
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ws__dropdown-footer">
|
||||
<button class="ws__new-btn" disabled>
|
||||
<UIcon name="i-lucide-plus" />
|
||||
Nouveau collectif
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ws__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
min-height: 2rem;
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.ws__trigger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
||||
}
|
||||
|
||||
.ws__trigger--open {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
||||
}
|
||||
|
||||
.ws__trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ws__icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ws__icon--muted {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws__caret {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.ws__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
min-width: 13rem;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px var(--mood-shadow);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws__dropdown-header {
|
||||
padding: 0.625rem 0.875rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__items {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ws__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.625rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ws__item:hover { background: var(--mood-accent-soft); }
|
||||
.ws__item--active { background: var(--mood-accent-soft); }
|
||||
|
||||
.ws__item-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ws__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ws__item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.ws__item-role {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__item-check {
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws__dropdown-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--mood-accent-soft);
|
||||
}
|
||||
|
||||
.ws__new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dropdown-enter-active, .dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.dropdown-enter-from, .dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
diff: string
|
||||
}>()
|
||||
|
||||
interface DiffLine {
|
||||
text: string
|
||||
type: 'added' | 'removed' | 'header' | 'context'
|
||||
}
|
||||
|
||||
const parsedLines = computed((): DiffLine[] => {
|
||||
if (!props.diff) return []
|
||||
|
||||
return props.diff.split('\n').map((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
return { text: line, type: 'header' as const }
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return { text: line, type: 'added' as const }
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
return { text: line, type: 'removed' as const }
|
||||
}
|
||||
return { text: line, type: 'context' as const }
|
||||
})
|
||||
})
|
||||
|
||||
function lineClass(type: DiffLine['type']): string {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
case 'header':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
|
||||
v-for="(line, index) in parsedLines"
|
||||
:key="index"
|
||||
><div
|
||||
:class="['px-4 py-0.5', lineClass(line.type)]"
|
||||
>{{ line.text }}</div></template></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Error boundary component.
|
||||
*
|
||||
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
|
||||
* error message in French when child components crash.
|
||||
* Logs error details to console and emits error event for monitoring.
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: any]
|
||||
}>()
|
||||
|
||||
const hasError = ref(false)
|
||||
const errorDetails = ref<string | null>(null)
|
||||
|
||||
function handleError(error: any) {
|
||||
hasError.value = true
|
||||
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
|
||||
|
||||
// Log to console for debugging
|
||||
console.error('[ErrorBoundary] Erreur capturee:', error)
|
||||
|
||||
// Emit for external monitoring
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
errorDetails.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtErrorBoundary @error="handleError">
|
||||
<template v-if="!hasError">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div class="max-w-md space-y-4">
|
||||
<UIcon
|
||||
name="i-lucide-alert-triangle"
|
||||
class="text-5xl text-warning mx-auto"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Une erreur est survenue
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Un probleme inattendu s'est produit lors du chargement de ce contenu.
|
||||
Vous pouvez essayer de recharger cette section.
|
||||
</p>
|
||||
<p
|
||||
v-if="errorDetails"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
|
||||
>
|
||||
{{ errorDetails }}
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Reessayer"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
@click="retry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Reusable skeleton loader component.
|
||||
*
|
||||
* Provides multiple skeleton variants for loading states:
|
||||
* - Card: card layout with title and content lines
|
||||
* - List: multiple rows with optional avatar
|
||||
* - Detail: detailed view with mixed content
|
||||
*
|
||||
* Uses Nuxt UI USkeleton components.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Number of skeleton lines to display (default: 3). */
|
||||
lines?: number
|
||||
/** Show an avatar circle placeholder. */
|
||||
avatar?: boolean
|
||||
/** Render as a card skeleton with border and padding. */
|
||||
card?: boolean
|
||||
}>(),
|
||||
{
|
||||
lines: 3,
|
||||
avatar: false,
|
||||
card: false,
|
||||
},
|
||||
)
|
||||
|
||||
/** Generate varying line widths for a natural appearance. */
|
||||
const lineWidths = computed(() => {
|
||||
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
|
||||
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Card variant -->
|
||||
<UCard v-if="card" class="animate-pulse">
|
||||
<div class="space-y-4">
|
||||
<!-- Optional avatar row -->
|
||||
<div v-if="avatar" class="flex items-center gap-3">
|
||||
<USkeleton class="h-10 w-10 rounded-full" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<USkeleton class="h-4 w-1/3" />
|
||||
<USkeleton class="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title line -->
|
||||
<USkeleton class="h-5 w-2/3" />
|
||||
|
||||
<!-- Content lines -->
|
||||
<div class="space-y-2">
|
||||
<USkeleton
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="h-3"
|
||||
:class="width"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- List / default variant -->
|
||||
<div v-else class="space-y-3 animate-pulse">
|
||||
<div
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<!-- Optional avatar per line -->
|
||||
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
|
||||
<!-- Line content -->
|
||||
<div class="flex-1 space-y-1">
|
||||
<USkeleton class="h-3" :class="width" />
|
||||
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { currentMood, moods, setMood } = useLibreMood()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
|
||||
<UTooltip
|
||||
v-for="mood in moods"
|
||||
:key="mood.id"
|
||||
:text="mood.label"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="currentMood === mood.id"
|
||||
:aria-label="`Ambiance ${mood.label}`"
|
||||
class="mood-dot"
|
||||
:class="{ 'mood-dot--active': currentMood === mood.id }"
|
||||
:style="{ '--dot-color': mood.color }"
|
||||
@click="setMood(mood.id)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mood-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mood-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--dot-color, var(--mood-text-muted));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mood-dot:hover {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.mood-dot--active {
|
||||
border-color: var(--mood-text);
|
||||
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Offline detection banner.
|
||||
*
|
||||
* Uses navigator.onLine and online/offline events to detect
|
||||
* network connectivity changes. Shows a warning banner when
|
||||
* offline and a brief success message when back online.
|
||||
*/
|
||||
|
||||
const isOnline = ref(true)
|
||||
const showReconnected = ref(false)
|
||||
|
||||
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleOffline() {
|
||||
isOnline.value = false
|
||||
showReconnected.value = false
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
reconnectedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
isOnline.value = true
|
||||
showReconnected.value = true
|
||||
|
||||
// Show "reconnected" message briefly then hide
|
||||
reconnectedTimer = setTimeout(() => {
|
||||
showReconnected.value = false
|
||||
reconnectedTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isOnline.value = navigator.onLine
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
|
||||
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
|
||||
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="showReconnected && isOnline"
|
||||
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
|
||||
role="status"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
|
||||
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
|
||||
<span>Connexion retablie</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-down-enter-to,
|
||||
.slide-down-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
status: string
|
||||
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
|
||||
clickable?: boolean
|
||||
active?: boolean
|
||||
}>(), {
|
||||
clickable: true,
|
||||
active: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
|
||||
// Universal statuses
|
||||
draft: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
closed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Decision/vote specific
|
||||
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
review: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
voting: { label: 'En vote', cssClass: 'status-vote' },
|
||||
open: { label: 'En vote', cssClass: 'status-vote' },
|
||||
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
|
||||
// Version specific
|
||||
pending: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
rejected: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Mandate specific
|
||||
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
investiture: { label: 'En vote', cssClass: 'status-vote' },
|
||||
revoked: { label: 'Clos', cssClass: 'status-clos' },
|
||||
completed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
archived: { label: 'Clos', cssClass: 'status-clos' },
|
||||
reporting: { label: 'En vote', cssClass: 'status-vote' },
|
||||
}
|
||||
|
||||
const resolved = computed(() => {
|
||||
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.clickable) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="clickable"
|
||||
type="button"
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass, { active: active }]"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass]"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
|
||||
*/
|
||||
|
||||
export interface ToolboxAction {
|
||||
label: string
|
||||
icon?: string
|
||||
to?: string
|
||||
emit?: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
bullets?: string[]
|
||||
actions?: ToolboxAction[]
|
||||
defaultOpen?: boolean
|
||||
}>(),
|
||||
{
|
||||
bullets: undefined,
|
||||
actions: undefined,
|
||||
defaultOpen: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [actionEmit: string]
|
||||
}>()
|
||||
|
||||
const open = ref(props.defaultOpen)
|
||||
|
||||
const defaultActions: ToolboxAction[] = [
|
||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
||||
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
|
||||
]
|
||||
|
||||
const resolvedActions = computed(() => props.actions ?? defaultActions)
|
||||
|
||||
function handleAction(action: ToolboxAction) {
|
||||
if (action.to) {
|
||||
navigateTo(action.to)
|
||||
}
|
||||
else if (action.emit) {
|
||||
emit('action', action.emit)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vignette" :class="{ 'vignette--open': open }">
|
||||
<button class="vignette__header" @click="open = !open">
|
||||
<h4 class="vignette__title">{{ title }}</h4>
|
||||
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
|
||||
</button>
|
||||
<div v-show="open" class="vignette__content">
|
||||
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
||||
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
||||
</ul>
|
||||
<div class="vignette__actions">
|
||||
<button
|
||||
v-for="action in resolvedActions"
|
||||
:key="action.label"
|
||||
class="vignette__btn"
|
||||
:class="{ 'vignette__btn--primary': action.primary }"
|
||||
@click="handleAction(action)"
|
||||
>
|
||||
<UIcon v-if="action.icon" :name="action.icon" />
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vignette {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vignette__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 0.375rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.vignette__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.vignette__title {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vignette__chevron {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vignette--open .vignette__chevron {
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.vignette__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.625rem;
|
||||
}
|
||||
|
||||
.vignette__bullets {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
list-style-type: disc;
|
||||
}
|
||||
.vignette__bullets li::marker {
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.vignette__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.vignette__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-surface);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.vignette__btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.vignette__btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.vignette__btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.vignette__btn--primary {
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const typeLabel = (docType: string): string => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
case 'engagement': return 'Engagement'
|
||||
case 'reglement': return 'Reglement'
|
||||
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',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<UCard v-else-if="documents.length === 0">
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Document grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<UCard
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
||||
{{ doc.title }}
|
||||
</h3>
|
||||
<StatusBadge :status="doc.status" type="document" />
|
||||
</div>
|
||||
|
||||
<!-- Type + Version -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(doc.doc_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="doc.description"
|
||||
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
>
|
||||
{{ doc.description }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<UIcon name="i-lucide-list" class="text-sm" />
|
||||
<span>{{ doc.items_count }} items</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,347 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocumentPreview — clean "PDF-like" viewer for a reference document.
|
||||
*
|
||||
* Two modes:
|
||||
* - current: shows current_text for every item (document en vigueur)
|
||||
* - projected: applies latest "vote" or "proposed" version per item,
|
||||
* highlighting changed clauses (document tel qu'il serait si les
|
||||
* votes en cours passaient)
|
||||
*/
|
||||
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
|
||||
|
||||
const props = defineProps<{
|
||||
document: Document
|
||||
items: DocumentItem[]
|
||||
mode: 'current' | 'projected'
|
||||
versionMap: Record<string, ItemVersion | null>
|
||||
}>()
|
||||
|
||||
const sortedItems = computed(() =>
|
||||
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
|
||||
)
|
||||
|
||||
const changedCount = computed(() =>
|
||||
props.mode === 'projected'
|
||||
? Object.values(props.versionMap).filter(Boolean).length
|
||||
: 0,
|
||||
)
|
||||
|
||||
function getDisplayText(item: DocumentItem): string {
|
||||
if (props.mode === 'projected' && props.versionMap[item.id]) {
|
||||
return props.versionMap[item.id]!.proposed_text
|
||||
}
|
||||
return item.current_text
|
||||
}
|
||||
|
||||
function isChanged(item: DocumentItem): boolean {
|
||||
return props.mode === 'projected' && !!props.versionMap[item.id]
|
||||
}
|
||||
|
||||
function itemTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'clause': return 'Engagement'
|
||||
case 'rule': return 'Variable'
|
||||
case 'verification': return 'Application'
|
||||
case 'preamble': return 'Préambule'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doc-preview">
|
||||
<!-- ══ Document header ══ -->
|
||||
<div class="doc-preview__header">
|
||||
<div class="doc-preview__watermark" aria-hidden="true">
|
||||
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
|
||||
</div>
|
||||
<h1 class="doc-preview__title">{{ document.title }}</h1>
|
||||
<div class="doc-preview__meta">
|
||||
<span>Version {{ document.version }}</span>
|
||||
<span class="doc-preview__sep">·</span>
|
||||
<span>{{ document.items_count }} items</span>
|
||||
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
|
||||
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else class="doc-preview__current-badge">
|
||||
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||
Texte officiel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Items ══ -->
|
||||
<div class="doc-preview__body">
|
||||
<template v-for="item in sortedItems" :key="item.id">
|
||||
<!-- Section heading -->
|
||||
<div v-if="item.item_type === 'section'" class="doc-preview__section">
|
||||
<h2 class="doc-preview__section-title">
|
||||
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
|
||||
{{ item.title || item.current_text }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Preamble -->
|
||||
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
|
||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||
</div>
|
||||
|
||||
<!-- Regular clause / rule / verification -->
|
||||
<div
|
||||
v-else
|
||||
class="doc-preview__item"
|
||||
:class="{ 'doc-preview__item--changed': isChanged(item) }"
|
||||
>
|
||||
<div class="doc-preview__item-head">
|
||||
<span class="doc-preview__item-pos">{{ item.position }}</span>
|
||||
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
|
||||
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</span>
|
||||
<span v-if="isChanged(item)" class="doc-preview__change-chip">
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
Vote en cours
|
||||
</span>
|
||||
</div>
|
||||
<div class="doc-preview__item-text">
|
||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Footer ══ -->
|
||||
<div class="doc-preview__footer">
|
||||
<div class="doc-preview__footer-main">
|
||||
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
|
||||
</div>
|
||||
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
|
||||
Projection non officielle — texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-preview {
|
||||
position: relative;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
box-shadow: 0 4px 32px var(--mood-shadow);
|
||||
line-height: 1.75;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Watermark ── */
|
||||
.doc-preview__watermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-35deg);
|
||||
font-size: clamp(2rem, 8vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.doc-preview__header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.doc-preview__title {
|
||||
font-size: clamp(1.25rem, 3vw, 1.875rem);
|
||||
font-weight: 900;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.doc-preview__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-preview__sep { opacity: 0.3; }
|
||||
|
||||
.doc-preview__proj-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
|
||||
color: var(--mood-warning, #d97706);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-preview__current-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
|
||||
color: var(--mood-success, #16a34a);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.doc-preview__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
/* Section heading */
|
||||
.doc-preview__section {
|
||||
padding-top: 1.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-preview__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
/* Preamble */
|
||||
.doc-preview__preamble {
|
||||
padding: 1rem 1.25rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||
border-radius: 12px;
|
||||
font-style: italic;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Item */
|
||||
.doc-preview__item {
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.doc-preview__item--changed {
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
|
||||
border-left: 3px solid var(--mood-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.doc-preview__item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-preview__item-pos {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
font-family: monospace;
|
||||
color: var(--mood-accent);
|
||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-preview__item-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.doc-preview__item-type {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.doc-preview__change-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1px 0.5rem;
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
|
||||
color: var(--mood-warning, #d97706);
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.doc-preview__item-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.doc-preview__footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-preview__footer-main {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-preview__footer-note {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Print ── */
|
||||
@media print {
|
||||
.doc-preview {
|
||||
box-shadow: none;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
.doc-preview__watermark { opacity: 0.05; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocumentTuto — Quick tutorial overlay explaining how the document works.
|
||||
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
|
||||
*/
|
||||
const open = ref(false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'i-lucide-infinity',
|
||||
title: 'Vote permanent',
|
||||
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
title: 'Inertie variable',
|
||||
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
title: 'Seuil adaptatif',
|
||||
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-pen-line',
|
||||
title: 'Contre-propositions',
|
||||
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-git-branch',
|
||||
title: 'Dépôt automatique',
|
||||
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton
|
||||
icon="i-lucide-circle-help"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@click="open = true"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
|
||||
Comment ça marche ?
|
||||
</h2>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="tuto-step"
|
||||
>
|
||||
<div class="tuto-step__icon">
|
||||
<UIcon :name="step.icon" class="text-base" />
|
||||
</div>
|
||||
<div class="tuto-step__content">
|
||||
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
|
||||
{{ step.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
|
||||
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
|
||||
Référence : formule g1vote —
|
||||
<a
|
||||
href="https://g1vote-view-237903.pages.duniter.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: var(--mood-accent)"
|
||||
>
|
||||
g1vote-view
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tuto-step {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tuto-step__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tuto-step__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,303 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EngagementCard — Enhanced item card with inline mini vote board,
|
||||
* inertia indicator, and action buttons.
|
||||
*
|
||||
* Replaces the basic ItemCard for the document detail view.
|
||||
*/
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
showVoteBoard?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
showVoteBoard: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
const isSection = computed(() => props.item.item_type === 'section')
|
||||
const isPreamble = computed(() => props.item.item_type === 'preamble')
|
||||
|
||||
const itemTypeIcon = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'i-lucide-shield-check'
|
||||
case 'rule': return 'i-lucide-scale'
|
||||
case 'verification': return 'i-lucide-check-circle'
|
||||
case 'preamble': return 'i-lucide-scroll-text'
|
||||
case 'section': return 'i-lucide-layout-list'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
})
|
||||
|
||||
const itemTypeLabel = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'Engagement'
|
||||
case 'rule': return 'Variables'
|
||||
case 'verification': return 'Application'
|
||||
case 'preamble': return 'Préambule'
|
||||
case 'section': return 'Titre'
|
||||
default: return props.item.item_type
|
||||
}
|
||||
})
|
||||
|
||||
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
|
||||
const mockVotes = computed(() => {
|
||||
const order = props.item.sort_order
|
||||
const pos = props.item.position
|
||||
|
||||
// Conseils et bonnes pratiques: varied votes, some non-adopted
|
||||
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
|
||||
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
|
||||
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
|
||||
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
|
||||
|
||||
// Default: well-adopted items
|
||||
const base = ((order * 7 + 13) % 5) + 8 // 8-12
|
||||
const against = (order % 3) // 0-2
|
||||
return { votesFor: base, votesAgainst: against }
|
||||
})
|
||||
|
||||
function navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Section header (visual separator, not a card) -->
|
||||
<div v-if="isSection" class="engagement-section">
|
||||
<div class="engagement-section__line" />
|
||||
<div class="engagement-section__content">
|
||||
<h3 class="engagement-section__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="engagement-section__text">
|
||||
{{ item.current_text }}
|
||||
</p>
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
class="mt-2 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular item card -->
|
||||
<div
|
||||
v-else
|
||||
class="engagement-card"
|
||||
:class="{
|
||||
'engagement-card--preamble': isPreamble,
|
||||
}"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="engagement-card__header" @click="navigateToItem">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="engagement-card__position">
|
||||
{{ item.position }}
|
||||
</div>
|
||||
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
|
||||
<span v-if="item.title" class="engagement-card__title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="engagement-card__type-label">
|
||||
{{ itemTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="engagement-card__body" @click="navigateToItem">
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Mini vote board -->
|
||||
<div v-if="showVoteBoard" class="engagement-card__vote">
|
||||
<MiniVoteBoard
|
||||
:votes-for="mockVotes.votesFor"
|
||||
:votes-against="mockVotes.votesAgainst"
|
||||
:wot-size="7224"
|
||||
:is-permanent="item.is_permanent_vote"
|
||||
:inertia-preset="item.inertia_preset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inertia indicator -->
|
||||
<div class="engagement-card__inertia">
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="engagement-card__actions">
|
||||
<UButton
|
||||
label="Proposer une alternative"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
<UButton
|
||||
label="Voter"
|
||||
icon="i-lucide-vote"
|
||||
variant="soft"
|
||||
color="success"
|
||||
size="xs"
|
||||
@click.stop="navigateToItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Section separator */
|
||||
.engagement-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.engagement-section__line {
|
||||
width: 4px;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-section__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.engagement-section__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.engagement-section__text {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.engagement-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.engagement-card:hover {
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.engagement-card--preamble {
|
||||
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.engagement-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.engagement-card__position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 1.625rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-accent);
|
||||
color: white;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-card__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engagement-card__type-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.engagement-card__body {
|
||||
padding: 0.5rem 1rem 0.625rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.engagement-card__body {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.engagement-card__vote {
|
||||
padding: 0 1rem;
|
||||
opacity: 0.7;
|
||||
transform: scale(0.92);
|
||||
transform-origin: left center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.engagement-card:hover .engagement-card__vote {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.engagement-card__inertia {
|
||||
padding: 0.25rem 1rem 0.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.engagement-card:hover .engagement-card__inertia {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.engagement-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,489 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
||||
* for a reference document. Main block collapsible, each sub-section independently collapsible.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
genesisJson: string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
// Individual section toggles
|
||||
const sectionOpen = reactive<Record<string, boolean>>({
|
||||
source: true,
|
||||
tools: false,
|
||||
forum: true,
|
||||
process: false,
|
||||
contributors: false,
|
||||
})
|
||||
|
||||
function toggleSection(key: string) {
|
||||
sectionOpen[key] = !sectionOpen[key]
|
||||
}
|
||||
|
||||
interface GenesisData {
|
||||
source_document: {
|
||||
title: string
|
||||
url: string
|
||||
repo: string
|
||||
}
|
||||
reference_tools: Record<string, string>
|
||||
forum_synthesis: Array<{
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
posts?: number
|
||||
}>
|
||||
formula_trigger: string
|
||||
contributors: Array<{
|
||||
name: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
const genesis = computed((): GenesisData | null => {
|
||||
try {
|
||||
return JSON.parse(props.genesisJson)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'Rejetée'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'reference': return 'Référence'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'genesis-status--rejected'
|
||||
case 'in_progress': return 'genesis-status--progress'
|
||||
case 'reference': return 'genesis-status--reference'
|
||||
default: return 'genesis-status--default'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="genesis" class="genesis-block">
|
||||
<!-- Header (always visible) -->
|
||||
<button
|
||||
class="genesis-block__header"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="genesis-block__icon">
|
||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
|
||||
Bloc de genèse
|
||||
</h3>
|
||||
<p class="text-xs genesis-text-muted">
|
||||
Sources, références et formule de dépôt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-lg genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
<Transition name="genesis-expand">
|
||||
<div v-if="expanded" class="genesis-block__body">
|
||||
<!-- Source document -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('source')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-file-text" />
|
||||
Document source
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.source" class="genesis-section__content">
|
||||
<div class="genesis-card">
|
||||
<p class="font-medium text-sm genesis-text">
|
||||
{{ genesis.source_document.title }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<a
|
||||
:href="genesis.source_document.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs" />
|
||||
Texte officiel
|
||||
</a>
|
||||
<a
|
||||
:href="genesis.source_document.repo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
Dépôt git
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference tools -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('tools')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
Outils de référence
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
v-for="(url, name) in genesis.reference_tools"
|
||||
:key="name"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--tool"
|
||||
>
|
||||
<span class="text-xs font-semibold capitalize genesis-text">
|
||||
{{ name.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forum synthesis -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('forum')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-messages-square" />
|
||||
Synthèse des discussions
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="topic in genesis.forum_synthesis"
|
||||
:key="topic.url"
|
||||
:href="topic.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--forum"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-xs font-medium genesis-text">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
<span
|
||||
class="genesis-status shrink-0"
|
||||
:class="statusClass(topic.status)"
|
||||
>
|
||||
{{ statusLabel(topic.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="topic.posts" class="text-xs genesis-text-muted">
|
||||
{{ topic.posts }} messages
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula trigger -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('process')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-zap" />
|
||||
Processus de dépôt
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.process" class="genesis-section__content">
|
||||
<div class="genesis-card">
|
||||
<p class="text-xs leading-relaxed genesis-text">
|
||||
{{ genesis.formula_trigger }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-users" />
|
||||
Contributeurs
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="c in genesis.contributors"
|
||||
:key="c.name"
|
||||
class="genesis-contributor"
|
||||
>
|
||||
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
|
||||
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.genesis-block {
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.genesis-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-block__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__header h3 {
|
||||
color: var(--mood-accent) !important;
|
||||
}
|
||||
|
||||
.genesis-block__header p {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.genesis-block__icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-block__body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.genesis-section {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-section__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-section__toggle:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.genesis-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.genesis-section__toggle .text-sm {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.genesis-section__content {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.genesis-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-card .font-medium,
|
||||
.genesis-card .text-xs,
|
||||
.genesis-text {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-card--tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--tool .text-xs {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-card--tool:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-card--forum {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--forum:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.genesis-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.genesis-contributor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-contributor .font-semibold {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-contributor .text-xs:not(.font-semibold) {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Status badges — palette-aware */
|
||||
.genesis-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.genesis-status--reference {
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-status--progress {
|
||||
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
|
||||
color: var(--mood-warning);
|
||||
}
|
||||
|
||||
.genesis-status--rejected {
|
||||
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
|
||||
color: var(--mood-error);
|
||||
}
|
||||
|
||||
.genesis-status--default {
|
||||
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Genesis-context text utilities */
|
||||
.genesis-accent {
|
||||
color: var(--mood-accent) !important;
|
||||
}
|
||||
|
||||
.genesis-text {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-text-muted {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.genesis-muted-icon {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Expand/collapse transition */
|
||||
.genesis-expand-enter-active,
|
||||
.genesis-expand-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.genesis-expand-enter-from,
|
||||
.genesis-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,419 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Inertia slider — displays the inertia preset level for a section.
|
||||
* Read-only indicator (voting on the preset uses the standard vote flow).
|
||||
* In full mode: shows formula diagram with simplified curve visualization.
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
preset: string
|
||||
compact?: boolean
|
||||
mini?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
mini: false,
|
||||
})
|
||||
|
||||
interface InertiaLevel {
|
||||
label: string
|
||||
gradient: number
|
||||
majority: number
|
||||
color: string
|
||||
position: number // 0-100 for slider position
|
||||
description: string
|
||||
}
|
||||
|
||||
const LEVELS: Record<string, InertiaLevel> = {
|
||||
low: {
|
||||
label: 'Remplacement facile',
|
||||
gradient: 0.1,
|
||||
majority: 50,
|
||||
color: '#22c55e',
|
||||
position: 10,
|
||||
description: 'Majorité simple suffit, même à faible participation',
|
||||
},
|
||||
standard: {
|
||||
label: 'Inertie pour le remplacement',
|
||||
gradient: 0.2,
|
||||
majority: 50,
|
||||
color: '#3b82f6',
|
||||
position: 37,
|
||||
description: 'Équilibre : consensus croissant avec la participation',
|
||||
},
|
||||
high: {
|
||||
label: 'Remplacement difficile',
|
||||
gradient: 0.4,
|
||||
majority: 60,
|
||||
color: '#f59e0b',
|
||||
position: 63,
|
||||
description: 'Forte mobilisation et super-majorité requises',
|
||||
},
|
||||
very_high: {
|
||||
label: 'Remplacement très difficile',
|
||||
gradient: 0.6,
|
||||
majority: 66,
|
||||
color: '#ef4444',
|
||||
position: 90,
|
||||
description: 'Quasi-unanimité requise à toute participation',
|
||||
},
|
||||
}
|
||||
|
||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
||||
|
||||
// Generate SVG curve points for the inertia function
|
||||
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
|
||||
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
|
||||
const curvePath = computed(() => {
|
||||
const G = level.value.gradient
|
||||
const M = level.value.majority / 100
|
||||
const points: string[] = []
|
||||
const steps = 40
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const participation = i / steps // T/W ratio 0..1
|
||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
||||
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
|
||||
const x = 30 + participation * 170
|
||||
const y = 10 + (1 - threshold) * 70
|
||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
|
||||
return `M ${points.join(' L ')}`
|
||||
})
|
||||
|
||||
// The 4 curve paths for the diagram overlay
|
||||
const allCurves = computed(() => {
|
||||
return Object.entries(LEVELS).map(([key, lvl]) => {
|
||||
const G = lvl.gradient
|
||||
const M = lvl.majority / 100
|
||||
const points: string[] = []
|
||||
const steps = 40
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const participation = i / steps
|
||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
||||
const x = 30 + participation * 170
|
||||
const y = 10 + (1 - threshold) * 70
|
||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
color: lvl.color,
|
||||
path: `M ${points.join(' L ')}`,
|
||||
active: key === props.preset,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
|
||||
<!-- Slider track -->
|
||||
<div class="inertia__track">
|
||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
||||
<div
|
||||
class="inertia__thumb"
|
||||
:style="{ left: `${level.position}%`, borderColor: level.color }"
|
||||
/>
|
||||
<!-- Level marks -->
|
||||
<div
|
||||
v-for="(lvl, key) in LEVELS"
|
||||
:key="key"
|
||||
class="inertia__mark"
|
||||
:class="{ 'inertia__mark--active': key === preset }"
|
||||
:style="{ left: `${lvl.position}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label row -->
|
||||
<div v-if="mini" class="inertia__info">
|
||||
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
|
||||
Inertie
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="inertia__info">
|
||||
<span class="inertia__label" :style="{ color: level.color }">
|
||||
{{ level.label }}
|
||||
</span>
|
||||
<span v-if="!compact" class="inertia__params">
|
||||
G={{ level.gradient }} M={{ level.majority }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description (not in compact mode) -->
|
||||
<p v-if="!compact" class="inertia__desc">
|
||||
{{ level.description }}
|
||||
</p>
|
||||
|
||||
<!-- Formula diagram (not in compact mode) -->
|
||||
<div v-if="!compact" class="inertia__diagram">
|
||||
<svg viewBox="0 0 220 100" class="inertia__svg">
|
||||
<!-- Grid -->
|
||||
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
|
||||
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
|
||||
|
||||
<!-- Grid lines -->
|
||||
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
|
||||
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
|
||||
|
||||
<!-- Majority line M -->
|
||||
<line
|
||||
x1="30"
|
||||
:y1="10 + (1 - level.majority / 100) * 70"
|
||||
x2="200"
|
||||
:y2="10 + (1 - level.majority / 100) * 70"
|
||||
class="inertia__majority-line"
|
||||
/>
|
||||
<text
|
||||
x="203"
|
||||
:y="13 + (1 - level.majority / 100) * 70"
|
||||
class="inertia__axis-label"
|
||||
style="fill: var(--mood-accent)"
|
||||
>M={{ level.majority }}%</text>
|
||||
|
||||
<!-- Background curves (ghosted) -->
|
||||
<path
|
||||
v-for="curve in allCurves"
|
||||
:key="curve.key"
|
||||
:d="curve.path"
|
||||
fill="none"
|
||||
:stroke="curve.color"
|
||||
:stroke-width="curve.active ? 0 : 1"
|
||||
:opacity="curve.active ? 0 : 0.15"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
|
||||
<!-- Active curve -->
|
||||
<path
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="level.color"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Axis labels -->
|
||||
<text x="15" y="14" class="inertia__axis-label">100%</text>
|
||||
<text x="15" y="49" class="inertia__axis-label">50%</text>
|
||||
<text x="15" y="84" class="inertia__axis-label">0%</text>
|
||||
|
||||
<text x="28" y="95" class="inertia__axis-label">0%</text>
|
||||
<text x="105" y="95" class="inertia__axis-label">50%</text>
|
||||
<text x="185" y="95" class="inertia__axis-label">100%</text>
|
||||
|
||||
<!-- Axis titles -->
|
||||
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
|
||||
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
|
||||
</svg>
|
||||
|
||||
<!-- Simplified formula -->
|
||||
<div class="inertia__formula">
|
||||
<span class="inertia__formula-label">Formule :</span>
|
||||
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
|
||||
</div>
|
||||
<div class="inertia__formula-legend">
|
||||
<span><strong>T</strong> = votes exprimés</span>
|
||||
<span><strong>W</strong> = taille WoT</span>
|
||||
<span><strong>M</strong> = majorité cible</span>
|
||||
<span><strong>G</strong> = gradient d'inertie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inertia {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inertia--compact {
|
||||
gap: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.inertia--mini {
|
||||
gap: 0.125rem;
|
||||
width: fit-content;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.inertia--mini .inertia__track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.inertia--mini .inertia__thumb {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.inertia__track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__track {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.inertia__fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.inertia__thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-bg);
|
||||
border: 3px solid;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.inertia__mark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.inertia__mark--active {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inertia__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inertia__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__label {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.inertia__label--mini {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.inertia__params {
|
||||
font-size: 0.625rem;
|
||||
font-family: monospace;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Diagram */
|
||||
.inertia__diagram {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inertia__svg {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.inertia__axis {
|
||||
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.inertia__grid {
|
||||
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
||||
stroke-width: 0.5;
|
||||
stroke-dasharray: 2 4;
|
||||
}
|
||||
|
||||
.inertia__majority-line {
|
||||
stroke: var(--mood-accent);
|
||||
stroke-width: 0.75;
|
||||
stroke-dasharray: 4 3;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.inertia__axis-label {
|
||||
font-size: 5px;
|
||||
fill: var(--mood-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.inertia__axis-title {
|
||||
font-size: 5px;
|
||||
fill: var(--mood-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inertia__formula {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inertia__formula-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__formula-code {
|
||||
font-size: 0.6875rem;
|
||||
font-family: monospace;
|
||||
color: var(--mood-text);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.inertia__formula-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.5625rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__formula-legend strong {
|
||||
color: var(--mood-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
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 navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateToItem"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Item header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="solid" color="primary" size="xs">
|
||||
{{ item.position }}
|
||||
</UBadge>
|
||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="item.voting_protocol_id"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Sous vote
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Pas de vote
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="pl-2">
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<UButton
|
||||
label="Proposer une modification"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ItemVersion } from '~/stores/documents'
|
||||
|
||||
const props = defineProps<{
|
||||
version: ItemVersion
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: [versionId: string]
|
||||
reject: [versionId: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateAddress(address: string | null): string {
|
||||
if (!address) return 'Inconnu'
|
||||
if (address.length <= 16) return address
|
||||
return address.slice(0, 8) + '...' + address.slice(-6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusBadge :status="version.status" type="version" />
|
||||
<span class="text-sm text-gray-500">
|
||||
Propose par
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
||||
{{ truncateAddress(version.proposed_by) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(version.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rationale -->
|
||||
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff view -->
|
||||
<div v-if="version.diff">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
||||
<DiffView :diff="version.diff" />
|
||||
</div>
|
||||
|
||||
<!-- Proposed text (fallback if no diff) -->
|
||||
<div v-else-if="version.proposed_text">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<MarkdownRenderer :content="version.proposed_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions for authenticated users -->
|
||||
<div
|
||||
v-if="auth.isAuthenticated && version.status === 'proposed'"
|
||||
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<UButton
|
||||
label="Accepter"
|
||||
icon="i-lucide-check"
|
||||
color="success"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('accept', version.id)"
|
||||
/>
|
||||
<UButton
|
||||
label="Rejeter"
|
||||
icon="i-lucide-x"
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('reject', version.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,240 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MiniVoteBoard — compact inline vote status for an engagement item.
|
||||
*
|
||||
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
||||
* Uses mock data when no vote session is linked (dev mode).
|
||||
*/
|
||||
import { useVoteFormula } from '~/composables/useVoteFormula'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
votesFor?: number
|
||||
votesAgainst?: number
|
||||
wotSize?: number
|
||||
isPermanent?: boolean
|
||||
inertiaPreset?: string
|
||||
startsAt?: string | null
|
||||
endsAt?: string | null
|
||||
}>(), {
|
||||
votesFor: 0,
|
||||
votesAgainst: 0,
|
||||
wotSize: 7224,
|
||||
isPermanent: true,
|
||||
inertiaPreset: 'standard',
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
})
|
||||
|
||||
const { computeThreshold } = useVoteFormula()
|
||||
|
||||
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
||||
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
||||
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
||||
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
||||
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
||||
}
|
||||
|
||||
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
||||
|
||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
||||
|
||||
const threshold = computed(() => {
|
||||
if (totalVotes.value === 0) return 1
|
||||
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
||||
})
|
||||
|
||||
const isPassing = computed(() => props.votesFor >= threshold.value)
|
||||
|
||||
const forPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesFor / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const againstPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesAgainst / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const thresholdPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 50
|
||||
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
||||
})
|
||||
|
||||
const participationRate = computed(() => {
|
||||
if (props.wotSize === 0) return 0
|
||||
return (totalVotes.value / props.wotSize) * 100
|
||||
})
|
||||
|
||||
const remaining = computed(() => {
|
||||
const diff = threshold.value - props.votesFor
|
||||
return diff > 0 ? diff : 0
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-board">
|
||||
<!-- Vote type + status on same line -->
|
||||
<div class="mini-board__header">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<template v-if="isPermanent">
|
||||
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
|
||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
|
||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
|
||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
||||
</span>
|
||||
</template>
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'warning'"
|
||||
:variant="isPassing ? 'solid' : 'subtle'"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopté' : 'En attente' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mini-board__bar">
|
||||
<div
|
||||
class="mini-board__bar-for"
|
||||
:style="{ width: `${forPct}%` }"
|
||||
/>
|
||||
<div
|
||||
class="mini-board__bar-against"
|
||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
||||
/>
|
||||
<!-- Threshold marker -->
|
||||
<div
|
||||
v-if="totalVotes > 0"
|
||||
class="mini-board__bar-threshold"
|
||||
:style="{ left: `${thresholdPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="mini-board__stats">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat mini-board__stat--for">
|
||||
{{ votesFor }} pour
|
||||
</span>
|
||||
<span class="mini-board__stat mini-board__stat--against">
|
||||
{{ votesAgainst }} contre
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat">
|
||||
{{ votesFor }}/{{ threshold }} requis
|
||||
</span>
|
||||
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
||||
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation -->
|
||||
<div class="mini-board__footer">
|
||||
<span class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
||||
({{ participationRate.toFixed(2) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.mini-board__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mini-board__bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mini-board__bar-for {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #22c55e;
|
||||
border-radius: 3px 0 0 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-against {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #ef4444;
|
||||
transition: width 0.4s ease, left 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-threshold {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
background: #facc15;
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
transition: left 0.4s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.mini-board__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mini-board__stat {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.mini-board__stat--for {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mini-board__stat--against {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mini-board__stat--remaining {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mini-board__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,243 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Interactive formula parameter editor.
|
||||
*
|
||||
* Provides sliders and inputs for adjusting all formula parameters,
|
||||
* emitting the updated config on each change.
|
||||
*/
|
||||
import type { FormulaConfig } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FormulaConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: FormulaConfig]
|
||||
}>()
|
||||
|
||||
/** Local reactive copy to avoid direct prop mutation. */
|
||||
const local = reactive({ ...props.modelValue })
|
||||
|
||||
/** Sync incoming prop changes. */
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
Object.assign(local, newVal)
|
||||
}, { deep: true })
|
||||
|
||||
/** Emit on any local change. */
|
||||
watch(local, () => {
|
||||
emit('update:modelValue', { ...local })
|
||||
}, { deep: true })
|
||||
|
||||
/** Track optional fields. */
|
||||
const showSmith = ref(local.smith_exponent !== null)
|
||||
const showTechcomm = ref(local.techcomm_exponent !== null)
|
||||
const showNuancedMin = ref(local.nuanced_min_participants !== null)
|
||||
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
|
||||
|
||||
watch(showSmith, (v) => {
|
||||
local.smith_exponent = v ? 0.5 : null
|
||||
})
|
||||
watch(showTechcomm, (v) => {
|
||||
local.techcomm_exponent = v ? 0.5 : null
|
||||
})
|
||||
watch(showNuancedMin, (v) => {
|
||||
local.nuanced_min_participants = v ? 10 : null
|
||||
})
|
||||
watch(showNuancedThreshold, (v) => {
|
||||
local.nuanced_threshold_pct = v ? 66 : null
|
||||
})
|
||||
|
||||
interface ParamDef {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
type: 'input' | 'slider'
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
const mainParams: ParamDef[] = [
|
||||
{
|
||||
key: 'duration_days',
|
||||
label: 'Duree (jours)',
|
||||
description: 'Duree du vote en jours',
|
||||
type: 'input',
|
||||
min: 1,
|
||||
max: 365,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: 'majority_pct',
|
||||
label: 'Majorite (%)',
|
||||
description: 'Ratio de majorite cible a haute participation',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: 'base_exponent',
|
||||
label: 'Exposant de base (B)',
|
||||
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
|
||||
type: 'slider',
|
||||
min: 0.01,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: 'gradient_exponent',
|
||||
label: 'Gradient d\'inertie (G)',
|
||||
description: 'Controle la vitesse de transition vers la majorite simple',
|
||||
type: 'slider',
|
||||
min: 0.01,
|
||||
max: 2.0,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: 'constant_base',
|
||||
label: 'Constante de base (C)',
|
||||
description: 'Plancher fixe de votes requis',
|
||||
type: 'input',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Main parameters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ param.label }}
|
||||
</label>
|
||||
<span class="text-sm font-mono font-bold text-primary">
|
||||
{{ (local as any)[param.key] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="param.type === 'slider'">
|
||||
<URange
|
||||
v-model="(local as any)[param.key]"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UInput
|
||||
v-model.number="(local as any)[param.key]"
|
||||
type="number"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p class="text-xs text-gray-500">{{ param.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional parameters -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
||||
Parametres optionnels
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Smith exponent -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showSmith" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Critere Smith (S)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showSmith && local.smith_exponent !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Exposant Smith</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.smith_exponent"
|
||||
:min="0.01"
|
||||
:max="1.0"
|
||||
:step="0.01"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- TechComm exponent -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showTechcomm" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Critere TechComm (T)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showTechcomm && local.techcomm_exponent !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Exposant TechComm</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.techcomm_exponent"
|
||||
:min="0.01"
|
||||
:max="1.0"
|
||||
:step="0.01"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nuanced min participants -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showNuancedMin" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Participants minimum (nuance)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
|
||||
<UInput
|
||||
v-model.number="local.nuanced_min_participants"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nuanced threshold pct -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showNuancedThreshold" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Seuil nuance (%)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.nuanced_threshold_pct"
|
||||
:min="50"
|
||||
:max="100"
|
||||
:step="1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Display decoded mode params string as labeled badges/chips.
|
||||
*
|
||||
* Parses the compact mode params string and renders each parameter
|
||||
* as a human-readable chip.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modeParams: string
|
||||
}>()
|
||||
|
||||
interface ParamChip {
|
||||
code: string
|
||||
label: string
|
||||
value: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const chips = computed((): ParamChip[] => {
|
||||
if (!props.modeParams) return []
|
||||
|
||||
try {
|
||||
const parsed = parseModeParams(props.modeParams)
|
||||
const result: ParamChip[] = []
|
||||
|
||||
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
|
||||
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
|
||||
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
|
||||
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
|
||||
|
||||
if (parsed.constant_base > 0) {
|
||||
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
|
||||
}
|
||||
if (parsed.smith_exponent !== null) {
|
||||
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
|
||||
}
|
||||
if (parsed.techcomm_exponent !== null) {
|
||||
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
|
||||
<UBadge
|
||||
v-for="chip in chips"
|
||||
:key="chip.code"
|
||||
:color="(chip.color as any)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ chip.label }}: {{ chip.value }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Dropdown/select to pick a voting protocol.
|
||||
*
|
||||
* Fetches protocols from the store and renders them in a USelect
|
||||
* with protocol name, mode params, and vote type badge.
|
||||
*/
|
||||
import type { VotingProtocol } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
voteType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
const protocols = useProtocolsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (protocols.protocols.length === 0) {
|
||||
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const filteredProtocols = computed(() => {
|
||||
if (!props.voteType) return protocols.protocols
|
||||
return protocols.protocols.filter(p => p.vote_type === props.voteType)
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return filteredProtocols.value.map(p => ({
|
||||
label: buildLabel(p),
|
||||
value: p.id,
|
||||
}))
|
||||
})
|
||||
|
||||
function buildLabel(p: VotingProtocol): string {
|
||||
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
|
||||
const params = p.mode_params ? ` [${p.mode_params}]` : ''
|
||||
return `${p.name} - ${typeLabel}${params}`
|
||||
}
|
||||
|
||||
function onSelect(value: string) {
|
||||
emit('update:modelValue', value || null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<USelect
|
||||
:model-value="modelValue ?? undefined"
|
||||
:items="options"
|
||||
placeholder="Selectionnez un protocole..."
|
||||
:loading="protocols.loading"
|
||||
value-key="value"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
|
||||
{{ protocols.error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
txHash: string | null
|
||||
block: number | null
|
||||
}>()
|
||||
|
||||
const truncatedHash = computed(() => {
|
||||
if (!props.txHash) return null
|
||||
if (props.txHash.length <= 20) return props.txHash
|
||||
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="txHash">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedHash }}
|
||||
</span>
|
||||
</div>
|
||||
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
|
||||
Bloc #{{ block.toLocaleString('fr-FR') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="warning" variant="subtle" size="xs">
|
||||
Non ancre
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
cid: string | null
|
||||
}>()
|
||||
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
|
||||
|
||||
const truncatedCid = computed(() => {
|
||||
if (!props.cid) return null
|
||||
if (props.cid.length <= 20) return props.cid
|
||||
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
|
||||
})
|
||||
|
||||
const gatewayUrl = computed(() => {
|
||||
if (!props.cid) return null
|
||||
return `${IPFS_GATEWAY}${props.cid}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="cid">
|
||||
<a
|
||||
:href="gatewayUrl!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
|
||||
>
|
||||
<UIcon name="i-lucide-hard-drive" class="text-sm" />
|
||||
<span>{{ truncatedCid }}</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-sm" />
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="neutral" variant="subtle" size="xs">
|
||||
Non disponible
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
export interface SanctuaryEntryOut {
|
||||
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
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entry: SanctuaryEntryOut
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [id: string]
|
||||
}>()
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateHash(hash: string | null, length: number = 16): string {
|
||||
if (!hash) return '-'
|
||||
if (hash.length <= length * 2) return hash
|
||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
||||
}
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyHash() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.entry.content_hash)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/sanctuary/${entry.id}`)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Entry header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ entry.title || 'Entree sans titre' }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(entry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
|
||||
{{ typeLabel(entry.entry_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Hashes and anchors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Content hash -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
||||
</div>
|
||||
<UButton
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="p-0"
|
||||
@click.stop="copyHash"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.content_hash) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- IPFS CID -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
||||
</div>
|
||||
<div @click.stop>
|
||||
<IPFSLink :cid="entry.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
||||
</div>
|
||||
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification status indicators -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
label="Verifier"
|
||||
icon="i-lucide-shield-check"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('verify', entry.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,659 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ContextMapper — Recommandeur de méthode de décision.
|
||||
* 4 questions de contexte → méthode optimale + justification.
|
||||
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
|
||||
*/
|
||||
|
||||
interface Option { value: string; label: string; icon: string }
|
||||
interface Question { id: string; question: string; hint?: string; options: Option[] }
|
||||
|
||||
interface MethodRec {
|
||||
name: string
|
||||
icon: string
|
||||
tag: string
|
||||
tagColor: string
|
||||
description: string
|
||||
formula?: string
|
||||
when: string
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
}
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
id: 'urgency',
|
||||
question: 'Quelle est l\'urgence ?',
|
||||
hint: 'Le délai disponible avant que la décision soit nécessaire',
|
||||
options: [
|
||||
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
|
||||
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
|
||||
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stakes',
|
||||
question: 'Quel est l\'enjeu ?',
|
||||
hint: 'L\'impact et la réversibilité de la décision',
|
||||
options: [
|
||||
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
|
||||
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
|
||||
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
|
||||
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'groupSize',
|
||||
question: 'Taille du groupe ?',
|
||||
hint: 'Nombre de personnes concernées ou habilitées à voter',
|
||||
options: [
|
||||
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
|
||||
{ value: 'medium', label: '10 – 100', icon: 'i-lucide-users' },
|
||||
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature',
|
||||
question: 'Nature de la décision ?',
|
||||
hint: 'Le type de compétence principalement sollicité',
|
||||
options: [
|
||||
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
|
||||
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
|
||||
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const answers = ref<Record<string, string>>({})
|
||||
const step = ref(0)
|
||||
const animating = ref(false)
|
||||
|
||||
const currentQuestion = computed(() => questions[step.value])
|
||||
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
|
||||
const progress = computed(() => (step.value / questions.length) * 100)
|
||||
|
||||
function selectAnswer(questionId: string, value: string) {
|
||||
answers.value = { ...answers.value, [questionId]: value }
|
||||
if (step.value < questions.length - 1) {
|
||||
animating.value = true
|
||||
setTimeout(() => {
|
||||
step.value++
|
||||
animating.value = false
|
||||
}, 160)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step.value > 0) step.value--
|
||||
}
|
||||
|
||||
function reset() {
|
||||
answers.value = {}
|
||||
step.value = 0
|
||||
}
|
||||
|
||||
const recommendation = computed((): MethodRec | null => {
|
||||
if (!isComplete.value) return null
|
||||
const { urgency, stakes, groupSize, nature } = answers.value
|
||||
|
||||
// Immediate → Advice process (Laloux)
|
||||
if (urgency === 'immediate') {
|
||||
return {
|
||||
name: 'Processus de sollicitation d\'avis',
|
||||
icon: 'i-lucide-message-circle',
|
||||
tag: 'Laloux / Teal',
|
||||
tagColor: 'teal',
|
||||
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
|
||||
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
|
||||
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
|
||||
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
|
||||
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
|
||||
}
|
||||
}
|
||||
|
||||
// Technical + medium/large → Smith WoT
|
||||
if (nature === 'technical' && groupSize !== 'small') {
|
||||
return {
|
||||
name: 'Vote inertiel WoT + critère Smith',
|
||||
icon: 'i-lucide-network',
|
||||
tag: 'G1 standard',
|
||||
tagColor: 'accent',
|
||||
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nSeuil Smith : ⌈SmithWoT^S⌉',
|
||||
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
|
||||
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
|
||||
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
|
||||
}
|
||||
}
|
||||
|
||||
// Irreversible + large → High threshold WoT
|
||||
if (stakes === 'irreversible' && groupSize === 'large') {
|
||||
return {
|
||||
name: 'Vote inertiel WoT (inertie forte)',
|
||||
icon: 'i-lucide-shield',
|
||||
tag: 'G1 renforcé',
|
||||
tagColor: 'secondary',
|
||||
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nParamètres : M=67%, G=0.3 (inertie forte)',
|
||||
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
|
||||
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
|
||||
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
|
||||
}
|
||||
}
|
||||
|
||||
// Small group → Sociocratic consent
|
||||
if (groupSize === 'small') {
|
||||
return {
|
||||
name: 'Consentement sociocratique',
|
||||
icon: 'i-lucide-check-circle-2',
|
||||
tag: 'Sociocracie',
|
||||
tagColor: 'tertiary',
|
||||
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
|
||||
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
|
||||
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
|
||||
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
|
||||
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
|
||||
}
|
||||
}
|
||||
|
||||
// Political + medium → WoT majority
|
||||
if (nature === 'political') {
|
||||
return {
|
||||
name: 'Vote majoritaire WoT',
|
||||
icon: 'i-lucide-vote',
|
||||
tag: 'G1 standard',
|
||||
tagColor: 'accent',
|
||||
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)',
|
||||
when: 'Décision politique communautaire, participation variable, groupe >10.',
|
||||
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
|
||||
cons: ['Durée 7-30j', 'Participation faible possible'],
|
||||
}
|
||||
}
|
||||
|
||||
// Default: minor/operational
|
||||
return {
|
||||
name: 'Advice process + validation légère',
|
||||
icon: 'i-lucide-thumbs-up',
|
||||
tag: 'Léger',
|
||||
tagColor: 'teal',
|
||||
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
|
||||
formula: 'Consultation → Décision → Notification (sans vote formel)',
|
||||
when: 'Décision opérationnelle de faible impact, facilement réversible.',
|
||||
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
|
||||
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ use: [name: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cmap">
|
||||
<!-- Header -->
|
||||
<div class="cmap__head">
|
||||
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
|
||||
<div>
|
||||
<h3 class="cmap__title">Choisir une méthode</h3>
|
||||
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<Transition name="fade-up" mode="out-in">
|
||||
<div v-if="isComplete" key="result" class="cmap__result">
|
||||
<div class="cmap__result-header">
|
||||
<div class="cmap__result-icon">
|
||||
<UIcon :name="recommendation!.icon" />
|
||||
</div>
|
||||
<div class="cmap__result-info">
|
||||
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
|
||||
{{ recommendation!.tag }}
|
||||
</span>
|
||||
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
|
||||
|
||||
<div v-if="recommendation!.formula" class="cmap__formula">
|
||||
<span class="cmap__formula-label">Formule</span>
|
||||
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="cmap__pros-cons">
|
||||
<div>
|
||||
<span class="cmap__pros-label">Pour</span>
|
||||
<ul class="cmap__list cmap__list--pro">
|
||||
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cmap__cons-label">Contre</span>
|
||||
<ul class="cmap__list cmap__list--con">
|
||||
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cmap__when">
|
||||
<UIcon name="i-lucide-lightbulb" />
|
||||
{{ recommendation!.when }}
|
||||
</p>
|
||||
|
||||
<div class="cmap__result-actions">
|
||||
<button class="cmap__btn-reset" @click="reset">
|
||||
<UIcon name="i-lucide-refresh-cw" />
|
||||
Recommencer
|
||||
</button>
|
||||
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
|
||||
<UIcon name="i-lucide-play" />
|
||||
Utiliser cette méthode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz -->
|
||||
<div v-else key="quiz" class="cmap__quiz">
|
||||
<!-- Progress -->
|
||||
<div class="cmap__progress">
|
||||
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
|
||||
|
||||
<!-- Question -->
|
||||
<Transition name="slide-right" mode="out-in">
|
||||
<div :key="step" class="cmap__question-block">
|
||||
<p class="cmap__question">{{ currentQuestion.question }}</p>
|
||||
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
|
||||
|
||||
<div class="cmap__options">
|
||||
<button
|
||||
v-for="opt in currentQuestion.options"
|
||||
:key="opt.value"
|
||||
class="cmap__option"
|
||||
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
|
||||
@click="selectAnswer(currentQuestion.id, opt.value)"
|
||||
>
|
||||
<div class="cmap__option-icon">
|
||||
<UIcon :name="opt.icon" />
|
||||
</div>
|
||||
<span class="cmap__option-label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<button v-if="step > 0" class="cmap__back" @click="goBack">
|
||||
<UIcon name="i-lucide-chevron-left" />
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cmap__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__head-icon {
|
||||
font-size: 1.375rem;
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.cmap__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmap__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.cmap__progress {
|
||||
height: 4px;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmap__progress-bar {
|
||||
height: 100%;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.cmap__step-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Question */
|
||||
.cmap__question-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__question {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cmap__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cmap__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cmap__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
|
||||
text-align: left;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.cmap__option:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.cmap__option:active { transform: translateY(0); }
|
||||
|
||||
.cmap__option--selected {
|
||||
background: var(--mood-accent);
|
||||
}
|
||||
|
||||
.cmap__option--selected .cmap__option-icon,
|
||||
.cmap__option--selected .cmap__option-label {
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.cmap__option-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cmap__option--selected .cmap__option-icon {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.cmap__option-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.cmap__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0;
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
.cmap__back:hover { color: var(--mood-text); }
|
||||
|
||||
/* Result */
|
||||
.cmap__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.cmap__result-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__result-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.cmap__result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__result-tag {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
width: fit-content;
|
||||
}
|
||||
.cmap__result-tag--accent {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
.cmap__result-tag--teal {
|
||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||
color: var(--mood-success);
|
||||
}
|
||||
.cmap__result-tag--secondary {
|
||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
|
||||
color: var(--mood-secondary, var(--mood-accent));
|
||||
}
|
||||
.cmap__result-tag--tertiary {
|
||||
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
||||
color: var(--mood-tertiary, var(--mood-accent));
|
||||
}
|
||||
|
||||
.cmap__result-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cmap__result-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmap__formula {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__formula-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.cmap__formula-code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cmap__pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cmap__pros-label,
|
||||
.cmap__cons-label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.cmap__pros-label { color: var(--mood-success); }
|
||||
.cmap__cons-label { color: var(--mood-error); }
|
||||
|
||||
.cmap__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__list li {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
padding-left: 0.875rem;
|
||||
position: relative;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cmap__list--pro li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--mood-success);
|
||||
font-weight: 700;
|
||||
font-size: 0.5rem;
|
||||
top: 0.2em;
|
||||
}
|
||||
|
||||
.cmap__list--con li::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--mood-error);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cmap__when {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cmap__result-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmap__btn-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||
|
||||
.cmap__btn-use {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.125rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.cmap__btn-use:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
|
||||
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
|
||||
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
|
||||
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
|
||||
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
|
||||
</style>
|
||||
@@ -1,666 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SocioElection — Guide processus d'élection sociocratique.
|
||||
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
|
||||
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
|
||||
*/
|
||||
|
||||
interface Step {
|
||||
num: number
|
||||
title: string
|
||||
actor: string
|
||||
duration: string
|
||||
icon: string
|
||||
description: string
|
||||
tips: string[]
|
||||
pitfall?: string
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
num: 1,
|
||||
title: 'Clarifier le rôle',
|
||||
actor: 'Facilitateur + cercle',
|
||||
duration: '10-15 min',
|
||||
icon: 'i-lucide-clipboard-list',
|
||||
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
|
||||
tips: [
|
||||
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
|
||||
'Fixer une durée standard (ex: 1 an renouvelable)',
|
||||
'Identifier les compétences nécessaires — pas souhaitables',
|
||||
],
|
||||
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Nommer en silence',
|
||||
actor: 'Tous les membres',
|
||||
duration: '3-5 min',
|
||||
icon: 'i-lucide-pencil',
|
||||
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
|
||||
tips: [
|
||||
'Pas de discussion pendant cette étape',
|
||||
'S\'auto-nommer est bienvenu et valorisé',
|
||||
'Une seule nomination par personne',
|
||||
],
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Recueillir les nominations',
|
||||
actor: 'Facilitateur',
|
||||
duration: '5-10 min',
|
||||
icon: 'i-lucide-list-checks',
|
||||
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
|
||||
tips: [
|
||||
'Lire nom + raison tels qu\'écrits',
|
||||
'Le facilitateur lit aussi sa propre nomination',
|
||||
'Compter et afficher les nominations',
|
||||
],
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
title: 'Argumenter',
|
||||
actor: 'Chaque membre',
|
||||
duration: '1-2 min / personne',
|
||||
icon: 'i-lucide-message-square',
|
||||
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
|
||||
tips: [
|
||||
'1 minute maximum par personne',
|
||||
'Argumenter pour, pas contre',
|
||||
'Les candidats s\'expriment aussi brièvement',
|
||||
],
|
||||
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
title: 'Lever les objections',
|
||||
actor: 'Facilitateur + cercle',
|
||||
duration: '5-15 min',
|
||||
icon: 'i-lucide-shield-check',
|
||||
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
|
||||
tips: [
|
||||
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
|
||||
'Une objection peut mener à reconsidérer une candidature',
|
||||
'L\'élu·e peut décliner — c\'est légitime',
|
||||
],
|
||||
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
title: 'Célébrer',
|
||||
actor: 'Tous',
|
||||
duration: '2-3 min',
|
||||
icon: 'i-lucide-star',
|
||||
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
|
||||
tips: [
|
||||
'Documenter l\'élection (date, durée, personnes présentes)',
|
||||
'Annoncer à la communauté au sens large',
|
||||
'Fixer la prochaine évaluation du rôle',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const expandedStep = ref<number | null>(null)
|
||||
|
||||
function toggleStep(num: number) {
|
||||
expandedStep.value = expandedStep.value === num ? null : num
|
||||
}
|
||||
|
||||
// Advice process (Laloux)
|
||||
const adviceSteps = [
|
||||
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
|
||||
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
|
||||
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
|
||||
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
|
||||
]
|
||||
|
||||
// Role clarity framework
|
||||
interface RoleAxis {
|
||||
label: string
|
||||
icon: string
|
||||
question: string
|
||||
example: string
|
||||
}
|
||||
|
||||
const roleAxes: RoleAxis[] = [
|
||||
{
|
||||
label: 'Mission',
|
||||
icon: 'i-lucide-target',
|
||||
question: 'Pourquoi ce rôle existe-t-il ?',
|
||||
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
|
||||
},
|
||||
{
|
||||
label: 'Domaine',
|
||||
icon: 'i-lucide-shield',
|
||||
question: 'Sur quoi a-t-il autorité exclusive ?',
|
||||
example: 'Configuration des serveurs de forge, rotation des clés',
|
||||
},
|
||||
{
|
||||
label: 'Redevabilités',
|
||||
icon: 'i-lucide-check-square',
|
||||
question: 'Quelles activités doit-il assurer ?',
|
||||
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
|
||||
},
|
||||
{
|
||||
label: 'Durée',
|
||||
icon: 'i-lucide-calendar',
|
||||
question: 'Pour combien de temps ?',
|
||||
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
|
||||
},
|
||||
]
|
||||
|
||||
const activeTab = ref<'election' | 'advice' | 'role'>('election')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="se">
|
||||
<!-- Tabs -->
|
||||
<div class="se__tabs">
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'election' }"
|
||||
@click="activeTab = 'election'"
|
||||
>
|
||||
<UIcon name="i-lucide-users" />
|
||||
Élection
|
||||
</button>
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'advice' }"
|
||||
@click="activeTab = 'advice'"
|
||||
>
|
||||
<UIcon name="i-lucide-message-circle" />
|
||||
Conseil
|
||||
</button>
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'role' }"
|
||||
@click="activeTab = 'role'"
|
||||
>
|
||||
<UIcon name="i-lucide-clipboard-list" />
|
||||
Rôle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Election sociocratique -->
|
||||
<div v-if="activeTab === 'election'" class="se__panel">
|
||||
<p class="se__intro">
|
||||
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
|
||||
et le consentement collectif — pas sur la popularité.
|
||||
</p>
|
||||
|
||||
<div class="se__steps">
|
||||
<div
|
||||
v-for="s in steps"
|
||||
:key="s.num"
|
||||
class="se__step"
|
||||
:class="{ 'se__step--open': expandedStep === s.num }"
|
||||
>
|
||||
<button class="se__step-head" @click="toggleStep(s.num)">
|
||||
<div class="se__step-num">{{ s.num }}</div>
|
||||
<div class="se__step-icon">
|
||||
<UIcon :name="s.icon" />
|
||||
</div>
|
||||
<div class="se__step-info">
|
||||
<span class="se__step-title">{{ s.title }}</span>
|
||||
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="se__step-toggle"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedStep === s.num" class="se__step-body">
|
||||
<p class="se__step-desc">{{ s.description }}</p>
|
||||
<ul class="se__step-tips">
|
||||
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
|
||||
</ul>
|
||||
<div v-if="s.pitfall" class="se__step-pitfall">
|
||||
<UIcon name="i-lucide-alert-triangle" />
|
||||
{{ s.pitfall }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advice process -->
|
||||
<div v-if="activeTab === 'advice'" class="se__panel">
|
||||
<div class="se__advice-header">
|
||||
<span class="se__advice-tag">Laloux / Teal</span>
|
||||
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
|
||||
<p class="se__advice-subtitle">
|
||||
Toute personne peut prendre une décision — à condition d'avoir d'abord
|
||||
consulté les experts et les impactés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-steps">
|
||||
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
|
||||
<div class="se__advice-dot">
|
||||
<UIcon :name="as.icon" />
|
||||
</div>
|
||||
<span class="se__advice-text">{{ as.text }}</span>
|
||||
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-rule">
|
||||
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
|
||||
<div>
|
||||
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
|
||||
consulter largement. Mais la décision finale appartient toujours à celui ou
|
||||
celle qui l'a initiée.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-when">
|
||||
<div class="se__advice-when-item se__advice-when-item--yes">
|
||||
<span class="se__advice-when-label">Adapter pour</span>
|
||||
<ul>
|
||||
<li>Décisions urgentes</li>
|
||||
<li>Rôles bien définis</li>
|
||||
<li>Culture de confiance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="se__advice-when-item se__advice-when-item--no">
|
||||
<span class="se__advice-when-label">Éviter si</span>
|
||||
<ul>
|
||||
<li>Décision irréversible</li>
|
||||
<li>Groupe > 100 personnes</li>
|
||||
<li>Enjeu fondateur</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role clarity -->
|
||||
<div v-if="activeTab === 'role'" class="se__panel">
|
||||
<p class="se__intro">
|
||||
Un rôle bien défini évite les zones grises, les conflits d'autorité
|
||||
et les mandats flous. Quatre axes suffisent.
|
||||
</p>
|
||||
|
||||
<div class="se__role-axes">
|
||||
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
|
||||
<div class="se__role-axis-icon">
|
||||
<UIcon :name="axis.icon" />
|
||||
</div>
|
||||
<div class="se__role-axis-body">
|
||||
<span class="se__role-axis-label">{{ axis.label }}</span>
|
||||
<p class="se__role-axis-question">{{ axis.question }}</p>
|
||||
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__role-tip">
|
||||
<UIcon name="i-lucide-info" />
|
||||
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
|
||||
de gouvernance sans changer la personne qui le tient.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.se { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
/* Tabs */
|
||||
.se__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.se__tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.se__tab--active {
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
box-shadow: 0 1px 4px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||
|
||||
.se__intro {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
|
||||
.se__step {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se__step--open { background: var(--mood-surface); }
|
||||
|
||||
.se__step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.se__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;
|
||||
}
|
||||
|
||||
.se__step-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.se__step-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.se__step-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.se__step-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.se__step-toggle {
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se__step-body {
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.se__step-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__step-tips {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
list-style-type: disc;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.se__step-tips li::marker { color: var(--mood-accent); }
|
||||
|
||||
.se__step-pitfall {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Advice */
|
||||
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.se__advice-tag {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||
color: var(--mood-success);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.se__advice-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__advice-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.se__advice-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.se__advice-dot {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.se__advice-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
padding-top: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.se__advice-line {
|
||||
position: absolute;
|
||||
left: calc(1rem - 1px);
|
||||
top: calc(0.5rem + 2rem);
|
||||
width: 2px;
|
||||
height: calc(100% - 2rem + 0.5rem);
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-rule {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
|
||||
.se__advice-rule strong { color: var(--mood-text); }
|
||||
|
||||
.se__advice-when {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item {
|
||||
padding: 0.625rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item--yes {
|
||||
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-when-item--no {
|
||||
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-when-label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
|
||||
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
|
||||
|
||||
.se__advice-when-item ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
list-style-type: disc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Role */
|
||||
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||
|
||||
.se__role-axis {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.se__role-axis-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.se__role-axis-body { flex: 1; min-width: 0; }
|
||||
|
||||
.se__role-axis-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.se__role-axis-question {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__role-axis-example {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0.125rem 0 0;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.se__role-tip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Expand transition */
|
||||
.expand-enter-active, .expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-enter-from, .expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.expand-enter-to, .expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
|
||||
* Toggle le contenu pour économiser la hauteur visible.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
icon?: string
|
||||
defaultOpen?: boolean
|
||||
}>(),
|
||||
{
|
||||
icon: undefined,
|
||||
defaultOpen: false,
|
||||
},
|
||||
)
|
||||
|
||||
const open = ref(props.defaultOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tsection" :class="{ 'tsection--open': open }">
|
||||
<button class="tsection__header" @click="open = !open">
|
||||
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
|
||||
<span class="tsection__title">{{ title }}</span>
|
||||
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
|
||||
</button>
|
||||
<div v-show="open" class="tsection__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tsection {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tsection__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.tsection__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.tsection__icon {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tsection__title {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tsection__chevron {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.6;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tsection--open .tsection__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tsection__body {
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,551 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WorkflowMilestones — 11 jalons de protocole de fonctionnement.
|
||||
* Sélectif et qualitatif : ce qui fait la différence entre un protocole
|
||||
* qui tient et un qui dérive.
|
||||
* Référence : g1vote, sociocracie, Laloux, Elinor Ostrom (gouvernance des communs).
|
||||
*/
|
||||
|
||||
interface Milestone {
|
||||
num: number
|
||||
name: string
|
||||
icon: string
|
||||
actor: string
|
||||
duration: { min: string; standard: string; major: string }
|
||||
description: string
|
||||
essential: boolean
|
||||
tip?: string
|
||||
ostrom?: string
|
||||
}
|
||||
|
||||
const milestones: Milestone[] = [
|
||||
{
|
||||
num: 1,
|
||||
name: 'Prise d\'initiative',
|
||||
icon: 'i-lucide-lightbulb',
|
||||
actor: 'Tout membre',
|
||||
duration: { min: '—', standard: '1-2j', major: '1-2j' },
|
||||
description: 'Formaliser l\'intention : quel problème, quel besoin, quelle cible visée. Nommer un·e porteur·euse responsable.',
|
||||
essential: true,
|
||||
tip: 'Une initiative sans porteur identifié ne décolle pas. La responsabilité individuelle est le premier jalon.',
|
||||
ostrom: 'Principe 1 — Frontières claires : qui est concerné, pourquoi.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
name: 'Processus d\'avis (advice)',
|
||||
icon: 'i-lucide-message-circle',
|
||||
actor: 'Porteur + experts + impactés',
|
||||
duration: { min: '1j', standard: '3-7j', major: '7-14j' },
|
||||
description: 'Consulter les personnes qui ont l\'expertise ET celles qui seront impactées. Écouter vraiment, intégrer ou expliquer pourquoi on n\'intègre pas.',
|
||||
essential: true,
|
||||
tip: 'Ce jalon est souvent escamoté. C\'est la principale cause d\'échec ou de résistance en implémentation.',
|
||||
ostrom: 'Principe 5 — Résolution des conflits accessible et peu coûteuse.',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
name: 'Rédaction + amendements',
|
||||
icon: 'i-lucide-file-edit',
|
||||
actor: 'Porteur + communauté',
|
||||
duration: { min: '1-2j', standard: '3-7j', major: '7-21j' },
|
||||
description: 'Rédiger la proposition formelle. Ouvrir une période d\'amendements publics. Intégrer les modifications acceptées, rejeter les autres avec justification.',
|
||||
essential: true,
|
||||
tip: 'Distinguer amendements substantiels (re-vote possible) et de forme (porteur décide).',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
name: 'Qualification technique',
|
||||
icon: 'i-lucide-shield-check',
|
||||
actor: 'Comité technique (si applicable)',
|
||||
duration: { min: '—', standard: '2-5j', major: '5-10j' },
|
||||
description: 'Pour les décisions techniques : revue par les experts désignés. Évaluation de faisabilité, risques, impact. Avis formel (non bloquant, sauf veto défini).',
|
||||
essential: false,
|
||||
tip: 'Optionnel selon la nature de la décision. Systématique pour les Runtime Upgrades.',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
name: 'Ouverture du vote',
|
||||
icon: 'i-lucide-vote',
|
||||
actor: 'Porteur + plateforme',
|
||||
duration: { min: '—', standard: '1j', major: '1j' },
|
||||
description: 'Publier la proposition finale. Notifier la communauté. Ouvrir la session de vote avec les paramètres définis (protocole, formule, durée).',
|
||||
essential: true,
|
||||
tip: 'L\'ouverture doit être annoncée à l\'avance (délai de préavis selon règlement).',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
name: 'Phase de vote',
|
||||
icon: 'i-lucide-bar-chart-2',
|
||||
actor: 'Membres habilités',
|
||||
duration: { min: '3j', standard: '7-14j', major: '21-30j' },
|
||||
description: 'Les membres habilités votent selon le protocole. Seuil de participation minimal surveillé. Résultats intermédiaires visibles (ou non, selon le protocole).',
|
||||
essential: true,
|
||||
ostrom: 'Principe 3 — Choix collectifs : ceux qui sont concernés participent aux décisions.',
|
||||
},
|
||||
{
|
||||
num: 7,
|
||||
name: 'Contrôle du quorum',
|
||||
icon: 'i-lucide-check-circle',
|
||||
actor: 'Plateforme + porteur',
|
||||
duration: { min: '—', standard: '—', major: '—' },
|
||||
description: 'Vérifier que le quorum minimum est atteint avant clôture. Si non atteint : prolonger, relancer, ou annuler selon les règles préétablies.',
|
||||
essential: true,
|
||||
tip: 'Définir à l\'avance le quorum et la procédure si non atteint — évite les ambiguïtés.',
|
||||
ostrom: 'Principe 4 — Supervision des règles par les membres.',
|
||||
},
|
||||
{
|
||||
num: 8,
|
||||
name: 'Proclamation des résultats',
|
||||
icon: 'i-lucide-megaphone',
|
||||
actor: 'Plateforme + porteur',
|
||||
duration: { min: '—', standard: '1j', major: '1j' },
|
||||
description: 'Annoncer le résultat officiel avec les chiffres détaillés (votes pour, contre, abstentions, taux participation, seuil requis). Archiver on-chain si adopté.',
|
||||
essential: true,
|
||||
tip: 'La transparence des résultats est aussi importante que le résultat lui-même.',
|
||||
ostrom: 'Principe 8 — Gouvernance emboîtée : résultats remontés aux niveaux supérieurs.',
|
||||
},
|
||||
{
|
||||
num: 9,
|
||||
name: 'Mise en application',
|
||||
icon: 'i-lucide-play-circle',
|
||||
actor: 'Porteur + implémenteurs',
|
||||
duration: { min: '—', standard: 'Variable', major: 'Variable' },
|
||||
description: 'Planifier l\'application effective de la décision. Désigner les responsables. Fixer des jalons d\'implémentation si complexe.',
|
||||
essential: true,
|
||||
tip: 'Une décision adoptée mais non implémentée érode la confiance dans le processus.',
|
||||
},
|
||||
{
|
||||
num: 10,
|
||||
name: 'Suivi et accountability',
|
||||
icon: 'i-lucide-activity',
|
||||
actor: 'Porteur + communauté',
|
||||
duration: { min: '—', standard: 'Continu', major: 'Continu' },
|
||||
description: 'Rapports réguliers sur l\'avancement. Signalement des écarts. Mécanisme de remontée si la décision produit des effets inattendus.',
|
||||
essential: false,
|
||||
tip: 'Intégrer dans le prochain cycle de gouvernance si des ajustements s\'imposent.',
|
||||
ostrom: 'Principe 4 — Surveillance continue des comportements et résultats.',
|
||||
},
|
||||
{
|
||||
num: 11,
|
||||
name: 'Rétrospective',
|
||||
icon: 'i-lucide-rotate-ccw',
|
||||
actor: 'Cercle concerné',
|
||||
duration: { min: '—', standard: '1-2h', major: '1-2j' },
|
||||
description: 'Évaluer : le processus a-t-il bien fonctionné ? La décision produit-elle les effets attendus ? Quoi améliorer pour la prochaine fois ?',
|
||||
essential: false,
|
||||
tip: 'La rétrospective est le moteur d\'amélioration du protocole lui-même (méta-gouvernance).',
|
||||
ostrom: 'Principe 7 — Reconnaissance externe de l\'organisation par des autorités supérieures.',
|
||||
},
|
||||
]
|
||||
|
||||
const showOstrom = ref(false)
|
||||
const activeDecisionType = ref<'minor' | 'standard' | 'major'>('standard')
|
||||
|
||||
const decisionTypes = [
|
||||
{ value: 'minor', label: 'Mineur', color: 'teal' },
|
||||
{ value: 'standard', label: 'Standard', color: 'accent' },
|
||||
{ value: 'major', label: 'Majeur', color: 'secondary' },
|
||||
]
|
||||
|
||||
const essentialMilestones = computed(() =>
|
||||
milestones.filter(m => m.essential),
|
||||
)
|
||||
|
||||
const optionalMilestones = computed(() =>
|
||||
milestones.filter(m => !m.essential),
|
||||
)
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
const type = activeDecisionType.value
|
||||
const durations = {
|
||||
minor: '5-10 jours',
|
||||
standard: '14-30 jours',
|
||||
major: '45-90 jours',
|
||||
}
|
||||
return durations[type]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wm">
|
||||
<!-- Header -->
|
||||
<div class="wm__header">
|
||||
<h3 class="wm__title">Jalons de protocole</h3>
|
||||
<p class="wm__subtitle">
|
||||
11 jalons, dont 7 indispensables. Durées recommandées selon le type de décision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Decision type selector -->
|
||||
<div class="wm__type-selector">
|
||||
<button
|
||||
v-for="dt in decisionTypes"
|
||||
:key="dt.value"
|
||||
class="wm__type-btn"
|
||||
:class="[
|
||||
`wm__type-btn--${dt.color}`,
|
||||
{ 'wm__type-btn--active': activeDecisionType === dt.value },
|
||||
]"
|
||||
@click="activeDecisionType = dt.value as 'minor' | 'standard' | 'major'"
|
||||
>
|
||||
{{ dt.label }}
|
||||
</button>
|
||||
<span class="wm__total-duration">≈ {{ totalDuration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Essential milestones -->
|
||||
<div class="wm__section">
|
||||
<div class="wm__section-label">
|
||||
<span class="wm__section-badge wm__section-badge--essential">7 essentiels</span>
|
||||
</div>
|
||||
<div class="wm__milestones">
|
||||
<div
|
||||
v-for="m in essentialMilestones"
|
||||
:key="m.num"
|
||||
class="wm__milestone wm__milestone--essential"
|
||||
>
|
||||
<div class="wm__milestone-left">
|
||||
<div class="wm__milestone-num">{{ m.num }}</div>
|
||||
<div v-if="m.num < milestones.length" class="wm__milestone-line" />
|
||||
</div>
|
||||
<div class="wm__milestone-icon">
|
||||
<UIcon :name="m.icon" />
|
||||
</div>
|
||||
<div class="wm__milestone-body">
|
||||
<div class="wm__milestone-head">
|
||||
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||
<span class="wm__milestone-duration">
|
||||
{{ m.duration[activeDecisionType] || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||
<div v-if="m.tip" class="wm__milestone-tip">
|
||||
<UIcon name="i-lucide-lightbulb" />
|
||||
{{ m.tip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional milestones -->
|
||||
<div class="wm__section">
|
||||
<div class="wm__section-label">
|
||||
<span class="wm__section-badge wm__section-badge--optional">4 contextuels</span>
|
||||
</div>
|
||||
<div class="wm__milestones">
|
||||
<div
|
||||
v-for="m in optionalMilestones"
|
||||
:key="m.num"
|
||||
class="wm__milestone wm__milestone--optional"
|
||||
>
|
||||
<div class="wm__milestone-left">
|
||||
<div class="wm__milestone-num wm__milestone-num--optional">{{ m.num }}</div>
|
||||
</div>
|
||||
<div class="wm__milestone-icon wm__milestone-icon--optional">
|
||||
<UIcon :name="m.icon" />
|
||||
</div>
|
||||
<div class="wm__milestone-body">
|
||||
<div class="wm__milestone-head">
|
||||
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||
<span class="wm__milestone-duration">
|
||||
{{ m.duration[activeDecisionType] || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ostrom toggle -->
|
||||
<button class="wm__ostrom-toggle" @click="showOstrom = !showOstrom">
|
||||
<UIcon name="i-lucide-book-open" />
|
||||
<span>Principes Ostrom appliqués</span>
|
||||
<UIcon :name="showOstrom ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-if="showOstrom" class="wm__ostrom">
|
||||
<p class="wm__ostrom-intro">
|
||||
Elinor Ostrom (Nobel 2009) a identifié 8 principes pour la gouvernance
|
||||
durable des communs. Les jalons ci-dessus les incarnent.
|
||||
</p>
|
||||
<div class="wm__ostrom-items">
|
||||
<div
|
||||
v-for="m in milestones.filter(x => x.ostrom)"
|
||||
:key="m.num"
|
||||
class="wm__ostrom-item"
|
||||
>
|
||||
<span class="wm__ostrom-jalon">Jalon {{ m.num }}</span>
|
||||
<span class="wm__ostrom-text">{{ m.ostrom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wm { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.wm__header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.wm__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wm__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Type selector */
|
||||
.wm__type-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm__type-btn {
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.wm__type-btn--accent.wm__type-btn--active {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.wm__type-btn--teal.wm__type-btn--active {
|
||||
background: color-mix(in srgb, var(--mood-success) 20%, transparent);
|
||||
color: var(--mood-success);
|
||||
}
|
||||
|
||||
.wm__type-btn--secondary.wm__type-btn--active {
|
||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
||||
color: var(--mood-secondary, var(--mood-accent));
|
||||
}
|
||||
|
||||
.wm__total-duration {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.wm__section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.wm__section-label { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.wm__section-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.wm__section-badge--essential {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.wm__section-badge--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 12%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Milestones */
|
||||
.wm__milestones { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.wm__milestone {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.wm__milestone-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.375rem;
|
||||
}
|
||||
|
||||
.wm__milestone-num {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wm__milestone-num--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 20%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 1.25rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wm__milestone-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wm__milestone-icon--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 10%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.wm__milestone-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm__milestone-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.wm__milestone--optional .wm__milestone-name {
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-duration {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.wm__milestone-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
.wm__milestone-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-accent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Ostrom */
|
||||
.wm__ostrom-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
transition: color 0.12s ease;
|
||||
text-align: left;
|
||||
}
|
||||
.wm__ostrom-toggle:hover { color: var(--mood-text); }
|
||||
.wm__ostrom-toggle .i-lucide-book-open { color: var(--mood-accent); }
|
||||
|
||||
.wm__ostrom {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
padding: 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wm__ostrom-intro {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wm__ostrom-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wm__ostrom-item {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wm__ostrom-jalon {
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wm__ostrom-text { color: var(--mood-text-muted); }
|
||||
|
||||
/* Expand transition */
|
||||
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
|
||||
.expand-enter-to, .expand-leave-from { max-height: 1000px; opacity: 1; }
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Display vote formula with KaTeX rendering.
|
||||
*
|
||||
* Renders the WoT threshold formula using KaTeX when available,
|
||||
* falling back to a code display. Shows parameter values and
|
||||
* optional Smith/TechComm criteria formulas.
|
||||
*/
|
||||
import type { FormulaConfig } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
formulaConfig: FormulaConfig
|
||||
showExplanation?: boolean
|
||||
}>()
|
||||
|
||||
const showExplain = ref(props.showExplanation ?? false)
|
||||
|
||||
/**
|
||||
* Render a LaTeX string to HTML using KaTeX, with code fallback.
|
||||
*/
|
||||
function renderFormula(tex: string): string {
|
||||
if (typeof window !== 'undefined' && (window as any).katex) {
|
||||
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
return `<code class="text-sm font-mono">${tex}</code>`
|
||||
}
|
||||
|
||||
/** Main threshold formula in LaTeX. */
|
||||
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
|
||||
|
||||
/** Smith criterion formula. */
|
||||
const smithFormulaTeX = computed(() => {
|
||||
if (props.formulaConfig.smith_exponent === null) return null
|
||||
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
|
||||
})
|
||||
|
||||
/** TechComm criterion formula. */
|
||||
const techcommFormulaTeX = computed(() => {
|
||||
if (props.formulaConfig.techcomm_exponent === null) return null
|
||||
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
|
||||
})
|
||||
|
||||
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
|
||||
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
|
||||
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
|
||||
|
||||
const parameters = computed(() => [
|
||||
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
|
||||
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
|
||||
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
|
||||
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
|
||||
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
|
||||
...(props.formulaConfig.smith_exponent !== null ? [{
|
||||
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
|
||||
}] : []),
|
||||
...(props.formulaConfig.techcomm_exponent !== null ? [{
|
||||
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
|
||||
}] : []),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Main formula -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
|
||||
<div v-html="mainFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- Smith criterion -->
|
||||
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
|
||||
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
|
||||
<div v-html="smithFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- TechComm criterion -->
|
||||
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
|
||||
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
|
||||
<div v-html="techcommFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- Parameters grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="param in parameters"
|
||||
:key="param.code"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
|
||||
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation toggle -->
|
||||
<div class="flex justify-end">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="showExplain = !showExplain"
|
||||
>
|
||||
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,114 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visual gauge showing votes vs threshold.
|
||||
*
|
||||
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
|
||||
* a vertical threshold marker, participation statistics, and a pass/fail badge.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
votesFor: number
|
||||
votesAgainst: number
|
||||
threshold: number
|
||||
wotSize: number
|
||||
}>()
|
||||
|
||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
||||
|
||||
/** Percentage of "pour" votes relative to total votes. */
|
||||
const forPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesFor / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
/** Percentage of "contre" votes relative to total votes. */
|
||||
const againstPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesAgainst / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
/** Position of the threshold marker as a percentage of total votes. */
|
||||
const thresholdPosition = computed(() => {
|
||||
if (totalVotes.value === 0) return 50
|
||||
// Threshold as a percentage of total votes
|
||||
const pct = (props.threshold / totalVotes.value) * 100
|
||||
return Math.min(pct, 100)
|
||||
})
|
||||
|
||||
/** Whether the vote passes (votes_for >= threshold). */
|
||||
const isPassing = computed(() => props.votesFor >= props.threshold)
|
||||
|
||||
/** Participation rate. */
|
||||
const participationRate = computed(() => {
|
||||
if (props.wotSize === 0) return 0
|
||||
return (totalVotes.value / props.wotSize) * 100
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Progress bar -->
|
||||
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<!-- Green fill (pour) -->
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
|
||||
:style="{ width: `${forPct}%` }"
|
||||
/>
|
||||
<!-- Red fill (contre) -->
|
||||
<div
|
||||
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
|
||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
||||
/>
|
||||
|
||||
<!-- Threshold marker -->
|
||||
<div
|
||||
v-if="totalVotes > 0"
|
||||
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
|
||||
:style="{ left: `${thresholdPosition}%` }"
|
||||
>
|
||||
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
|
||||
Seuil
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Percentage labels inside the bar -->
|
||||
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
|
||||
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
|
||||
<span class="flex-1" />
|
||||
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Counts and threshold text -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-green-600 dark:text-green-400 font-medium">
|
||||
{{ votesFor }} pour
|
||||
</span>
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">
|
||||
{{ votesAgainst }} contre
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
|
||||
{{ votesFor }} / {{ threshold }} requis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation rate -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
|
||||
({{ participationRate.toFixed(2) }}%)
|
||||
</span>
|
||||
|
||||
<!-- Pass/fail badge -->
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'error'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopte' : 'Non adopte' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Composable for making authenticated API calls to the libreDecision backend.
|
||||
*
|
||||
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
||||
* from the auth store when available.
|
||||
*
|
||||
* Production-grade features:
|
||||
* - Error interceptor with user-friendly French messages
|
||||
* - Retry logic with exponential backoff for 5xx errors
|
||||
* - Request timeout (30s default)
|
||||
* - AbortController support for cancellation
|
||||
*/
|
||||
|
||||
/** Map of HTTP status codes to user-friendly French error messages. */
|
||||
const HTTP_ERROR_MESSAGES: Record<number, string> = {
|
||||
401: 'Session expirée, veuillez vous reconnecter',
|
||||
403: 'Accès non autorisé',
|
||||
404: 'Ressource introuvable',
|
||||
409: 'Conflit avec une ressource existante',
|
||||
422: 'Données invalides',
|
||||
429: 'Trop de requêtes, veuillez patienter',
|
||||
500: 'Erreur serveur, veuillez réessayer',
|
||||
502: 'Service temporairement indisponible',
|
||||
503: 'Service en maintenance, veuillez réessayer',
|
||||
504: 'Délai de réponse dépassé',
|
||||
}
|
||||
|
||||
/** Default request timeout in milliseconds. */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Maximum number of retry attempts for 5xx errors. */
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
/** Base delay for exponential backoff in milliseconds. */
|
||||
const BASE_BACKOFF_MS = 1_000
|
||||
|
||||
export interface ApiOptions extends Record<string, any> {
|
||||
/** Custom timeout in milliseconds (default: 30000). */
|
||||
timeout?: number
|
||||
/** External AbortController for request cancellation. */
|
||||
signal?: AbortSignal
|
||||
/** Disable automatic retry for this request. */
|
||||
noRetry?: boolean
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
status: number
|
||||
message: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-friendly error message from an HTTP status code.
|
||||
*/
|
||||
function resolveErrorMessage(status: number, fallback?: string): string {
|
||||
return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the specified duration in milliseconds.
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an HTTP status code is retryable (5xx server errors).
|
||||
*/
|
||||
function isRetryable(status: number): boolean {
|
||||
return status >= 500 && status < 600
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
const orgsStore = useOrganizationsStore()
|
||||
|
||||
/**
|
||||
* Perform a typed fetch against the backend API.
|
||||
*
|
||||
* @param path - API path relative to apiBase, e.g. "/documents"
|
||||
* @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry)
|
||||
* @returns Typed response
|
||||
* @throws ApiError with status, message, and optional detail
|
||||
*/
|
||||
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const {
|
||||
timeout = DEFAULT_TIMEOUT_MS,
|
||||
signal: externalSignal,
|
||||
noRetry = false,
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth.token) {
|
||||
headers.Authorization = `Bearer ${auth.token}`
|
||||
}
|
||||
if (orgsStore.activeSlug) {
|
||||
headers['X-Organization'] = orgsStore.activeSlug
|
||||
}
|
||||
|
||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
||||
let lastError: any = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
// Create an AbortController for timeout management
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
// If an external signal is provided, forward its abort
|
||||
let externalAbortHandler: (() => void) | null = null
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
clearTimeout(timeoutId)
|
||||
throw _createApiError(0, 'Requête annulée')
|
||||
}
|
||||
externalAbortHandler = () => controller.abort()
|
||||
externalSignal.addEventListener('abort', externalAbortHandler, { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<T>(`${config.public.apiBase}${path}`, {
|
||||
...fetchOptions,
|
||||
headers: { ...headers, ...fetchOptions.headers },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
|
||||
const status = err?.response?.status || err?.status || 0
|
||||
|
||||
// Handle 401: auto-logout for expired sessions
|
||||
if (status === 401 && auth.token) {
|
||||
auth.token = null
|
||||
auth.identity = null
|
||||
auth._clearToken()
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
// Handle abort (timeout or external cancellation)
|
||||
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
||||
if (externalSignal?.aborted) {
|
||||
throw _createApiError(0, 'Requête annulée')
|
||||
}
|
||||
throw _createApiError(0, 'Délai de réponse dépassé')
|
||||
}
|
||||
|
||||
// Retry only for 5xx errors and if we have remaining attempts
|
||||
if (isRetryable(status) && attempt < maxAttempts) {
|
||||
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1)
|
||||
await delay(backoffMs)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build and throw a structured error
|
||||
const detail = err?.data?.detail || err?.message || undefined
|
||||
const message = resolveErrorMessage(status, detail)
|
||||
throw _createApiError(status, message, detail)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
if (externalAbortHandler && externalSignal) {
|
||||
externalSignal.removeEventListener('abort', externalAbortHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: should not reach here, but handle gracefully
|
||||
const fallbackStatus = lastError?.response?.status || lastError?.status || 0
|
||||
const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined
|
||||
throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a structured ApiError object.
|
||||
*/
|
||||
function _createApiError(status: number, message: string, detail?: string): ApiError {
|
||||
const error: ApiError = { status, message }
|
||||
if (detail) error.detail = detail
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController for manual request cancellation.
|
||||
* Useful for component unmount cleanup.
|
||||
*
|
||||
* @example
|
||||
* const { controller, signal } = createAbortController()
|
||||
* await $api('/documents', { signal })
|
||||
* // On unmount:
|
||||
* controller.abort()
|
||||
*/
|
||||
function createAbortController() {
|
||||
const controller = new AbortController()
|
||||
return { controller, signal: controller.signal }
|
||||
}
|
||||
|
||||
return { $api, createAbortController }
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Composable for toast notifications using Nuxt UI.
|
||||
*
|
||||
* Provides typed notification helpers with French messages and
|
||||
* integration with WebSocket events for real-time notifications.
|
||||
*/
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface NotifyOptions {
|
||||
/** Toast title. */
|
||||
title: string
|
||||
/** Toast description (optional). */
|
||||
description?: string
|
||||
/** Notification type. */
|
||||
type?: NotificationType
|
||||
/** Auto-close duration in milliseconds (default: 5000). */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** Map notification types to Nuxt UI toast color props. */
|
||||
const TYPE_COLORS: Record<NotificationType, string> = {
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
}
|
||||
|
||||
/** Map notification types to Lucide icon names. */
|
||||
const TYPE_ICONS: Record<NotificationType, string> = {
|
||||
success: 'i-lucide-check-circle',
|
||||
error: 'i-lucide-alert-circle',
|
||||
warning: 'i-lucide-alert-triangle',
|
||||
info: 'i-lucide-info',
|
||||
}
|
||||
|
||||
/** Default duration for toasts (ms). */
|
||||
const DEFAULT_DURATION = 5_000
|
||||
|
||||
export function useNotifications() {
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* Show a toast notification.
|
||||
*
|
||||
* @param options - Notification options (title, description, type, duration)
|
||||
*/
|
||||
function notify(options: NotifyOptions): void
|
||||
function notify(title: string, description?: string, type?: NotificationType): void
|
||||
function notify(
|
||||
titleOrOptions: string | NotifyOptions,
|
||||
description?: string,
|
||||
type?: NotificationType,
|
||||
): void {
|
||||
let opts: NotifyOptions
|
||||
|
||||
if (typeof titleOrOptions === 'string') {
|
||||
opts = {
|
||||
title: titleOrOptions,
|
||||
description,
|
||||
type: type || 'info',
|
||||
}
|
||||
} else {
|
||||
opts = titleOrOptions
|
||||
}
|
||||
|
||||
const notifType = opts.type || 'info'
|
||||
|
||||
toast.add({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
icon: TYPE_ICONS[notifType],
|
||||
color: TYPE_COLORS[notifType] as any,
|
||||
duration: opts.duration ?? DEFAULT_DURATION,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success toast.
|
||||
*/
|
||||
function notifySuccess(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error toast.
|
||||
*/
|
||||
function notifyError(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'error',
|
||||
duration: 8_000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning toast.
|
||||
*/
|
||||
function notifyWarning(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info toast.
|
||||
*/
|
||||
function notifyInfo(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket event listeners that auto-show notifications.
|
||||
* Call this once in app.vue or a layout component.
|
||||
*/
|
||||
function setupWsNotifications(wsComposable: ReturnType<typeof useWebSocket>): void {
|
||||
wsComposable.onVoteSubmitted((data) => {
|
||||
notifyInfo(
|
||||
'Nouveau vote enregistre',
|
||||
data?.session_title || 'Un vote a ete soumis dans une session active.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onDecisionAdvanced((data) => {
|
||||
notifySuccess(
|
||||
'Decision avancee',
|
||||
data?.title
|
||||
? `La decision "${data.title}" est passee a l'etape suivante.`
|
||||
: 'Une decision a progresse dans son processus.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onMandateUpdated((data) => {
|
||||
notifyInfo(
|
||||
'Mandat mis a jour',
|
||||
data?.title
|
||||
? `Le mandat "${data.title}" a ete modifie.`
|
||||
: 'Un mandat a ete mis a jour.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onDocumentChanged((data) => {
|
||||
notifyInfo(
|
||||
'Document modifie',
|
||||
data?.title
|
||||
? `Le document "${data.title}" a ete modifie.`
|
||||
: 'Un document de reference a ete modifie.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onSanctuaryArchived((data) => {
|
||||
notifySuccess(
|
||||
'Document archive au sanctuaire',
|
||||
data?.title
|
||||
? `"${data.title}" a ete ancre sur IPFS.`
|
||||
: 'Un document a ete archive de maniere immuable.',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
notify,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
setupWsNotifications,
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Composable for real-time vote formula computation.
|
||||
*
|
||||
* Re-exports and wraps the threshold utility functions for reactive use
|
||||
* in Vue components. Provides convenient methods for threshold calculations,
|
||||
* inertia factor, required ratio, and adoption checks.
|
||||
*/
|
||||
import { wotThreshold, smithThreshold, techcommThreshold } from '~/utils/threshold'
|
||||
|
||||
export interface FormulaParams {
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
}
|
||||
|
||||
export function useVoteFormula() {
|
||||
/**
|
||||
* Compute the WoT threshold for a given set of parameters.
|
||||
*/
|
||||
function computeThreshold(wotSize: number, totalVotes: number, params: FormulaParams): number {
|
||||
return wotThreshold(
|
||||
wotSize,
|
||||
totalVotes,
|
||||
params.majority_pct,
|
||||
params.base_exponent,
|
||||
params.gradient_exponent,
|
||||
params.constant_base,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the inertia factor: 1 - (T/W)^G
|
||||
* Ranges from ~1 (low participation) to ~0 (full participation).
|
||||
*/
|
||||
function computeInertiaFactor(
|
||||
totalVotes: number,
|
||||
wotSize: number,
|
||||
gradientExponent: number,
|
||||
): number {
|
||||
if (wotSize <= 0 || totalVotes <= 0) return 1.0
|
||||
const participationRatio = totalVotes / wotSize
|
||||
return 1.0 - Math.pow(participationRatio, gradientExponent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the required ratio of "pour" votes at a given participation level.
|
||||
* requiredRatio = M + (1 - M) * inertiaFactor
|
||||
*/
|
||||
function computeRequiredRatio(
|
||||
totalVotes: number,
|
||||
wotSize: number,
|
||||
majorityPct: number,
|
||||
gradientExponent: number,
|
||||
): number {
|
||||
const M = majorityPct / 100
|
||||
const inertia = computeInertiaFactor(totalVotes, wotSize, gradientExponent)
|
||||
return M + (1.0 - M) * inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a vote is adopted given all criteria.
|
||||
* A vote is adopted when it passes the WoT threshold AND
|
||||
* any applicable Smith/TechComm criteria.
|
||||
*/
|
||||
function isAdopted(
|
||||
votesFor: number,
|
||||
threshold: number,
|
||||
smithVotesFor?: number,
|
||||
smithThresholdVal?: number,
|
||||
techcommVotesFor?: number,
|
||||
techcommThresholdVal?: number,
|
||||
): boolean {
|
||||
// Main WoT criterion
|
||||
if (votesFor < threshold) return false
|
||||
|
||||
// Smith criterion (if applicable)
|
||||
if (smithThresholdVal !== undefined && smithThresholdVal > 0) {
|
||||
if ((smithVotesFor ?? 0) < smithThresholdVal) return false
|
||||
}
|
||||
|
||||
// TechComm criterion (if applicable)
|
||||
if (techcommThresholdVal !== undefined && techcommThresholdVal > 0) {
|
||||
if ((techcommVotesFor ?? 0) < techcommThresholdVal) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
computeThreshold,
|
||||
computeInertiaFactor,
|
||||
computeRequiredRatio,
|
||||
isAdopted,
|
||||
smithThreshold,
|
||||
techcommThreshold,
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Composable for WebSocket connectivity to receive live updates.
|
||||
*
|
||||
* Connects to the backend WS endpoint and allows subscribing to
|
||||
* individual vote session channels for real-time tally updates.
|
||||
*
|
||||
* Production-grade features:
|
||||
* - Bearer token authentication via query param
|
||||
* - Heartbeat (ping every 25s, pong expected within 10s)
|
||||
* - Exponential backoff on reconnect (1s, 2s, 4s, 8s, max 30s)
|
||||
* - Max reconnect attempts: 10
|
||||
* - Typed event handlers for domain events
|
||||
* - Message queue during disconnection with replay on reconnect
|
||||
*/
|
||||
|
||||
/** WebSocket event types from the backend. */
|
||||
export type WsEventType =
|
||||
| 'vote_submitted'
|
||||
| 'decision_advanced'
|
||||
| 'mandate_updated'
|
||||
| 'document_changed'
|
||||
| 'sanctuary_archived'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
|
||||
/** Typed WebSocket message from the backend. */
|
||||
export interface WsMessage {
|
||||
event: WsEventType
|
||||
data: any
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
/** Event handler callback type. */
|
||||
type WsEventHandler = (data: any) => void
|
||||
|
||||
/** Maximum reconnect attempts before giving up. */
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
|
||||
/** Base delay for reconnection backoff (ms). */
|
||||
const RECONNECT_BASE_MS = 1_000
|
||||
|
||||
/** Maximum delay between reconnection attempts (ms). */
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
|
||||
/** Interval between heartbeat pings (ms). */
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000
|
||||
|
||||
/** Maximum time to wait for a pong response (ms). */
|
||||
const PONG_TIMEOUT_MS = 10_000
|
||||
|
||||
export function useWebSocket() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempts = 0
|
||||
let intentionalClose = false
|
||||
|
||||
const connected = ref(false)
|
||||
const lastMessage = ref<WsMessage | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** Message queue: messages sent while disconnected are replayed on reconnect. */
|
||||
const messageQueue: string[] = []
|
||||
|
||||
/** Typed event handlers registry. */
|
||||
const eventHandlers: Map<WsEventType, Set<WsEventHandler>> = new Map()
|
||||
|
||||
/**
|
||||
* Open a WebSocket connection to the backend live endpoint.
|
||||
*/
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset state
|
||||
error.value = null
|
||||
intentionalClose = false
|
||||
|
||||
// Build WS URL with authentication token
|
||||
let wsUrl = config.public.apiBase
|
||||
.replace(/^http/, 'ws')
|
||||
.replace(/\/api\/v1$/, '/api/v1/ws/live')
|
||||
|
||||
if (auth.token) {
|
||||
wsUrl += `?token=${encodeURIComponent(auth.token)}`
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts = 0
|
||||
error.value = null
|
||||
|
||||
// Start heartbeat
|
||||
_startHeartbeat()
|
||||
|
||||
// Replay queued messages
|
||||
_flushMessageQueue()
|
||||
}
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
connected.value = false
|
||||
_stopHeartbeat()
|
||||
|
||||
if (!intentionalClose) {
|
||||
_scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message: WsMessage = JSON.parse(event.data)
|
||||
lastMessage.value = message
|
||||
|
||||
// Handle pong for heartbeat
|
||||
if (message.event === 'pong') {
|
||||
_onPongReceived()
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch to typed event handlers
|
||||
_dispatchEvent(message)
|
||||
} catch {
|
||||
// Non-JSON message, store as-is
|
||||
lastMessage.value = { event: 'error', data: event.data }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time updates for a vote session.
|
||||
*/
|
||||
function subscribe(sessionId: string) {
|
||||
_send(JSON.stringify({ action: 'subscribe', session_id: sessionId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a vote session's updates.
|
||||
*/
|
||||
function unsubscribe(sessionId: string) {
|
||||
_send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the WebSocket connection.
|
||||
*/
|
||||
function disconnect() {
|
||||
intentionalClose = true
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
_stopHeartbeat()
|
||||
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.onmessage = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
connected.value = false
|
||||
reconnectAttempts = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// ---- Typed event handler registration ----
|
||||
|
||||
/**
|
||||
* Register a handler for when a vote is submitted.
|
||||
*/
|
||||
function onVoteSubmitted(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('vote_submitted', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a decision advances to the next step.
|
||||
*/
|
||||
function onDecisionAdvanced(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('decision_advanced', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a mandate is updated.
|
||||
*/
|
||||
function onMandateUpdated(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('mandate_updated', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a document is changed.
|
||||
*/
|
||||
function onDocumentChanged(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('document_changed', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a document is archived to the sanctuary.
|
||||
*/
|
||||
function onSanctuaryArchived(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('sanctuary_archived', handler)
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* Send a message via WebSocket, or queue it if disconnected.
|
||||
*/
|
||||
function _send(message: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(message)
|
||||
} else {
|
||||
messageQueue.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay all queued messages after reconnection.
|
||||
*/
|
||||
function _flushMessageQueue() {
|
||||
while (messageQueue.length > 0) {
|
||||
const message = messageQueue.shift()!
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(message)
|
||||
} else {
|
||||
// Put it back if connection dropped again
|
||||
messageQueue.unshift(message)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event handler and return an unsubscribe function.
|
||||
*/
|
||||
function _addEventHandler(event: WsEventType, handler: WsEventHandler): () => void {
|
||||
if (!eventHandlers.has(event)) {
|
||||
eventHandlers.set(event, new Set())
|
||||
}
|
||||
eventHandlers.get(event)!.add(handler)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventHandlers.get(event)?.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a received message to all registered handlers for its event type.
|
||||
*/
|
||||
function _dispatchEvent(message: WsMessage) {
|
||||
const handlers = eventHandlers.get(message.event)
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(message.data)
|
||||
} catch (err) {
|
||||
console.error(`[WS] Erreur dans le handler pour "${message.event}":`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat: send ping every 25s and expect pong within 10s.
|
||||
*/
|
||||
function _startHeartbeat() {
|
||||
_stopHeartbeat()
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ action: 'ping' }))
|
||||
|
||||
// Start pong timeout
|
||||
pongTimeoutTimer = setTimeout(() => {
|
||||
console.warn('[WS] Pong non recu dans le delai imparti, reconnexion...')
|
||||
// Force close and reconnect
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
}, PONG_TIMEOUT_MS)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat timers.
|
||||
*/
|
||||
function _stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
if (pongTimeoutTimer) {
|
||||
clearTimeout(pongTimeoutTimer)
|
||||
pongTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pong response: cancel the pong timeout.
|
||||
*/
|
||||
function _onPongReceived() {
|
||||
if (pongTimeoutTimer) {
|
||||
clearTimeout(pongTimeoutTimer)
|
||||
pongTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff.
|
||||
*/
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) return
|
||||
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
error.value = 'Connexion au serveur perdue. Veuillez rafraichir la page.'
|
||||
console.error(`[WS] Nombre maximum de tentatives de reconnexion atteint (${MAX_RECONNECT_ATTEMPTS})`)
|
||||
return
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts),
|
||||
RECONNECT_MAX_MS,
|
||||
)
|
||||
|
||||
reconnectAttempts++
|
||||
console.info(`[WS] Tentative de reconnexion ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} dans ${backoffMs}ms`)
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, backoffMs)
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
lastMessage,
|
||||
error,
|
||||
connect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
disconnect,
|
||||
onVoteSubmitted,
|
||||
onDecisionAdvanced,
|
||||
onMandateUpdated,
|
||||
onDocumentChanged,
|
||||
onSanctuaryArchived,
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* Auth store: manages Duniter Ed25519 challenge-response authentication.
|
||||
*
|
||||
* Persists the session token in localStorage for SPA rehydration.
|
||||
* The identity object mirrors the backend IdentityOut schema.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sign a challenge using the injected Duniter/Substrate wallet extension
|
||||
* (Cesium2, polkadot.js extension, Talisman, etc.).
|
||||
*
|
||||
* The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier.
|
||||
*/
|
||||
// TODO: trustWallet — remplacer par postMessage vers l'iframe trustWallet (librodrome)
|
||||
// Protocole prévu : window.postMessage({ type: 'LD_SIGN_REQUEST', address, challenge })
|
||||
// → trustWallet répond { type: 'LD_SIGN_RESPONSE', signature }
|
||||
async function _signWithExtension(address: string, challenge: string): Promise<string> {
|
||||
const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp')
|
||||
const { stringToHex } = await import('@polkadot/util')
|
||||
|
||||
const extensions = await web3Enable('libreDecision')
|
||||
if (!extensions.length) {
|
||||
throw new Error('Aucune extension Duniter détectée. Installez Cesium² ou Polkadot.js.')
|
||||
}
|
||||
|
||||
let injector
|
||||
try {
|
||||
injector = await web3FromAddress(address)
|
||||
} catch {
|
||||
throw new Error(`Adresse ${address.slice(0, 10)}… introuvable dans l'extension.`)
|
||||
}
|
||||
|
||||
if (!injector.signer?.signRaw) {
|
||||
throw new Error("L'extension ne supporte pas la signature de messages bruts.")
|
||||
}
|
||||
|
||||
const { signature } = await injector.signer.signRaw({
|
||||
address,
|
||||
data: stringToHex(challenge),
|
||||
type: 'bytes',
|
||||
})
|
||||
return signature
|
||||
}
|
||||
|
||||
export interface DuniterIdentity {
|
||||
id: string
|
||||
address: string
|
||||
display_name: string | null
|
||||
wot_status: string
|
||||
is_smith: boolean
|
||||
is_techcomm: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
identity: DuniterIdentity | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: null,
|
||||
identity: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => !!state.token && !!state.identity,
|
||||
isSmith: (state): boolean => state.identity?.is_smith ?? false,
|
||||
isTechComm: (state): boolean => state.identity?.is_techcomm ?? false,
|
||||
displayName: (state): string => {
|
||||
if (!state.identity) return ''
|
||||
return state.identity.display_name || state.identity.address.slice(0, 12) + '...'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Initiate the challenge-response login flow.
|
||||
*
|
||||
* Steps:
|
||||
* 1. POST /auth/challenge with the Duniter SS58 address
|
||||
* 2. Client signs the challenge with Ed25519 private key
|
||||
* 3. POST /auth/verify with address + signature + challenge
|
||||
* 4. Store the returned token and identity
|
||||
*/
|
||||
async login(address: string, signFn?: (challenge: string) => Promise<string>) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
// Step 1: Request challenge
|
||||
const challengeRes = await $api<{ challenge: string; expires_at: string }>(
|
||||
'/auth/challenge',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { address },
|
||||
},
|
||||
)
|
||||
|
||||
// Step 2: Sign the challenge via polkadot.js / Cesium2 extension
|
||||
let signature: string
|
||||
if (signFn) {
|
||||
signature = await signFn(challengeRes.challenge)
|
||||
} else {
|
||||
signature = await _signWithExtension(address, challengeRes.challenge)
|
||||
}
|
||||
|
||||
// Step 3: Verify and get token
|
||||
const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>(
|
||||
'/auth/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
address,
|
||||
signature,
|
||||
challenge: challengeRes.challenge,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Step 4: Store credentials
|
||||
this.token = verifyRes.token
|
||||
this.identity = verifyRes.identity
|
||||
this._persistToken()
|
||||
|
||||
return verifyRes
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur de connexion'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the currently authenticated identity from the backend.
|
||||
* Used on app init to validate a persisted token.
|
||||
*/
|
||||
async fetchMe() {
|
||||
if (!this.token) return
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||
this.identity = identity
|
||||
} catch (err: any) {
|
||||
const status = (err as any)?.status ?? 0
|
||||
this.error = err?.message || 'Session invalide'
|
||||
// N'effacer le token que sur 401/403 (session réellement invalide)
|
||||
// Les erreurs réseau ou 5xx sont transitoires — conserver la session
|
||||
if (status === 401 || status === 403) {
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this._clearToken()
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log out: invalidate session on server and clear local state.
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
if (this.token) {
|
||||
const { $api } = useApi()
|
||||
await $api('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during logout -- clear local state regardless
|
||||
} finally {
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this.error = null
|
||||
this._clearToken()
|
||||
navigateTo('/login')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hydrate the token from localStorage on app init.
|
||||
*/
|
||||
hydrateFromStorage() {
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem('libredecision_token')
|
||||
if (stored) {
|
||||
this.token = stored
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Persist token to localStorage */
|
||||
_persistToken() {
|
||||
if (import.meta.client && this.token) {
|
||||
localStorage.setItem('libredecision_token', this.token)
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Clear token from localStorage */
|
||||
_clearToken() {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem('libredecision_token')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Note: hydration from localStorage happens in app.vue onMounted
|
||||
// via auth.hydrateFromStorage() before calling auth.fetchMe().
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Documents store: reference documents, their items, and item versions.
|
||||
*
|
||||
* Maps to the backend /api/v1/documents endpoints.
|
||||
*/
|
||||
|
||||
export interface DocumentItem {
|
||||
id: string
|
||||
document_id: string
|
||||
position: string
|
||||
item_type: string
|
||||
title: string | null
|
||||
current_text: string
|
||||
voting_protocol_id: string | null
|
||||
sort_order: number
|
||||
section_tag: string | null
|
||||
inertia_preset: string
|
||||
is_permanent_vote: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
doc_type: string
|
||||
version: string
|
||||
status: string
|
||||
description: string | null
|
||||
ipfs_cid: string | null
|
||||
chain_anchor: string | null
|
||||
genesis_json: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items_count: number
|
||||
}
|
||||
|
||||
export interface ItemVersion {
|
||||
id: string
|
||||
item_id: string
|
||||
version_number: number
|
||||
proposed_text: string
|
||||
rationale: string | null
|
||||
diff: string | null
|
||||
status: string
|
||||
proposed_by: string | null
|
||||
reviewed_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DocumentCreate {
|
||||
slug: string
|
||||
title: string
|
||||
doc_type: string
|
||||
description?: string | null
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface VersionProposal {
|
||||
proposed_text: string
|
||||
rationale?: string | null
|
||||
}
|
||||
|
||||
interface DocumentsState {
|
||||
list: Document[]
|
||||
current: Document | null
|
||||
items: DocumentItem[]
|
||||
versions: ItemVersion[]
|
||||
allItemVersions: Record<string, ItemVersion[]>
|
||||
loadingVersions: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useDocumentsStore = defineStore('documents', {
|
||||
state: (): DocumentsState => ({
|
||||
list: [],
|
||||
current: null,
|
||||
items: [],
|
||||
versions: [],
|
||||
allItemVersions: {},
|
||||
loadingVersions: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
byType: (state) => {
|
||||
return (docType: string) => state.list.filter(d => d.doc_type === docType)
|
||||
},
|
||||
activeDocuments: (state): Document[] => {
|
||||
return state.list.filter(d => d.status === 'active')
|
||||
},
|
||||
draftDocuments: (state): Document[] => {
|
||||
return state.list.filter(d => d.status === 'draft')
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch all documents with optional filters.
|
||||
*/
|
||||
async fetchAll(params?: { doc_type?: string; status?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.doc_type) query.doc_type = params.doc_type
|
||||
if (params?.status) query.status = params.status
|
||||
|
||||
this.list = await $api<Document[]>('/documents/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des documents'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single document by slug and its items.
|
||||
*/
|
||||
async fetchBySlug(slug: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
const [doc, items] = await Promise.all([
|
||||
$api<Document>(`/documents/${slug}`),
|
||||
$api<DocumentItem[]>(`/documents/${slug}/items`),
|
||||
])
|
||||
|
||||
this.current = doc
|
||||
this.items = items
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Document introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new reference document.
|
||||
*/
|
||||
async createDocument(payload: DocumentCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const doc = await $api<Document>('/documents/', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
this.list.unshift(doc)
|
||||
return doc
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du document'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all versions for a specific item within a document.
|
||||
*/
|
||||
async fetchItemVersions(slug: string, itemId: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.versions = await $api<ItemVersion[]>(
|
||||
`/documents/${slug}/items/${itemId}/versions`,
|
||||
)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des versions'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Propose a new version for a document item.
|
||||
*/
|
||||
async proposeVersion(slug: string, itemId: string, data: VersionProposal) {
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const version = await $api<ItemVersion>(
|
||||
`/documents/${slug}/items/${itemId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
this.versions.unshift(version)
|
||||
return version
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la proposition'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Accept a proposed version.
|
||||
*/
|
||||
async acceptVersion(slug: string, itemId: string, versionId: string) {
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<ItemVersion>(
|
||||
`/documents/${slug}/items/${itemId}/versions/${versionId}/accept`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const idx = this.versions.findIndex(v => v.id === versionId)
|
||||
if (idx >= 0) this.versions[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'acceptation'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reject a proposed version.
|
||||
*/
|
||||
async rejectVersion(slug: string, itemId: string, versionId: string) {
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<ItemVersion>(
|
||||
`/documents/${slug}/items/${itemId}/versions/${versionId}/reject`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const idx = this.versions.findIndex(v => v.id === versionId)
|
||||
if (idx >= 0) this.versions[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du rejet'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all versions for every item in a document (parallel).
|
||||
* Used to compute the "projected" view (document as-if all active votes passed).
|
||||
*/
|
||||
async fetchAllItemVersions(slug: string, itemIds: string[]) {
|
||||
this.loadingVersions = true
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const results = await Promise.allSettled(
|
||||
itemIds.map(id => $api<ItemVersion[]>(`/documents/${slug}/items/${id}/versions`)),
|
||||
)
|
||||
const map: Record<string, ItemVersion[]> = {}
|
||||
itemIds.forEach((id, i) => {
|
||||
const r = results[i]
|
||||
map[id] = r?.status === 'fulfilled' ? r.value : []
|
||||
})
|
||||
this.allItemVersions = map
|
||||
} finally {
|
||||
this.loadingVersions = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a document into the Sanctuary.
|
||||
*/
|
||||
async archiveDocument(slug: string) {
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const doc = await $api<Document>(
|
||||
`/documents/${slug}/archive`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
// Update current if viewing this document
|
||||
if (this.current?.slug === slug) {
|
||||
this.current = doc
|
||||
}
|
||||
// Update in list
|
||||
const idx = this.list.findIndex(d => d.slug === slug)
|
||||
if (idx >= 0) this.list[idx] = doc
|
||||
return doc
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'archivage'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current document, items and versions.
|
||||
*/
|
||||
clearCurrent() {
|
||||
this.current = null
|
||||
this.items = []
|
||||
this.versions = []
|
||||
this.allItemVersions = {}
|
||||
this.loadingVersions = false
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
export interface GroupMember {
|
||||
id: string
|
||||
display_name: string
|
||||
identity_id: string | null
|
||||
added_at: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
organization_id: string | null
|
||||
created_at: string
|
||||
members: GroupMember[]
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
organization_id: string | null
|
||||
member_count: number
|
||||
}
|
||||
|
||||
export interface GroupCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface GroupMemberCreate {
|
||||
display_name: string
|
||||
identity_id?: string | null
|
||||
}
|
||||
|
||||
export const useGroupsStore = defineStore('groups', () => {
|
||||
const { $api } = useApi()
|
||||
|
||||
const list = ref<GroupSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
list.value = await $api<GroupSummary[]>('/groups/')
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Erreur chargement groupes'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getGroup(id: string): Promise<Group | null> {
|
||||
try {
|
||||
return await $api<Group>(`/groups/${id}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload: GroupCreate): Promise<Group | null> {
|
||||
try {
|
||||
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
|
||||
await fetchAll()
|
||||
return group
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Erreur création groupe'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await $api(`/groups/${id}`, { method: 'DELETE' })
|
||||
list.value = list.value.filter(g => g.id !== id)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
|
||||
try {
|
||||
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
const g = list.value.find(g => g.id === groupId)
|
||||
if (g) g.member_count++
|
||||
return member
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
|
||||
try {
|
||||
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
|
||||
const g = list.value.find(g => g.id === groupId)
|
||||
if (g) g.member_count--
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
export interface MandateStep {
|
||||
id: string
|
||||
mandate_id: string
|
||||
step_order: number
|
||||
step_type: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
status: string
|
||||
vote_session_id: string | null
|
||||
outcome: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Mandate {
|
||||
id: string
|
||||
title: string
|
||||
origin_id: string | null
|
||||
origin_display_name: string | null
|
||||
description: string | null
|
||||
mandate_type: string
|
||||
status: string
|
||||
mandatee_id: string | null
|
||||
mandatee_display_name: string | null
|
||||
decision_id: string | null
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
steps: MandateStep[]
|
||||
}
|
||||
|
||||
export interface MandateCreate {
|
||||
title: string
|
||||
origin_id?: string | null
|
||||
description?: string | null
|
||||
mandate_type: string
|
||||
nomination_mode?: string
|
||||
decision_id?: string | null
|
||||
starts_at?: string | null
|
||||
ends_at?: string | null
|
||||
}
|
||||
|
||||
export interface MandateUpdate {
|
||||
title?: string
|
||||
origin_id?: string | null
|
||||
description?: string | null
|
||||
mandate_type?: string
|
||||
starts_at?: string | null
|
||||
ends_at?: string | null
|
||||
}
|
||||
|
||||
export interface MandateStepCreate {
|
||||
step_order: number
|
||||
step_type: string
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface MandatesState {
|
||||
list: Mandate[]
|
||||
current: Mandate | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useMandatesStore = defineStore('mandates', {
|
||||
state: (): MandatesState => ({
|
||||
list: [],
|
||||
current: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
byStatus: (state) => (status: string) => state.list.filter(m => m.status === status),
|
||||
activeMandates: (state): Mandate[] => state.list.filter(m => m.status === 'active'),
|
||||
completedMandates: (state): Mandate[] => state.list.filter(m => m.status === 'completed'),
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchAll(params?: { mandate_type?: string; status?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.mandate_type) query.mandate_type = params.mandate_type
|
||||
if (params?.status) query.status = params.status
|
||||
this.list = await $api<Mandate[]>('/mandates/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchById(id: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.current = await $api<Mandate>(`/mandates/${id}`)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Mandat introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async create(payload: MandateCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const mandate = await $api<Mandate>('/mandates/', { method: 'POST', body: payload })
|
||||
this.list.unshift(mandate)
|
||||
return mandate
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du mandat'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async update(id: string, data: MandateUpdate) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<Mandate>(`/mandates/${id}`, { method: 'PUT', body: data })
|
||||
if (this.current?.id === id) this.current = updated
|
||||
const idx = this.list.findIndex(m => m.id === id)
|
||||
if (idx >= 0) this.list[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la mise a jour du mandat'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
await $api(`/mandates/${id}`, { method: 'DELETE' })
|
||||
this.list = this.list.filter(m => m.id !== id)
|
||||
if (this.current?.id === id) this.current = null
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la suppression du mandat'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async advance(id: string) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<Mandate>(`/mandates/${id}/advance`, { method: 'POST' })
|
||||
if (this.current?.id === id) this.current = updated
|
||||
const idx = this.list.findIndex(m => m.id === id)
|
||||
if (idx >= 0) this.list[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'avancement du mandat'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async addStep(id: string, step: MandateStepCreate) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { method: 'POST', body: step })
|
||||
if (this.current?.id === id) this.current.steps.push(newStep)
|
||||
return newStep
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async assignMandatee(id: string, mandateeId: string) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
|
||||
method: 'POST',
|
||||
body: { mandatee_id: mandateeId },
|
||||
})
|
||||
if (this.current?.id === id) this.current = updated
|
||||
const idx = this.list.findIndex(m => m.id === id)
|
||||
if (idx >= 0) this.list[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'assignation du mandataire'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async revoke(id: string) {
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { method: 'POST' })
|
||||
if (this.current?.id === id) this.current = updated
|
||||
const idx = this.list.findIndex(m => m.id === id)
|
||||
if (idx >= 0) this.list[idx] = updated
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la revocation du mandat'
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrent() {
|
||||
this.current = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
org_type: string
|
||||
is_transparent: boolean
|
||||
color: string | null
|
||||
icon: string | null
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface OrgState {
|
||||
organizations: Organization[]
|
||||
activeSlug: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useOrganizationsStore = defineStore('organizations', {
|
||||
state: (): OrgState => ({
|
||||
organizations: [],
|
||||
activeSlug: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
active: (state): Organization | null =>
|
||||
state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null,
|
||||
|
||||
hasOrganizations: (state): boolean => state.organizations.length > 0,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchOrganizations() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const orgs = await $api<Organization[]>('/organizations/')
|
||||
// Duniter G1 first, then alphabetical
|
||||
this.organizations = orgs.sort((a, b) => {
|
||||
if (a.slug === 'duniter-g1') return -1
|
||||
if (b.slug === 'duniter-g1') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
// Restore persisted active slug, or default to first org
|
||||
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
|
||||
if (stored && this.organizations.some(o => o.slug === stored)) {
|
||||
this.activeSlug = stored
|
||||
} else if (this.organizations[0]) {
|
||||
this.activeSlug = this.organizations[0].slug
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.error = err?.message || 'Erreur lors du chargement des organisations'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
setActive(slug: string) {
|
||||
if (this.organizations.some(o => o.slug === slug)) {
|
||||
this.activeSlug = slug
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('libredecision_org', slug)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* Protocols store: voting protocols and formula configurations.
|
||||
*
|
||||
* Maps to the backend /api/v1/protocols endpoints.
|
||||
*/
|
||||
|
||||
export interface FormulaConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent: number | null
|
||||
techcomm_exponent: number | null
|
||||
nuanced_min_participants: number | null
|
||||
nuanced_threshold_pct: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VotingProtocol {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
vote_type: string
|
||||
formula_config_id: string
|
||||
mode_params: string | null
|
||||
is_meta_governed: boolean
|
||||
created_at: string
|
||||
formula_config: FormulaConfig
|
||||
}
|
||||
|
||||
export interface ProtocolCreate {
|
||||
name: string
|
||||
description: string | null
|
||||
vote_type: string
|
||||
formula_config_id: string
|
||||
}
|
||||
|
||||
export interface FormulaCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent?: number | null
|
||||
techcomm_exponent?: number | null
|
||||
nuanced_min_participants?: number | null
|
||||
nuanced_threshold_pct?: number | null
|
||||
}
|
||||
|
||||
export interface SimulateParams {
|
||||
wot_size: number
|
||||
total_votes: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_wot_size?: number
|
||||
smith_exponent?: number
|
||||
techcomm_size?: number
|
||||
techcomm_exponent?: number
|
||||
}
|
||||
|
||||
export interface SimulateResult {
|
||||
threshold: number
|
||||
smith_threshold: number | null
|
||||
techcomm_threshold: number | null
|
||||
inertia_factor: number
|
||||
required_ratio: number
|
||||
}
|
||||
|
||||
interface ProtocolsState {
|
||||
protocols: VotingProtocol[]
|
||||
formulas: FormulaConfig[]
|
||||
currentProtocol: VotingProtocol | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useProtocolsStore = defineStore('protocols', {
|
||||
state: (): ProtocolsState => ({
|
||||
protocols: [],
|
||||
formulas: [],
|
||||
currentProtocol: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
binaryProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.vote_type === 'binary')
|
||||
},
|
||||
nuancedProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.vote_type === 'nuanced')
|
||||
},
|
||||
metaGovernedProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.is_meta_governed)
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch all voting protocols with their formula configurations.
|
||||
*/
|
||||
async fetchProtocols(params?: { vote_type?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.vote_type) query.vote_type = params.vote_type
|
||||
|
||||
this.protocols = await $api<VotingProtocol[]>('/protocols/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des protocoles'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all formula configurations.
|
||||
*/
|
||||
async fetchFormulas() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.formulas = await $api<FormulaConfig[]>('/protocols/formulas')
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des formules'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single protocol by ID.
|
||||
*/
|
||||
async fetchProtocolById(id: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.currentProtocol = await $api<VotingProtocol>(`/protocols/${id}`)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Protocole introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new voting protocol.
|
||||
*/
|
||||
async createProtocol(data: ProtocolCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const protocol = await $api<VotingProtocol>('/protocols/', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
this.protocols.push(protocol)
|
||||
return protocol
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du protocole'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new formula configuration.
|
||||
*/
|
||||
async createFormula(data: FormulaCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const formula = await $api<FormulaConfig>('/protocols/formulas', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
this.formulas.push(formula)
|
||||
return formula
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la formule'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Simulate formula computation on the backend.
|
||||
*/
|
||||
async simulate(params: SimulateParams) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
return await $api<SimulateResult>('/protocols/simulate', {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
})
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la simulation'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Votes store: vote sessions, individual votes, and result computation.
|
||||
*
|
||||
* Maps to the backend /api/v1/votes endpoints.
|
||||
*/
|
||||
|
||||
export interface Vote {
|
||||
id: string
|
||||
session_id: string
|
||||
voter_id: string
|
||||
vote_value: string
|
||||
nuanced_level: number | null
|
||||
comment: string | null
|
||||
signature: string
|
||||
signed_payload: string
|
||||
voter_wot_status: string
|
||||
voter_is_smith: boolean
|
||||
voter_is_techcomm: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VoteSession {
|
||||
id: string
|
||||
decision_id: string | null
|
||||
item_version_id: string | null
|
||||
voting_protocol_id: string
|
||||
wot_size: number
|
||||
smith_size: number
|
||||
techcomm_size: number
|
||||
starts_at: string
|
||||
ends_at: string
|
||||
status: string
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_total: number
|
||||
smith_votes_for: number
|
||||
techcomm_votes_for: number
|
||||
threshold_required: number
|
||||
result: string | null
|
||||
chain_recorded: boolean
|
||||
chain_tx_hash: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VoteResult {
|
||||
session_id: string
|
||||
status: string
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_total: number
|
||||
wot_size: number
|
||||
smith_size: number
|
||||
techcomm_size: number
|
||||
smith_votes_for: number
|
||||
techcomm_votes_for: number
|
||||
threshold_required: number
|
||||
result: string
|
||||
smith_threshold: number | null
|
||||
smith_pass: boolean
|
||||
techcomm_threshold: number | null
|
||||
techcomm_pass: boolean
|
||||
}
|
||||
|
||||
export interface ThresholdDetails {
|
||||
wot_threshold: number
|
||||
smith_threshold: number | null
|
||||
techcomm_threshold: number | null
|
||||
wot_pass: boolean
|
||||
smith_pass: boolean | null
|
||||
techcomm_pass: boolean | null
|
||||
inertia_factor: number
|
||||
required_ratio: number
|
||||
}
|
||||
|
||||
export interface VoteCreate {
|
||||
session_id: string
|
||||
vote_value: string
|
||||
nuanced_level?: number | null
|
||||
comment?: string | null
|
||||
signature: string
|
||||
signed_payload: string
|
||||
}
|
||||
|
||||
export interface VoteSessionCreate {
|
||||
decision_id?: string | null
|
||||
item_version_id?: string | null
|
||||
voting_protocol_id: string
|
||||
wot_size?: number
|
||||
smith_size?: number
|
||||
techcomm_size?: number
|
||||
}
|
||||
|
||||
export interface SessionFilters {
|
||||
status?: string
|
||||
voting_protocol_id?: string
|
||||
decision_id?: string
|
||||
}
|
||||
|
||||
interface VotesState {
|
||||
currentSession: VoteSession | null
|
||||
votes: Vote[]
|
||||
result: VoteResult | null
|
||||
thresholdDetails: ThresholdDetails | null
|
||||
sessions: VoteSession[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useVotesStore = defineStore('votes', {
|
||||
state: (): VotesState => ({
|
||||
currentSession: null,
|
||||
votes: [],
|
||||
result: null,
|
||||
thresholdDetails: null,
|
||||
sessions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isSessionOpen: (state): boolean => {
|
||||
if (!state.currentSession) return false
|
||||
return state.currentSession.status === 'open' && new Date(state.currentSession.ends_at) > new Date()
|
||||
},
|
||||
participationRate: (state): number => {
|
||||
if (!state.currentSession || state.currentSession.wot_size === 0) return 0
|
||||
return (state.currentSession.votes_total / state.currentSession.wot_size) * 100
|
||||
},
|
||||
forPercentage: (state): number => {
|
||||
if (!state.currentSession || state.currentSession.votes_total === 0) return 0
|
||||
return (state.currentSession.votes_for / state.currentSession.votes_total) * 100
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch a vote session by ID with its votes and result.
|
||||
*/
|
||||
async fetchSession(sessionId: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
const [session, votes, result] = await Promise.all([
|
||||
$api<VoteSession>(`/votes/sessions/${sessionId}`),
|
||||
$api<Vote[]>(`/votes/sessions/${sessionId}/votes`),
|
||||
$api<VoteResult>(`/votes/sessions/${sessionId}/result`),
|
||||
])
|
||||
|
||||
this.currentSession = session
|
||||
this.votes = votes
|
||||
this.result = result
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Session de vote introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit a vote to the current session.
|
||||
*/
|
||||
async submitVote(payload: VoteCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const vote = await $api<Vote>(`/votes/sessions/${payload.session_id}/vote`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
|
||||
// Update local state
|
||||
this.votes.push(vote)
|
||||
|
||||
// Refresh session tallies and result
|
||||
if (this.currentSession) {
|
||||
const [session, result] = await Promise.all([
|
||||
$api<VoteSession>(`/votes/sessions/${payload.session_id}`),
|
||||
$api<VoteResult>(`/votes/sessions/${payload.session_id}/result`),
|
||||
])
|
||||
this.currentSession = session
|
||||
this.result = result
|
||||
}
|
||||
|
||||
return vote
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du vote'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch threshold details for a session.
|
||||
*/
|
||||
async fetchThresholdDetails(sessionId: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.thresholdDetails = await $api<ThresholdDetails>(
|
||||
`/votes/sessions/${sessionId}/threshold`,
|
||||
)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des details du seuil'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close a vote session.
|
||||
*/
|
||||
async closeSession(sessionId: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const session = await $api<VoteSession>(`/votes/sessions/${sessionId}/close`, {
|
||||
method: 'POST',
|
||||
})
|
||||
this.currentSession = session
|
||||
|
||||
// Refresh result after closing
|
||||
this.result = await $api<VoteResult>(`/votes/sessions/${sessionId}/result`)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la fermeture de la session'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a list of vote sessions with optional filters.
|
||||
*/
|
||||
async fetchSessions(filters?: SessionFilters) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (filters?.status) query.status = filters.status
|
||||
if (filters?.voting_protocol_id) query.voting_protocol_id = filters.voting_protocol_id
|
||||
if (filters?.decision_id) query.decision_id = filters.decision_id
|
||||
|
||||
this.sessions = await $api<VoteSession[]>('/votes/sessions', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des sessions'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new vote session.
|
||||
*/
|
||||
async createSession(data: VoteSessionCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const session = await $api<VoteSession>('/votes/sessions', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
this.sessions.push(session)
|
||||
return session
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current session state.
|
||||
*/
|
||||
clearSession() {
|
||||
this.currentSession = null
|
||||
this.votes = []
|
||||
this.result = null
|
||||
this.thresholdDetails = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python mode_params parser.
|
||||
*
|
||||
* A mode-params string encodes voting formula parameters in a compact format.
|
||||
* Example: "D30M50B.1G.2T.1"
|
||||
*
|
||||
* Supported codes:
|
||||
* D = duration_days (int)
|
||||
* M = majority_pct (int, 0-100)
|
||||
* B = base_exponent (float)
|
||||
* G = gradient_exponent (float)
|
||||
* C = constant_base (float)
|
||||
* S = smith_exponent (float)
|
||||
* T = techcomm_exponent (float)
|
||||
* N = ratio_multiplier (float)
|
||||
* R = is_ratio_mode (bool, 0 or 1)
|
||||
*
|
||||
* Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1.
|
||||
*/
|
||||
|
||||
export interface ModeParams {
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent: number | null
|
||||
techcomm_exponent: number | null
|
||||
ratio_multiplier: number | null
|
||||
is_ratio_mode: boolean
|
||||
}
|
||||
|
||||
type CodeType = 'int' | 'float' | 'bool'
|
||||
|
||||
const CODES: Record<string, { key: keyof ModeParams; type: CodeType }> = {
|
||||
D: { key: 'duration_days', type: 'int' },
|
||||
M: { key: 'majority_pct', type: 'int' },
|
||||
B: { key: 'base_exponent', type: 'float' },
|
||||
G: { key: 'gradient_exponent', type: 'float' },
|
||||
C: { key: 'constant_base', type: 'float' },
|
||||
S: { key: 'smith_exponent', type: 'float' },
|
||||
T: { key: 'techcomm_exponent', type: 'float' },
|
||||
N: { key: 'ratio_multiplier', type: 'float' },
|
||||
R: { key: 'is_ratio_mode', type: 'bool' },
|
||||
}
|
||||
|
||||
const PARAM_RE = /([A-Z])(\d*\.?\d+)/g
|
||||
|
||||
function getDefaults(): ModeParams {
|
||||
return {
|
||||
duration_days: 30,
|
||||
majority_pct: 50,
|
||||
base_exponent: 0.1,
|
||||
gradient_exponent: 0.2,
|
||||
constant_base: 0.0,
|
||||
smith_exponent: null,
|
||||
techcomm_exponent: null,
|
||||
ratio_multiplier: null,
|
||||
is_ratio_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a mode-params string into a structured ModeParams object.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1"
|
||||
* @returns Parsed parameters with defaults for codes not found
|
||||
* @throws Error if an unrecognised code letter is found
|
||||
*/
|
||||
export function parseModeParams(paramsStr: string): ModeParams {
|
||||
const result = getDefaults()
|
||||
|
||||
if (!paramsStr || !paramsStr.trim()) {
|
||||
return result
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
PARAM_RE.lastIndex = 0
|
||||
|
||||
while ((match = PARAM_RE.exec(paramsStr)) !== null) {
|
||||
const code = match[1]
|
||||
const rawValue = match[2]
|
||||
|
||||
if (!(code in CODES)) {
|
||||
throw new Error(`Code de parametre inconnu : '${code}'`)
|
||||
}
|
||||
|
||||
const { key, type } = CODES[code]
|
||||
|
||||
if (type === 'int') {
|
||||
;(result as any)[key] = Math.floor(parseFloat(rawValue))
|
||||
} else if (type === 'float') {
|
||||
;(result as any)[key] = parseFloat(rawValue)
|
||||
} else if (type === 'bool') {
|
||||
;(result as any)[key] = parseFloat(rawValue) !== 0
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a ModeParams object into a compact mode-params string.
|
||||
*
|
||||
* Only includes parameters that differ from defaults.
|
||||
*
|
||||
* @param params - Parameters to encode
|
||||
* @returns Compact string, e.g. "D30M50B.1G.2"
|
||||
*/
|
||||
export function encodeModeParams(params: Partial<ModeParams>): string {
|
||||
const defaults = getDefaults()
|
||||
const parts: string[] = []
|
||||
|
||||
const codeEntries = Object.entries(CODES) as [string, { key: keyof ModeParams; type: CodeType }][]
|
||||
|
||||
for (const [code, { key, type }] of codeEntries) {
|
||||
const value = params[key]
|
||||
if (value === undefined || value === null) continue
|
||||
if (value === defaults[key]) continue
|
||||
|
||||
if (type === 'int') {
|
||||
parts.push(`${code}${value}`)
|
||||
} else if (type === 'float') {
|
||||
const numVal = value as number
|
||||
if (numVal < 1 && numVal > 0) {
|
||||
parts.push(`${code}${numVal.toString().replace(/^0/, '')}`)
|
||||
} else {
|
||||
parts.push(`${code}${numVal}`)
|
||||
}
|
||||
} else if (type === 'bool') {
|
||||
parts.push(`${code}${value ? 1 : 0}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mode-params string for human display.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string
|
||||
* @returns Human-readable description in French
|
||||
*/
|
||||
export function formatModeParams(paramsStr: string): string {
|
||||
const params = parseModeParams(paramsStr)
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`Duree: ${params.duration_days} jours`)
|
||||
parts.push(`Majorite: ${params.majority_pct}%`)
|
||||
parts.push(`Base: ${params.base_exponent}`)
|
||||
parts.push(`Gradient: ${params.gradient_exponent}`)
|
||||
|
||||
if (params.constant_base > 0) {
|
||||
parts.push(`Constante: ${params.constant_base}`)
|
||||
}
|
||||
if (params.smith_exponent !== null) {
|
||||
parts.push(`Smith: ${params.smith_exponent}`)
|
||||
}
|
||||
if (params.techcomm_exponent !== null) {
|
||||
parts.push(`TechComm: ${params.techcomm_exponent}`)
|
||||
}
|
||||
|
||||
return parts.join(' | ')
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python WoT threshold formula.
|
||||
*
|
||||
* Core formula:
|
||||
* Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
|
||||
*
|
||||
* Where:
|
||||
* C = constant_base
|
||||
* B = base_exponent
|
||||
* W = wot_size (corpus of eligible voters)
|
||||
* T = total_votes (for + against)
|
||||
* M = majority_ratio (majority_pct / 100)
|
||||
* G = gradient_exponent
|
||||
*
|
||||
* Inertia behaviour:
|
||||
* - Low participation (T << W) -> near-unanimity required
|
||||
* - High participation (T -> W) -> simple majority M suffices
|
||||
*
|
||||
* Reference test case:
|
||||
* wot_size=7224, votes_for=97, votes_against=23 (total=120)
|
||||
* params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
|
||||
*/
|
||||
export function wotThreshold(
|
||||
wotSize: number,
|
||||
totalVotes: number,
|
||||
majorityPct: number = 50,
|
||||
baseExponent: number = 0.1,
|
||||
gradientExponent: number = 0.2,
|
||||
constantBase: number = 0.0,
|
||||
): number {
|
||||
if (wotSize <= 0) {
|
||||
throw new Error('wotSize doit etre strictement positif')
|
||||
}
|
||||
if (totalVotes < 0) {
|
||||
throw new Error('totalVotes ne peut pas etre negatif')
|
||||
}
|
||||
if (majorityPct < 0 || majorityPct > 100) {
|
||||
throw new Error('majorityPct doit etre entre 0 et 100')
|
||||
}
|
||||
|
||||
const M = majorityPct / 100
|
||||
const T = totalVotes
|
||||
const W = wotSize
|
||||
const C = constantBase
|
||||
const B = baseExponent
|
||||
const G = gradientExponent
|
||||
|
||||
// Guard: if no votes, threshold is at least ceil(C + B^W)
|
||||
if (T === 0) {
|
||||
return Math.ceil(C + Math.pow(B, W))
|
||||
}
|
||||
|
||||
// Core formula
|
||||
const participationRatio = T / W
|
||||
const inertiaFactor = 1.0 - Math.pow(participationRatio, G)
|
||||
const requiredRatio = M + (1.0 - M) * inertiaFactor
|
||||
const result = C + Math.pow(B, W) + requiredRatio * Math.max(0, T - C)
|
||||
|
||||
return Math.ceil(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Smith criterion threshold.
|
||||
*
|
||||
* @param smithWotSize - Number of Smith members
|
||||
* @param smithExponent - Exponent S for the Smith criterion
|
||||
* @returns Minimum number of Smith votes required
|
||||
*/
|
||||
export function smithThreshold(smithWotSize: number, smithExponent: number): number {
|
||||
if (smithWotSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(smithWotSize, smithExponent))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the TechComm criterion threshold.
|
||||
*
|
||||
* @param techcommSize - Number of TechComm members
|
||||
* @param techcommExponent - Exponent T for the TechComm criterion
|
||||
* @returns Minimum number of TechComm votes required
|
||||
*/
|
||||
export function techcommThreshold(techcommSize: number, techcommExponent: number): number {
|
||||
if (techcommSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(techcommSize, techcommExponent))
|
||||
}
|
||||
+2
-11
@@ -17,13 +17,13 @@ export default defineNuxtConfig({
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
meta: [
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=5' },
|
||||
{ name: 'description', content: 'Plateforme de decisions collectives pour la communaute Duniter/G1' },
|
||||
{ name: 'description', content: 'Boîte à outils de la première démocratie — décider ensemble, du trio à la population' },
|
||||
{ name: 'theme-color', content: '#16a34a' },
|
||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||
{ property: 'og:title', content: 'libreDecision' },
|
||||
{ property: 'og:description', content: 'Decisions collectives pour la communaute Duniter/G1' },
|
||||
{ property: 'og:description', content: 'Décider ensemble, du trio à la population' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
title: 'libreDecision',
|
||||
@@ -46,13 +46,4 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
},
|
||||
vite: {
|
||||
define: {
|
||||
// Polkadot packages expect a Node-like global
|
||||
global: 'globalThis',
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@polkadot/extension-dapp', '@polkadot/util'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user