Compare commits

7 Commits
main ... main

Author SHA1 Message Date
syoul
73c5bf148c ci: remplace Traefik par Fabio/Consul (pattern sonic)
- docker-compose.yml : labels SERVICE_* Registrator, réseau sonic external,
  container_name explicite, name COMPOSE_PROJECT_NAME
- pipeline : APP_DOMAIN (au lieu de DOMAIN), ACME sonic-acme-1 pour TLS,
  test-deploy sans suffixe -1 (container_name fixe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:25:06 +01:00
syoul
6509137892 fix: sync package-lock.json avec package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:02:47 +01:00
syoul
488114791c ci: build local sans registry, pattern sejeteralo
- Suppression write-docker-creds et secrets docker_registry/username/password
- build-backend/frontend : docker build local sur sonic (docker.sock)
- sbom-generate : scan des images locales via docker.sock
- docker-compose.yml : ajout image: libredecision-{backend,frontend}:latest
- deploy : suppression docker compose pull (images locales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:59:42 +01:00
syoul
3e702fdbf3 ci: remplace plugin-docker-buildx par docker:27-cli + socket
Evite le mode privileged (non supporté par YunoHost Woodpecker).
Pattern: write-docker-creds (from_secret) → docker-backend/frontend (volumes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:41:32 +01:00
syoul
e24c2a65a0 launch pipeline 2026-03-23 14:32:04 +01:00
syoul
53fc9927ef ci: refonte pipeline selon bonnes pratiques sonic
- Format when/steps migré vers liste Woodpecker next
- Séparation from_secret / volumes (bug Woodpecker)
- Ajout security-check, SBOM (syft+trivy+dtrack), write-env,
  test-env, test-deploy, healthcheck, notify-failure
- Deploy SSH → volumes Docker (docker.sock + /opt/libredecision)
- privileged: true sur les steps docker-buildx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:25:24 +01:00
syoul
a9599ba32a ci: refonte pipeline selon bonnes pratiques sonic 2026-03-23 14:24:16 +01:00
15 changed files with 316 additions and 1164 deletions

View File

@@ -1,8 +1,8 @@
# PostgreSQL # PostgreSQL
POSTGRES_DB=libredecision POSTGRES_DB=glibredecision
POSTGRES_USER=libredecision POSTGRES_USER=glibredecision
POSTGRES_PASSWORD=change-me-in-production POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
# Backend # Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key SECRET_KEY=change-me-in-production-with-a-real-secret-key
@@ -46,4 +46,4 @@ IPFS_TIMEOUT_SECONDS=30
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1 NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
# Docker / Production # Docker / Production
DOMAIN=decision.librodrome.org DOMAIN=glibredecision.org

View File

@@ -1,75 +1,225 @@
when: when:
branch: main - branch: main
event: push event: push
steps: steps:
test-backend:
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
- name: test-backend
image: python:3.11-slim image: python:3.11-slim
commands: commands:
- cd backend - cd backend
- pip install --no-cache-dir -r requirements.txt - pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short - pytest app/tests/ -v --tb=short
test-frontend: - name: test-frontend
image: node:20-slim image: node:20-slim
commands: commands:
- cd frontend - cd frontend
- npm ci - npm ci
- npm run build - npm run build
docker-backend: # NOTE: volumes + pas de from_secret : compatible
image: woodpeckerci/plugin-docker-buildx - name: build-backend
image: docker:27-cli
depends_on: depends_on:
- test-backend - test-backend
settings: volumes:
repo: ${CI_FORGE_URL}/${CI_REPO} - /var/run/docker.sock:/var/run/docker.sock
dockerfile: docker/backend.Dockerfile commands:
context: . - docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production .
tag: - echo "Image backend construite"
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
docker-frontend: # NOTE: volumes + pas de from_secret : compatible
image: woodpeckerci/plugin-docker-buildx - name: build-frontend
image: docker:27-cli
depends_on: depends_on:
- test-frontend - test-frontend
settings: volumes:
repo: ${CI_FORGE_URL}/${CI_REPO} - /var/run/docker.sock:/var/run/docker.sock
dockerfile: docker/frontend.Dockerfile commands:
context: . - docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production .
tag: - echo "Image frontend construite"
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
deploy: # NOTE: volumes + pas de from_secret : compatible
image: appleboy/drone-ssh - name: sbom-generate
image: alpine:3.20
depends_on: depends_on:
- docker-backend - build-backend
- docker-frontend - build-frontend
settings: volumes:
host: - /var/run/docker.sock:/var/run/docker.sock
from_secret: deploy_host commands:
username: - apk add --no-cache curl
from_secret: deploy_username - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
key: - mkdir -p .reports
from_secret: deploy_key - syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
port: 22 - syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
script: - echo "SBOM generes"
- cd /opt/libredecision
- docker compose -f docker/docker-compose.yml pull # NOTE: volumes + pas de from_secret : compatible
- docker compose -f docker/docker-compose.yml up -d --remove-orphans - name: sbom-scan
- docker image prune -f image: aquasec/trivy:latest
depends_on:
- sbom-generate
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
- name: sbom-publish
image: alpine/curl:latest
depends_on:
- sbom-scan
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
for COMPONENT in backend frontend; do
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=libredecision-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
depends_on:
- sbom-publish
environment:
APP_DOMAIN:
from_secret: app_domain
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy
- echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
- echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
depends_on:
- write-env
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Fabio/Traefik routing géré via labels du docker-compose.yml
- name: deploy
image: docker:27-cli
depends_on:
- test-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
- cd /opt/libredecision && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
exit 1
fi
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision && docker compose up -d --remove-orphans
- cd /opt/libredecision && docker compose ps
# Vérification que les containers sont running
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: healthcheck
image: alpine:3.20
depends_on:
- test-deploy
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure

101
CLAUDE.md
View File

@@ -1,88 +1,25 @@
# libreDecision # libreDecision
Boîte à outils de gouvernance collective pour la communauté Duniter/G1. Plateforme de decisions collectives pour la communaute Duniter/G1.
Documents modulaires sous vote permanent + protocoles de vote + mandats.
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
## Protocole de début de session
1. `git pull --rebase origin main`
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
3. Si l'objectif de la session n'est pas précisé, le demander
## Stack ## Stack
- **Frontend**: Nuxt 4 + Nuxt UI v3 + Pinia + UnoCSS (port 3002)
- **Backend**: Python FastAPI + SQLAlchemy async + PostgreSQL asyncpg (port 8002)
- **Auth**: Duniter V2 Ed25519 challenge-response
- **Sanctuaire**: IPFS (kubo) + hash on-chain (system.remark)
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm ## Commands
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic - Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev) - Backend tests: `cd backend && pytest tests/ -v`
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2 - Frontend: `cd frontend && npm run dev`
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker - Frontend build: `cd frontend && npm run build`
- Migrations: `cd backend && alembic upgrade head`
- Docker: `docker compose -f docker/docker-compose.yml up`
## Structure ## Conventions
- French for UI text and documentation
``` - English for code (variable names, comments, docstrings)
frontend/ - API versioned under `/api/v1/`
app/ - Pydantic v2 for all schemas
components/ # composants Vue - Async everywhere (SQLAlchemy, FastAPI)
layouts/ # layouts Nuxt - Ed25519 signatures for vote integrity
pages/ # routing file-based (9 pages sprint 1)
composables/ # (1 composable sprint 1)
stores/ # 5 Pinia stores (auth, ...)
assets/css/
moods.css # système de palettes (.mood-* sur <html>)
utils/ # (2 utils sprint 1)
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
backend/
app/
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
services/ # 6 services
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
models/ # 14 tables SQLAlchemy
alembic/versions/ # migrations
tests/ # 186 tests (63 intégration TDD sprint 1)
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
docker/
docker-compose.yml # postgres + backend + frontend + ipfs
backend.Dockerfile
frontend.Dockerfile
docs/content/ # 7 docs dev + 8 docs user
public/
hexagram-tsing.svg # sceau 井 (embossed)
hexagram-tsing-flat.svg
```
## Données runtime
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
- **ipfs-data** : volume Docker IPFS kubo
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
## Commandes
```bash
# Backend
cd backend && . venv/bin/activate
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
pytest tests/ -v
alembic upgrade head
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
# Frontend
cd frontend && npm run dev # :3002
npm run build
# Docker
docker compose -f docker/docker-compose.yml up
```
## Conventions / pièges
- **UI français, code anglais** (variables, commentaires, docstrings)
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test

View File

@@ -8,7 +8,6 @@ class Settings(BaseSettings):
# Environment # Environment
ENVIRONMENT: str = "development" # development, staging, production ENVIRONMENT: str = "development" # development, staging, production
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod # Database — SQLite by default for local dev, PostgreSQL for Docker/prod

View File

@@ -147,7 +147,7 @@ async def verify_challenge(
# 6. Get or create identity (apply dev profile if available) # 6. Get or create identity (apply dev profile if available)
dev_profile = None dev_profile = None
if settings.ENVIRONMENT == "development" or settings.DEMO_MODE: if settings.ENVIRONMENT == "development":
dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None) dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None)
identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile) identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile)
@@ -162,8 +162,8 @@ async def verify_challenge(
@router.get("/dev/profiles") @router.get("/dev/profiles")
async def list_dev_profiles(): async def list_dev_profiles():
"""List available demo profiles for quick login. Available in development or demo mode.""" """List available dev profiles for quick login. Only available in development."""
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE: if settings.ENVIRONMENT != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
return DEV_PROFILES return DEV_PROFILES

View File

@@ -31,7 +31,6 @@ from app.database import async_session, engine, Base, init_db
from app.models.protocol import FormulaConfig, VotingProtocol from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote from app.models.vote import VoteSession, Vote
@@ -2144,270 +2143,6 @@ async def seed_votes_on_items(
print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)") print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)")
# ---------------------------------------------------------------------------
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
Decision,
"title",
"Évolution Licence G1 v0.4.0",
description=(
"Proposition d'évolution de la Licence G1 vers la version 0.4.0. "
"Intègre les retours du forum, clarifie les engagements de certification "
"et précise le processus de vote nuancé."
),
context=(
"La Licence G1 v0.3.0 est en vigueur depuis l'origine de la monnaie libre. "
"Des discussions communautaires approfondies (topics 31066, 32375, 32409, 32412) "
"ont permis d'identifier des clarifications nécessaires. "
"Cette décision lance le processus de vote communautaire pour l'adoption "
"de la v0.4.0 selon le protocole Vote WoT standard."
),
decision_type="community",
status="draft",
)
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
steps = [
{
"step_order": 1,
"step_type": "qualification",
"title": "Rédaction de la proposition",
"description": (
"Co-rédaction de la v0.4.0 en intégrant les retours des discussions "
"forum. Coordination entre 1000i100, Natha, Pini et la communauté."
),
},
{
"step_order": 2,
"step_type": "review",
"title": "Période de commentaires",
"description": (
"Publication de la proposition sur le forum pendant 30 jours. "
"Recueil des amendements et objections. Intégration des retours "
"dans la version finale soumise au vote."
),
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote WoT (nuancé)",
"description": (
"Vote nuancé à 6 niveaux ouvert à tous les membres de la WoT. "
"Durée : 30 jours. Protocole : Vote WoT standard avec formule inertie."
),
},
{
"step_order": 4,
"step_type": "execution",
"title": "Mise à jour du dépôt officiel",
"description": (
"Si le seuil est atteint : mise à jour du dépôt git officiel, "
"calcul du hash IPFS, ancrage on-chain via system.remark."
),
},
]
for step_data in steps:
step = DecisionStep(decision_id=decision.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps)} steps created")
return decision
# ---------------------------------------------------------------------------
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
# Find Charlie (techcomm voter) as mandatee, or use first voter
mandatee_techcomm = next(
(v for v in voters if "Cgeek" in v.display_name or "Elois" in v.display_name),
voters[0] if voters else None,
)
mandatee_smith = next(
(v for v in voters if "Moul" in v.display_name or "Tuxmain" in v.display_name),
voters[1] if len(voters) > 1 else None,
)
mandates_data = [
{
"title": "Mandat Comité Technique — Session 2025-2026",
"description": (
"Le Comité Technique (ComTech) est mandaté par la communauté pour "
"assurer la validation technique des propositions on-chain : "
"runtime upgrades, modifications de paramètres réseau, audits de code. "
"Composition : 5 membres élus pour 12 mois."
),
"mandate_type": "techcomm",
"status": "active",
"mandatee_id": mandatee_techcomm.id if mandatee_techcomm else None,
"starts_at": now - timedelta(days=90),
"ends_at": now + timedelta(days=275),
"steps": [
{
"step_order": 1,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "completed",
"description": "Période de candidatures ouvertes sur le forum Duniter. Durée : 14 jours.",
"outcome": "5 candidats retenus : Elois, Cgeek, Maaltir, Hugo, Tuxmain",
},
{
"step_order": 2,
"step_type": "vote",
"title": "Élection par vote nuancé",
"status": "completed",
"description": "Vote WoT ouvert à tous les membres. Protocole Vote Nuancé (6 niveaux).",
"outcome": "Quorum atteint. 5 membres élus à plus de 70% de soutien.",
},
{
"step_order": 3,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "completed",
"description": "Mise en place du ComTech, définition des processus internes de décision.",
"outcome": "ComTech opérationnel depuis le 2025-09-15.",
},
{
"step_order": 4,
"step_type": "reporting",
"title": "Rapport de mi-mandat",
"status": "active",
"description": "Rapport public d'activité à mi-parcours du mandat.",
},
{
"step_order": 5,
"step_type": "completion",
"title": "Fin de mandat et bilan",
"status": "pending",
"description": "Rapport final, transmission aux successeurs, renouvellement ou dissolution.",
},
],
},
{
"title": "Mandat Administrateur des Forgerons — Rotation 2026-Q1",
"description": (
"L'Administrateur des Forgerons coordonne l'onboarding des nouveaux "
"forgerons, maintient la liste des nœuds actifs et anime les "
"discussions techniques de la sous-WoT Smith. "
"Mandat tournant de 6 mois, renouvelable une fois."
),
"mandate_type": "smith",
"status": "voting",
"mandatee_id": mandatee_smith.id if mandatee_smith else None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Définition du rôle",
"status": "completed",
"description": "Rédaction de la fiche de rôle et des responsabilités de l'Administrateur.",
"outcome": "Fiche de rôle validée par consensus sur le forum Duniter (topic 34201).",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures forgerons",
"status": "completed",
"description": "Candidatures ouvertes aux forgerons actifs depuis plus de 6 mois.",
"outcome": "2 candidats : Moul (Forgeron senior), Tuxmain (Forgeron actif).",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote forgeron (Smith)",
"status": "active",
"description": (
"Vote Smith à double critère : quorum WoT + quorum forgerons. "
"Durée : 30 jours. En cours."
),
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Passation avec l'administrateur sortant. Accès aux outils de coordination.",
},
],
},
{
"title": "Mandat Modération Forum — Duniter V2",
"description": (
"Équipe de modération élue pour maintenir la qualité des discussions "
"sur forum.duniter.org et forum.monnaie-libre.fr. "
"3 modérateurs, mandat de 12 mois."
),
"mandate_type": "custom",
"status": "draft",
"mandatee_id": None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Élaboration de la charte de modération",
"status": "active",
"description": "Définition des règles, outils et périmètre de la modération communautaire.",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "pending",
"description": "Ouvert à tous les membres de la WoT G1.",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Élection",
"status": "pending",
"description": "Vote WoT standard.",
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Formation aux outils Discourse et mise en place de la rotation.",
},
],
},
]
for m_data in mandates_data:
steps_data = m_data.pop("steps")
mandate, created = await get_or_create(
session,
Mandate,
"title",
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
if created:
for step_data in steps_data:
step = MandateStep(mandate_id=mandate.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps_data)} steps created")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main seed runner # Main seed runner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2419,32 +2154,29 @@ async def run_seed():
# Ensure tables exist # Ensure tables exist
await init_db() await init_db()
print("[0/10] Tables created.\n") print("[0/8] Tables created.\n")
async with async_session() as session: async with async_session() as session:
async with session.begin(): async with session.begin():
print("\n[1/10] Formula Configs...") print("\n[1/8] Formula Configs...")
formulas = await seed_formula_configs(session) formulas = await seed_formula_configs(session)
print("\n[2/10] Voting Protocols...") print("\n[2/8] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas) protocols = await seed_voting_protocols(session, formulas)
print("\n[3/10] Document: Acte d'engagement Certification...") print("\n[3/8] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols) await seed_document_engagement_certification(session, protocols)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...") print("\n[4/8] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols) doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
print("\n[5/10] Decision: Runtime Upgrade...") print("\n[5/7] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session) await seed_decision_runtime_upgrade(session)
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...") print("\n[6/7] Simulated voters...")
await seed_decision_licence_evolution(session)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session) voters = await seed_voters(session)
print("\n[8/10] Votes on first 3 engagements forgeron...") print("\n[7/7] Votes on first 3 engagements forgeron...")
await seed_votes_on_items( await seed_votes_on_items(
session, session,
doc_forgeron, doc_forgeron,
@@ -2452,11 +2184,6 @@ async def run_seed():
voters, voters,
) )
print("\n[9/10] Mandates...")
await seed_mandates(session, voters)
print("\n[10/10] Done.")
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Seed complete.") print("Seed complete.")
print("=" * 60) print("=" * 60)

View File

@@ -1,13 +1,14 @@
version: "3.9" name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-postgres
restart: always
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-libredecision} POSTGRES_DB: ${POSTGRES_DB:-libredecision}
POSTGRES_USER: ${POSTGRES_USER:-libredecision} POSTGRES_USER: ${POSTGRES_USER:-libredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -18,63 +19,57 @@ services:
start_period: 30s start_period: 30s
networks: networks:
- libredecision - libredecision
# Pas de label SERVICE_* : postgres non exposé publiquement
backend: backend:
build: image: libredecision-backend:latest
context: ../ container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend
dockerfile: docker/backend.Dockerfile restart: always
target: production
restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-libredecision} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-libredecision}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key} SECRET_KEY: ${SECRET_KEY}
ENVIRONMENT: production
DEBUG: "false" DEBUG: "false"
DEMO_MODE: ${DEMO_MODE:-true} CORS_ORIGINS: '["https://${APP_DOMAIN:-libredecision.org}"]'
CORS_ORIGINS: '["https://${DOMAIN:-decision.librodrome.org}"]'
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws} DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001 IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080 IPFS_GATEWAY_URL: http://ipfs:8080
labels: labels:
- "traefik.enable=true" # Registrator enregistre dans Consul, Fabio route automatiquement
- "traefik.http.routers.libredecision-api.rule=Host(`${DOMAIN:-decision.librodrome.org}`) && PathPrefix(`/api`)" - SERVICE_8002_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend-8002
- "traefik.http.routers.libredecision-api.entrypoints=websecure" - SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-libredecision.org}/api/*
- "traefik.http.routers.libredecision-api.tls.certresolver=letsencrypt" # TCP : HTTP check échoue si le service redirige (301/302)
- "traefik.http.services.libredecision-api.loadbalancer.server.port=8002" - SERVICE_8002_CHECK_TCP=true
networks: networks:
- libredecision - libredecision
- traefik - sonic
frontend: frontend:
build: image: libredecision-frontend:latest
context: ../ container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend
dockerfile: docker/frontend.Dockerfile restart: always
target: production
restart: unless-stopped
depends_on: depends_on:
- backend - backend
environment: environment:
NUXT_PUBLIC_API_BASE: https://${DOMAIN:-decision.librodrome.org}/api/v1 NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-libredecision.org}/api/v1
labels: labels:
- "traefik.enable=true" - SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend-3000
- "traefik.http.routers.libredecision-front.rule=Host(`${DOMAIN:-decision.librodrome.org}`)" - SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-libredecision.org}/*
- "traefik.http.routers.libredecision-front.entrypoints=websecure" - SERVICE_3000_CHECK_TCP=true
- "traefik.http.routers.libredecision-front.tls.certresolver=letsencrypt"
- "traefik.http.services.libredecision-front.loadbalancer.server.port=3000"
networks: networks:
- libredecision - sonic
- traefik
ipfs: ipfs:
image: ipfs/kubo:latest image: ipfs/kubo:latest
restart: unless-stopped container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-ipfs
restart: always
volumes: volumes:
- ipfs-data:/data/ipfs - ipfs-data:/data/ipfs
networks: networks:
- libredecision - libredecision
# Pas de label SERVICE_* : ipfs non exposé publiquement
volumes: volumes:
postgres-data: postgres-data:
@@ -83,5 +78,5 @@ volumes:
networks: networks:
libredecision: libredecision:
driver: bridge driver: bridge
traefik: sonic:
external: true external: true

View File

@@ -13,7 +13,7 @@ Ce guide couvre le deploiement complet de libreDecision en production avec Docke
| --------- | ---------------- | ----------- | | --------- | ---------------- | ----------- |
| Docker | 24+ | Moteur de conteneurs | | Docker | 24+ | Moteur de conteneurs |
| Docker Compose | 2.20+ | Orchestration multi-conteneurs | | Docker Compose | 2.20+ | Orchestration multi-conteneurs |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `decision.librodrome.org`) | | Nom de domaine | -- | Domaine pointe vers le serveur (ex: `libredecision.org`) |
| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt | | Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt |
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) | | Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees | | Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees |
@@ -46,12 +46,12 @@ cp .env.example .env
| `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose | | `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose |
| `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) | | `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) |
| `DEBUG` | Mode debug | `true` | **`false`** | | `DEBUG` | Mode debug | `true` | **`false`** |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://decision.librodrome.org"]` | | `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://libredecision.org"]` |
| `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production | | `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production |
| `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) | | `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) |
| `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) | | `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://decision.librodrome.org/api/v1` | | `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://libredecision.org/api/v1` |
| `DOMAIN` | Nom de domaine | `decision.librodrome.org` | Votre domaine | | `DOMAIN` | Nom de domaine | `libredecision.org` | Votre domaine |
### Generer les secrets ### Generer les secrets
@@ -108,7 +108,7 @@ docker compose -f docker/docker-compose.yml ps
docker compose -f docker/docker-compose.yml logs -f backend docker compose -f docker/docker-compose.yml logs -f backend
# Health check de l'API # Health check de l'API
curl -s https://decision.librodrome.org/api/health | jq . curl -s https://libredecision.org/api/health | jq .
``` ```
## Migration de base de donnees (Alembic) ## Migration de base de donnees (Alembic)
@@ -182,7 +182,7 @@ services:
- "--entrypoints.web.http.redirections.entryPoint.to=websecure" - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https" - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@decision.librodrome.org" - "--certificatesresolvers.letsencrypt.acme.email=admin@libredecision.org"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports: ports:
- "80:80" - "80:80"
@@ -209,8 +209,8 @@ docker compose -f docker-compose.traefik.yml up -d
Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik : Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik :
- **Frontend** : `Host(decision.librodrome.org)` sur le port 3000 - **Frontend** : `Host(libredecision.org)` sur le port 3000
- **Backend** : `Host(decision.librodrome.org) && PathPrefix(/api)` sur le port 8002 - **Backend** : `Host(libredecision.org) && PathPrefix(/api)` sur le port 8002
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`) - Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
- Redirection HTTP vers HTTPS automatique - Redirection HTTP vers HTTPS automatique
@@ -230,7 +230,7 @@ Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backe
```bash ```bash
# Health check de l'API # Health check de l'API
curl -s https://decision.librodrome.org/api/health curl -s https://libredecision.org/api/health
# Reponse attendue : {"status": "healthy"} # Reponse attendue : {"status": "healthy"}
``` ```
@@ -317,7 +317,7 @@ docker image prune -f
# 5. Verifier le deploiement # 5. Verifier le deploiement
docker compose -f docker/docker-compose.yml ps docker compose -f docker/docker-compose.yml ps
curl -s https://decision.librodrome.org/api/health curl -s https://libredecision.org/api/health
``` ```
### Pipeline CI/CD (Woodpecker) ### Pipeline CI/CD (Woodpecker)
@@ -377,7 +377,7 @@ docker compose -f docker/docker-compose.yml up -d # recree avec le nouveau m
**Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide. **Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide.
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig decision.librodrome.org` 1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig libredecision.org`
2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'` 2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'`
3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme` 3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme`

View File

@@ -4,11 +4,6 @@ const route = useRoute()
const { initMood } = useMood() const { initMood } = useMood()
const navigationItems = [ const navigationItems = [
{
label: 'Boîte à outils',
icon: 'i-lucide-wrench',
to: '/tools',
},
{ {
label: 'Documents', label: 'Documents',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
@@ -39,9 +34,6 @@ const navigationItems = [
/** Mobile drawer state. */ /** Mobile drawer state. */
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
/** Sidebar collapse state (icons-only mode). */
const sidebarCollapsed = ref(false)
/** Close mobile menu on route change. */ /** Close mobile menu on route change. */
watch(() => route.path, () => { watch(() => route.path, () => {
mobileMenuOpen.value = false mobileMenuOpen.value = false
@@ -51,14 +43,8 @@ watch(() => route.path, () => {
const ws = useWebSocket() const ws = useWebSocket()
const { setupWsNotifications } = useNotifications() const { setupWsNotifications } = useNotifications()
watch(sidebarCollapsed, (val) => {
localStorage.setItem('libred-sidebar-collapsed', String(val))
})
onMounted(async () => { onMounted(async () => {
initMood() initMood()
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
auth.hydrateFromStorage() auth.hydrateFromStorage()
if (auth.token) { if (auth.token) {
try { try {
@@ -108,7 +94,7 @@ function isActive(to: string) {
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" /> <UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
</span> </span>
<span class="app-header__logo-text"> <span class="app-header__logo-text">
<span class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span> <span class="app-header__logo-g">ğ</span><span class="app-header__logo-paren">(</span><span class="app-header__logo-word">Decision</span><span class="app-header__logo-paren">)</span>
</span> </span>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -191,7 +177,7 @@ function isActive(to: string) {
<!-- Main content with sidebar --> <!-- Main content with sidebar -->
<div class="app-body"> <div class="app-body">
<!-- Desktop sidebar --> <!-- Desktop sidebar -->
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }"> <aside class="app-sidebar">
<nav class="app-sidebar__nav"> <nav class="app-sidebar__nav">
<NuxtLink <NuxtLink
v-for="item in navigationItems" v-for="item in navigationItems"
@@ -200,21 +186,9 @@ function isActive(to: string) {
class="app-sidebar__link" class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }" :class="{ 'app-sidebar__link--active': isActive(item.to) }"
> >
<UIcon :name="item.icon" class="text-lg flex-shrink-0" /> <UIcon :name="item.icon" class="text-lg" />
<span class="app-sidebar__link-label">{{ item.label }}</span> <span>{{ item.label }}</span>
</NuxtLink> </NuxtLink>
<div class="app-sidebar__divider" />
<button
class="app-sidebar__toggle"
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<UIcon
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
class="text-base flex-shrink-0"
/>
<span class="app-sidebar__link-label">Replier</span>
</button>
</nav> </nav>
</aside> </aside>
@@ -262,7 +236,7 @@ function isActive(to: string) {
<!-- Footer --> <!-- Footer -->
<footer class="app-footer"> <footer class="app-footer">
<span>libreDecision v0.1.0</span> <span>ğ(Decision) v0.1.0</span>
<span class="app-footer__sep">·</span> <span class="app-footer__sep">·</span>
<span>Licence libre</span> <span>Licence libre</span>
</footer> </footer>
@@ -361,17 +335,24 @@ function isActive(to: string) {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0; gap: 0;
letter-spacing: -0.01em;
} }
.app-header__logo-libre { .app-header__logo-g {
font-size: 1.0625rem; font-size: 1.5rem;
font-weight: 400; font-weight: 800;
color: var(--mood-accent);
line-height: 1;
font-style: italic; font-style: italic;
color: var(--mood-text-muted);
} }
.app-header__logo-decision { .app-header__logo-paren {
font-size: 1.125rem;
font-weight: 300;
color: var(--mood-text-muted);
opacity: 0.5;
}
.app-header__logo-word {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
color: var(--mood-text); color: var(--mood-text);
@@ -478,12 +459,6 @@ function isActive(to: string) {
flex-shrink: 0; flex-shrink: 0;
background: var(--mood-surface); background: var(--mood-surface);
display: none; display: none;
transition: width 0.22s ease;
overflow: hidden;
}
.app-sidebar--collapsed {
width: 3.75rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -495,7 +470,7 @@ function isActive(to: string) {
.app-sidebar__nav { .app-sidebar__nav {
position: sticky; position: sticky;
top: 3.5rem; top: 3.5rem;
padding: 1rem 0.5rem; padding: 1rem 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -505,15 +480,13 @@ function isActive(to: string) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.625rem;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.875rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
color: var(--mood-text-muted); color: var(--mood-text-muted);
text-decoration: none; text-decoration: none;
border-radius: 12px; border-radius: 12px;
transition: all 0.12s ease; transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
} }
.app-sidebar__link:hover { .app-sidebar__link:hover {
@@ -527,56 +500,6 @@ function isActive(to: string) {
font-weight: 700; font-weight: 700;
} }
.app-sidebar__link-label {
overflow: hidden;
white-space: nowrap;
transition: opacity 0.18s ease, max-width 0.22s ease;
max-width: 10rem;
}
.app-sidebar--collapsed .app-sidebar__link-label {
opacity: 0;
max-width: 0;
}
.app-sidebar--collapsed .app-sidebar__link {
justify-content: center;
padding: 0.625rem;
}
.app-sidebar__divider {
height: 1px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
margin: 0.375rem 0.25rem;
}
.app-sidebar__toggle {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: pointer;
border-radius: 12px;
width: 100%;
transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
}
.app-sidebar__toggle:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar--collapsed .app-sidebar__toggle {
justify-content: center;
padding: 0.5rem;
}
/* === Mobile nav === */ /* === Mobile nav === */
.app-mobile-nav { .app-mobile-nav {
display: flex; display: flex;

View File

@@ -1,5 +1,5 @@
/* ========================================================================== /* ==========================================================================
libreDecision — Mood / Ambiance System ğ(Decision) — Mood / Ambiance System
Palettes harmoniques variees, colores en lite, lumineux en dark. Palettes harmoniques variees, colores en lite, lumineux en dark.
========================================================================== */ ========================================================================== */
@@ -144,7 +144,7 @@
} }
/* ========================================================================== /* ==========================================================================
Global design tokens — Plus Jakarta Sans, rounded, borderless Global design tokens — Nunito, rounded, borderless
========================================================================== */ ========================================================================== */
*, *,
@@ -154,12 +154,12 @@
} }
html { html {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-size: 16px; font-size: 16px;
} }
body { body {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; font-family: 'Nunito', system-ui, -apple-system, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -234,7 +234,7 @@ input:focus, select:focus, textarea:focus {
:root [class*="u-button"], :root [class*="u-button"],
:root [data-variant] { :root [data-variant] {
border: none !important; border: none !important;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important; font-family: 'Nunito', system-ui, sans-serif !important;
} }
:root input, :root input,
@@ -244,5 +244,5 @@ input:focus, select:focus, textarea:focus {
:root [class*="USelect"], :root [class*="USelect"],
:root [class*="UTextarea"] { :root [class*="UTextarea"] {
border: none !important; border: none !important;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important; font-family: 'Nunito', system-ui, sans-serif !important;
} }

View File

@@ -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>

View File

@@ -8,7 +8,7 @@
* - Permanent vote signage * - Permanent vote signage
* - Tuto overlay * - Tuto overlay
*/ */
import type { DocumentItem, ItemVersion } from '~/stores/documents' import type { DocumentItem } from '~/stores/documents'
const route = useRoute() const route = useRoute()
const documents = useDocumentsStore() const documents = useDocumentsStore()
@@ -138,40 +138,6 @@ async function archiveToSanctuary() {
} }
} }
// ─── View mode (editorial vs preview) ────────────────────────
type ViewMode = 'editorial' | 'preview'
type PreviewMode = 'current' | 'projected'
const viewMode = ref<ViewMode>('editorial')
const previewMode = ref<PreviewMode>('current')
const versionsLoaded = ref(false)
async function activatePreview() {
viewMode.value = 'preview'
if (!versionsLoaded.value && documents.items.length > 0) {
const itemIds = documents.items.map(i => i.id)
await documents.fetchAllItemVersions(slug.value, itemIds)
versionsLoaded.value = true
}
}
/** Map item_id → the active version under vote (or null). */
const activeVersionByItem = computed((): Record<string, ItemVersion | null> => {
const map: Record<string, ItemVersion | null> = {}
for (const item of documents.items) {
const versions = documents.allItemVersions[item.id] || []
map[item.id] = versions.find(v => v.status === 'vote')
|| versions.find(v => v.status === 'proposed')
|| null
}
return map
})
const hasProjectedChanges = computed(() =>
Object.values(activeVersionByItem.value).some(v => v !== null),
)
// ─── Active section (scroll spy) ────────────────────────────── // ─── Active section (scroll spy) ──────────────────────────────
const activeSection = ref<string | null>(null) const activeSection = ref<string | null>(null)
@@ -319,69 +285,8 @@ function toggleSection(tag: string) {
:genesis-json="documents.current.genesis_json" :genesis-json="documents.current.genesis_json"
/> />
<!-- VIEW MODE TOGGLE -->
<div class="doc-page__view-toggle">
<div class="doc-page__view-tabs">
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'editorial' }"
@click="viewMode = 'editorial'"
>
<UIcon name="i-lucide-layout-list" class="text-sm" />
Vue structurée
</button>
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'preview' }"
@click="activatePreview"
>
<UIcon name="i-lucide-file-text" class="text-sm" />
Aperçu document
<span v-if="documents.loadingVersions" class="doc-page__view-loading">
<UIcon name="i-lucide-loader-circle" class="text-xs animate-spin" />
</span>
</button>
</div>
<!-- Preview sub-mode (shown only in preview mode) -->
<Transition name="fade">
<div v-if="viewMode === 'preview'" class="doc-page__preview-modes">
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'current' }"
@click="previewMode = 'current'"
>
<UIcon name="i-lucide-circle-check" class="text-xs" />
En vigueur
</button>
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'projected' }"
:disabled="!hasProjectedChanges"
:title="!hasProjectedChanges ? 'Aucun vote en cours sur ce document' : 'Simuler les votes en cours'"
@click="previewMode = 'projected'"
>
<UIcon name="i-lucide-flask-conical" class="text-xs" />
Selon les votes
<span v-if="hasProjectedChanges" class="doc-page__preview-dot" />
</button>
</div>
</Transition>
</div>
<!-- DOCUMENT PREVIEW -->
<Transition name="fade">
<DocumentPreview
v-if="viewMode === 'preview'"
:document="documents.current"
:items="documents.items"
:mode="previewMode"
:version-map="activeVersionByItem"
/>
</Transition>
<!-- SECTION NAVIGATOR --> <!-- SECTION NAVIGATOR -->
<div v-if="sections.length > 1 && viewMode === 'editorial'" class="doc-page__section-nav"> <div v-if="sections.length > 1" class="doc-page__section-nav">
<button <button
v-for="section in sections" v-for="section in sections"
:key="section.tag" :key="section.tag"
@@ -396,7 +301,7 @@ function toggleSection(tag: string) {
</div> </div>
<!-- SECTIONS WITH ITEMS --> <!-- SECTIONS WITH ITEMS -->
<div v-if="viewMode === 'editorial'" class="doc-page__sections"> <div class="doc-page__sections">
<div <div
v-for="section in sections" v-for="section in sections"
:key="section.tag" :key="section.tag"
@@ -685,115 +590,6 @@ function toggleSection(tag: string) {
opacity: 1; opacity: 1;
} }
/* View mode toggle */
.doc-page__view-toggle {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.doc-page__view-tabs {
display: flex;
gap: 4px;
background: var(--mood-surface);
padding: 4px;
border-radius: 14px;
align-self: flex-start;
}
.doc-page__view-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.doc-page__view-tab:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.doc-page__view-tab--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.doc-page__view-loading {
display: inline-flex;
align-items: center;
margin-left: 2px;
}
.doc-page__preview-modes {
display: flex;
gap: 4px;
align-self: flex-start;
}
.doc-page__preview-mode {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-surface);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.doc-page__preview-mode:hover:not(:disabled) {
color: var(--mood-text);
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.doc-page__preview-mode:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.doc-page__preview-mode--active {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-mode--active:hover:not(:disabled) {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mood-warning, #f59e0b);
position: absolute;
top: 4px;
right: 4px;
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Section collapse transition */ /* Section collapse transition */
.section-collapse-enter-active, .section-collapse-enter-active,
.section-collapse-leave-active { .section-collapse-leave-active {

View File

@@ -68,8 +68,6 @@ interface DocumentsState {
current: Document | null current: Document | null
items: DocumentItem[] items: DocumentItem[]
versions: ItemVersion[] versions: ItemVersion[]
allItemVersions: Record<string, ItemVersion[]>
loadingVersions: boolean
loading: boolean loading: boolean
error: string | null error: string | null
} }
@@ -80,8 +78,6 @@ export const useDocumentsStore = defineStore('documents', {
current: null, current: null,
items: [], items: [],
versions: [], versions: [],
allItemVersions: {},
loadingVersions: false,
loading: false, loading: false,
error: null, error: null,
}), }),
@@ -251,28 +247,6 @@ export const useDocumentsStore = defineStore('documents', {
} }
}, },
/**
* 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. * Archive a document into the Sanctuary.
*/ */
@@ -306,8 +280,6 @@ export const useDocumentsStore = defineStore('documents', {
this.current = null this.current = null
this.items = [] this.items = []
this.versions = [] this.versions = []
this.allItemVersions = {}
this.loadingVersions = false
}, },
}, },
}) })

View File

@@ -29,7 +29,7 @@ export default defineNuxtConfig({
link: [ link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700&display=swap' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,700;1,800&display=swap' },
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' }, { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' },
], ],
script: [ script: [

View File

@@ -7505,15 +7505,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -15039,6 +15030,15 @@
"url": "https://opencollective.com/svgo" "url": "https://opencollective.com/svgo"
} }
}, },
"node_modules/svgo/node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/system-architecture": { "node_modules/system-architecture": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",