Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c5bf148c | ||
|
|
6509137892 | ||
|
|
488114791c | ||
|
|
3e702fdbf3 | ||
|
|
e24c2a65a0 | ||
|
|
53fc9927ef | ||
|
|
a9599ba32a |
@@ -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
|
||||||
|
|||||||
256
.woodpecker.yml
256
.woodpecker.yml
@@ -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
101
CLAUDE.md
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
289
backend/seed.py
289
backend/seed.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user