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
|
# libreDecision
|
||||||
|
|
||||||
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||||
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
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
|
## Protocole de début de session
|
||||||
|
|
||||||
1. `git pull --rebase origin main`
|
1. `git pull --rebase origin main`
|
||||||
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
package manager : npm ; port **3002** strict
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||||
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||||
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
- **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
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
app/
|
app/
|
||||||
components/ # composants Vue
|
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||||
layouts/ # layouts Nuxt
|
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||||
pages/ # routing file-based (9 pages sprint 1)
|
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||||
composables/ # (1 composable sprint 1)
|
data/
|
||||||
stores/ # 5 Pinia stores (auth, ...)
|
persistence.ts # IndexedDB local-first
|
||||||
assets/css/
|
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||||
moods.css # système de palettes (.mood-* sur <html>)
|
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||||
utils/ # (2 utils sprint 1)
|
stores/
|
||||||
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||||
backend/
|
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||||
app/
|
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||||
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||||
services/ # 6 services
|
# onboarding/ common/ (primitives Ld*)
|
||||||
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||||
models/ # 14 tables SQLAlchemy
|
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||||
alembic/versions/ # migrations
|
# creer (layout bare) · donnees
|
||||||
tests/ # 186 tests (63 intégration TDD sprint 1)
|
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||||
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
types/domain.ts # le noyau du modèle
|
||||||
docker/
|
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||||
docker-compose.yml # postgres + backend + frontend + ipfs
|
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||||
backend.Dockerfile
|
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||||
frontend.Dockerfile
|
docker/ # compose + Dockerfiles
|
||||||
docs/content/ # 7 docs dev + 8 docs user
|
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||||
public/
|
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||||
hexagram-tsing.svg # sceau 井 (embossed)
|
|
||||||
hexagram-tsing-flat.svg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Données runtime
|
## Données runtime
|
||||||
|
|
||||||
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||||
- **ipfs-data** : volume Docker IPFS kubo
|
- `.env` racine : secrets backend — jamais commité
|
||||||
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Frontend (tout le dev v2)
|
||||||
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
|
|
||||||
cd frontend && npm run dev # :3002
|
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
|
||||||
docker compose -f docker/docker-compose.yml up
|
docker compose -f docker/docker-compose.yml up
|
||||||
@@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up
|
|||||||
## Conventions / pièges
|
## Conventions / pièges
|
||||||
|
|
||||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||||
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||||
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||||
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||||
- **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)
|
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||||
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
des identifiants de code, jamais des textes affichés
|
||||||
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
- **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)
|
- **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
|
# 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
|
## Environnement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (Python 3.11+)
|
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||||
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+)
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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
|
## Conventions
|
||||||
|
|
||||||
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||||
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||||
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||||
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||||
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
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 :
|
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||||
- Contenu principal (slot `#default`)
|
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||||
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
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 :
|
## Ajouter une modalité de vote
|
||||||
- `ToolboxSection` : accordéon collapsible générique
|
|
||||||
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||||
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||||
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
`engine/index.ts`.
|
||||||
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```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
|
# libreDecision
|
||||||
|
|
||||||
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||||
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
|
||||||
|
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
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||||
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
npm ; port **3002**
|
||||||
- **Auth** : Duniter V2 Ed25519 challenge-response
|
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||||
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
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
|
## Démarrage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
cd frontend
|
||||||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
npm install
|
||||||
|
npm run dev # http://localhost:3002
|
||||||
# Frontend
|
npm test # vitest — 342 tests
|
||||||
cd frontend && npm run dev
|
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
|
frontend/app/
|
||||||
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||||
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
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' },
|
htmlAttrs: { lang: 'fr' },
|
||||||
meta: [
|
meta: [
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=5' },
|
{ 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: 'theme-color', content: '#16a34a' },
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ property: 'og:title', content: 'libreDecision' },
|
{ 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' },
|
{ property: 'og:type', content: 'website' },
|
||||||
],
|
],
|
||||||
title: 'libreDecision',
|
title: 'libreDecision',
|
||||||
@@ -46,13 +46,4 @@ export default defineNuxtConfig({
|
|||||||
nitro: {
|
nitro: {
|
||||||
compressPublicAssets: true,
|
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