From 1069bba2e426c820d967c7b6ea65610c09d38d59 Mon Sep 17 00:00:00 2001 From: Valentin Le Moign Date: Mon, 6 Jul 2026 13:48:31 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20versionner=20spec=20+=20guide=20de=20d?= =?UTF-8?q?=C3=A9ploiement=20(hors=20portfolios-examples)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guide de déploiement prod : VM Debian nue (sans Docker), service systemd, déploiement par push Git (bare repo + post-receive), branchement rsync/SSH en lecture seule à la prod Grav, reverse proxy HTTPS, dimensionnement VM. --- .gitignore | 6 +- docs/deploiement-production.md | 485 ++++++++++++++++++++++++ docs/spec-generateur-portfolios.md | 579 +++++++++++++++++++++++++++++ 3 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 docs/deploiement-production.md create mode 100644 docs/spec-generateur-portfolios.md diff --git a/.gitignore b/.gitignore index b555c60..95ce998 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ generator/ grav-reference/ -# Docs : PDF de référence + spec (déjà réalisée, désormais désynchro du code). -# Le Dockerfile de l'image Grav reste versionné (docker/grav/). -docs/ +# Docs versionnées (spec, guide de déploiement…), SAUF les exemples volumineux +# (PDF de référence). Le Dockerfile de l'image Grav reste versionné (docker/grav/). +docs/portfolios-examples/ # Divers .DS_Store diff --git a/docs/deploiement-production.md b/docs/deploiement-production.md new file mode 100644 index 0000000..8705990 --- /dev/null +++ b/docs/deploiement-production.md @@ -0,0 +1,485 @@ +# Déploiement en production + +Ce document décrit comment déployer le **générateur de portfolios** sur une VM +**Debian nue** (sans Docker) et le brancher, en lecture seule, à la VM **Grav de +production** de Figures Libres. + +Le déploiement se fait par **push Git** vers un dépôt *bare* sur la VM (même +principe que les autres projets Figures Libres : +). L'app tourne +comme **service systemd** (serveur Nitro), derrière un **reverse proxy HTTPS**. + +> ⚠️ **Exception à la règle « tout en Docker ».** Docker n'est utilisé qu'en +> développement local. En production, l'app tourne **nativement** sur Debian. + +--- + +## 1. Architecture cible + +``` + Poste dev VM « portfolios » (Debian nue) VM Grav (prod) + ┌───────────┐ git push prod ┌──────────────────────────────────────┐ rsync/SSH ┌──────────────┐ + │ generator │ ─────────────────▶ │ bare repo .git ──post-receive──▶ deploy│ ─(lecture ────▶│ user/pages/ │ + │ (repo) │ │ → checkout + npm ci + build │ seule) │ 02.projets/ │ + └───────────┘ │ → systemctl restart │◀──── pull ──────│ (inchangé) │ + │ │ └──────────────┘ + │ systemd: node .output/server/index.mjs │ + │ écoute 127.0.0.1:3000 │ + │ données: /var/lib/portfolio-generator/ │ + └──────────────┬─────────────────────────┘ + │ 127.0.0.1:3000 + ┌────────▼─────────┐ + │ nginx :443 (TLS) │ ◀── navigateurs (équipe + liens publics) + └──────────────────┘ +``` + +Points clés : + +- Le contenu ne circule que **dans un sens** : la VM portfolios *tire* les projets + depuis la prod Grav par `rsync`/SSH en **lecture seule**. La prod n'est jamais + modifiée et **ignore** l'existence de l'app. +- On **ne réimporte pas** `grav-reference` en prod (c'est un jeu d'exemple de dev). + Le contenu prod vit dans un cache local rafraîchi par le bouton « Rafraîchir ». +- Le rendu PDF appelle le serveur en interne sur **`localhost:3000`** : le port + Nitro **doit** rester `3000` (voir §6). + +--- + +## 2. Dimensionnement de la VM + +### RAM + +| Poste | Empreinte | +|-------|-----------| +| Serveur Nitro au repos | ~150–300 Mo | +| Pic à la **génération PDF** : Chromium (~300–600 Mo) + traitement d'images `sharp` en 2× sur des pages A3 + Ghostscript | **1–2 Go** ponctuels | + +- **Recommandé : 4 Go.** Plancher : 2 Go (risqué sur des portfolios très riches en + images — le processus de génération peut être tué par l'OOM killer). +- La génération est **séquentielle** (une seule instance Chromium à la fois via une + file d'attente) : la RAM ne monte pas linéairement avec le nombre d'utilisateurs. + +### Disque + +Ordres de grandeur mesurés / à prévoir (l'essentiel vit dans `data/`) : + +| Élément | Taille | Note | +|---------|--------|------| +| Debian + Node + outils | ~3–4 Go | système | +| `node_modules` | ~1–1,5 Go | Nuxt + Playwright + sharp | +| Chromium (Playwright) | ~0,5 Go | navigateur de rendu | +| `.output` (build Nitro) | ~0,1–0,3 Go | régénéré à chaque déploiement | +| `data/content-cache/` | ~0,75 Go aujourd'hui | miroir de `02.projets` (croît avec le contenu) | +| `data/frozen/` | ~0,4 Go aujourd'hui | médias figés des portfolios générés (croît à l'usage) | +| `data/exports/` | ~0,015 Go | PDF (1–8 Mo pièce, régénérables) | +| `data/cache/` | transitoire | rendu/travail PDF | +| `data/portfolios/` | quelques Mo | **la donnée métier** (JSON) | + +- **Recommandé : 30 Go** (confort + croissance du contenu et des instantanés). + Plancher : 20 Go. + +### CPU + +- **2 vCPU recommandés.** La génération PDF est *CPU-bound* (rendu Chromium + + ré-échantillonnage d'images + Ghostscript), par à-coups. + +--- + +## 3. Prérequis logiciels (installation unique, en root) + +Sur la **VM portfolios**, une seule fois : + +```bash +apt update && apt install -y curl git rsync openssh-client ghostscript ffmpeg nginx + +# Node.js 22 LTS (dépôt NodeSource) +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt install -y nodejs +node --version # v22.x + +# Dépendances système de Chromium (pour Playwright) — installées plus tard +# par « npx playwright install-deps » (§7), qui nécessite root. +``` + +--- + +## 4. Utilisateur et arborescence + +On dédie un utilisateur système `portfolio` qui **possède tout** (dépôt bare, +checkout, données, service). Cela évite tout jonglage de permissions. + +```bash +# en root +adduser --disabled-password --gecos "" portfolio + +# arborescences +install -d -o portfolio -g portfolio /home/portfolio/gitBareRepos +install -d -o portfolio -g portfolio /var/www/portfolio-generator +install -d -o portfolio -g portfolio /var/lib/portfolio-generator/data +install -d -o portfolio -g portfolio -m 700 /var/lib/portfolio-generator/.ssh +``` + +| Chemin | Rôle | +|--------|------| +| `/home/portfolio/gitBareRepos/portfolio-generator.git` | dépôt *bare* (cible du push) | +| `/var/www/portfolio-generator/public_html` | checkout de travail (build) | +| `/var/www/portfolio-generator/deploy.sh` | script de déploiement | +| `/var/lib/portfolio-generator/data` | **données persistantes** (`NUXT_DATA_DIR`) | +| `/var/lib/portfolio-generator/.ssh/portfolio_sync` | clé privée de synchro rsync | +| `/etc/portfolio-generator.env` | variables d'environnement du service | + +--- + +## 5. Déploiement par push Git + +> Tout le §5 s'exécute **en tant qu'utilisateur `portfolio`** (`sudo -iu portfolio`). + +### 5.1 Dépôt *bare* + +```bash +cd ~/gitBareRepos +git init --bare portfolio-generator.git +``` + +### 5.2 Hook `post-receive` + +`~/gitBareRepos/portfolio-generator.git/hooks/post-receive` : + +```bash +#!/usr/bin/env bash +PRODDIR="/var/www/portfolio-generator" +while read oldrev newrev refname; do + if [ "$refname" = "refs/heads/prod" ]; then + echo "===== DÉPLOIEMENT =====" + unset GIT_DIR + cd "$PRODDIR" || exit 1 + . deploy.sh + echo "===== OK =====" + else + echo "Commit non déployé — pushez la branche prod." + fi +done +``` + +```bash +chmod +x ~/gitBareRepos/portfolio-generator.git/hooks/post-receive +``` + +### 5.3 Checkout de travail + +Cloné une fois depuis le dépôt bare (il aura ainsi `origin` = bare) : + +```bash +git clone ~/gitBareRepos/portfolio-generator.git /var/www/portfolio-generator/public_html +``` + +### 5.4 Script `deploy.sh` + +`/var/www/portfolio-generator/deploy.sh` : + +```bash +#!/usr/bin/env bash +set -euo pipefail +APPDIR="/var/www/portfolio-generator/public_html" + +echo "→ mise à jour du code" +cd "$APPDIR" +git fetch origin +git reset --hard origin/prod + +echo "→ dépendances + build Nitro" +npm ci +npm run build + +echo "→ redémarrage du service" +sudo systemctl restart portfolio-generator +``` + +```bash +chmod +x /var/www/portfolio-generator/deploy.sh +``` + +`npm ci` télécharge automatiquement Chromium dans le cache de l'utilisateur +`portfolio` (les dépendances **système** de Chromium sont, elles, installées une +fois en root à l'étape §7). + +### 5.5 Autoriser le redémarrage du service (sudoers) + +`deploy.sh` a besoin de redémarrer le service ; on limite ce `sudo` au strict +nécessaire. `/etc/sudoers.d/portfolio-generator` (en root, `visudo -f …`) : + +``` +portfolio ALL=(root) NOPASSWD: /usr/bin/systemctl restart portfolio-generator +``` + +--- + +## 6. Service systemd et environnement + +### 6.1 Fichier d'environnement + +`/etc/portfolio-generator.env` (droits `600`, propriétaire `portfolio` — il +contient des secrets) : + +```dotenv +# --- Serveur --- +# Le port DOIT rester 3000 : le rendu PDF appelle le serveur sur localhost:3000. +NITRO_HOST=127.0.0.1 +NITRO_PORT=3000 + +# --- Secrets (valeurs aléatoires longues : openssl rand -hex 32) --- +NUXT_PORTFOLIO_PASSWORD= +NUXT_SESSION_SECRET= +NUXT_INTERNAL_TOKEN= + +# --- Données persistantes (hors du checkout, préservées aux déploiements) --- +NUXT_DATA_DIR=/var/lib/portfolio-generator/data + +# --- Source de contenu : production (cache rsync) --- +NUXT_CONTENT_SOURCE=prod +NUXT_SYNC_REMOTE=portfolio-sync@:/var/www/figureslibres/user/pages/02.projets/ +NUXT_SYNC_SSH_KEY=/var/lib/portfolio-generator/.ssh/portfolio_sync +``` + +### 6.2 Unité systemd + +`/etc/systemd/system/portfolio-generator.service` : + +```ini +[Unit] +Description=Générateur de portfolios Figures Libres +After=network.target + +[Service] +Type=simple +User=portfolio +Group=portfolio +WorkingDirectory=/var/www/portfolio-generator/public_html +EnvironmentFile=/etc/portfolio-generator.env +ExecStart=/usr/bin/node .output/server/index.mjs +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +systemctl daemon-reload +systemctl enable portfolio-generator # démarrage auto (ne pas encore start : voir §7) +``` + +--- + +## 7. Premier déploiement (bootstrap) + +1. **Côté poste dev**, dans le dépôt `generator`, ajouter le distant et pousser la + branche `prod` (déclenche le hook) : + ```bash + git remote add prod portfolio@:gitBareRepos/portfolio-generator.git + git push prod main:prod # pousse la branche locale main sur la branche prod distante + ``` + Le hook lance `deploy.sh` → `npm ci` + `npm run build`. (Au tout premier push, + `git reset --hard origin/prod` échoue si le checkout est vide : voir la note.) + +2. **Côté VM (utilisateur `portfolio`)**, une fois le code présent dans + `public_html`, installer les **dépendances système de Chromium** (root) : + ```bash + sudo npx --prefix /var/www/portfolio-generator/public_html playwright install-deps chromium + ``` + +3. Démarrer le service : + ```bash + sudo systemctl start portfolio-generator + sudo systemctl status portfolio-generator + journalctl -u portfolio-generator -f + ``` + +> **Note premier push** : si `git reset --hard origin/prod` du `deploy.sh` bute sur +> un checkout vierge, faire une fois manuellement dans `public_html` : +> `git fetch origin && git checkout -B prod origin/prod`, puis relancer un push. + +--- + +## 8. Brancher à la prod Grav (rsync/SSH en lecture seule) + +### 8.1 Clé de synchronisation (VM portfolios, utilisateur `portfolio`) + +```bash +ssh-keygen -t ed25519 -N "" \ + -f /var/lib/portfolio-generator/.ssh/portfolio_sync -C "portfolio-sync" +cat /var/lib/portfolio-generator/.ssh/portfolio_sync.pub # ← à déposer côté Grav +``` + +### 8.2 Côté VM Grav : accès en lecture seule + +Créer un utilisateur dédié et l'autoriser à **lire** les projets, sans shell ni +écriture : + +```bash +sudo adduser --disabled-password --gecos "" portfolio-sync +sudo setfacl -R -m u:portfolio-sync:rX /var/www/figureslibres/user/pages/02.projets +sudo setfacl -R -d -m u:portfolio-sync:rX /var/www/figureslibres/user/pages/02.projets +``` + +Déposer la clé publique dans `~portfolio-sync/.ssh/authorized_keys` en la +**restreignant à rsync** (recommandé) via `rrsync` : + +``` +command="rrsync -ro /var/www/figureslibres/user/pages/02.projets",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA...portfolio-sync... +``` + +> `rrsync` accompagne rsync (`/usr/bin/rrsync` ou +> `/usr/share/doc/rsync/scripts/rrsync`). Il verrouille la clé à un `rsync` en +> lecture seule sur ce seul dossier. À défaut, une clé standard en lecture seule +> convient. La commande exécutée par l'app est : +> `rsync -az --delete -e "ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i " / /`. + +### 8.3 Réseau + +- Ouvrir **SSH (22)** en sortie de la VM portfolios vers la VM Grav. +- Renseigner `NUXT_SYNC_REMOTE` avec le **chemin absolu** de `02.projets` sur la VM + Grav, terminé par `/` (voir §6.1). +- Tester la connexion une fois pour accepter la clé d'hôte : + ```bash + sudo -u portfolio ssh -i /var/lib/portfolio-generator/.ssh/portfolio_sync \ + portfolio-sync@ "echo ok" + ``` + +--- + +## 9. Reverse proxy HTTPS (indispensable) + +Le HTTPS est **requis**, pas seulement confortable : + +- les **liens de partage publics** (`/view/`) sont faits pour être ouverts hors + réseau local ; +- la **copie du lien dans le presse-papiers** n'est autorisée par les navigateurs + qu'en *contexte sécurisé* (HTTPS ou `localhost`) — en HTTP sur IP elle est bloquée. + +`/etc/nginx/sites-available/portfolio-generator` : + +```nginx +server { + listen 80; + server_name portfolios.figureslibres.io; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name portfolios.figureslibres.io; + + # ssl_certificate ... ; # via certbot (Let's Encrypt) + # ssl_certificate_key ... ; + + client_max_body_size 64m; # médias volumineux + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; # la génération PDF peut être longue + } +} +``` + +```bash +ln -s /etc/nginx/sites-available/portfolio-generator /etc/nginx/sites-enabled/ +apt install -y certbot python3-certbot-nginx +certbot --nginx -d portfolios.figureslibres.io +nginx -t && systemctl reload nginx +``` + +Le service Nitro écoutant sur `127.0.0.1:3000`, il **n'est pas** exposé +directement : seul le port 443 est public. Un pare-feu (`ufw allow 443,22`) durcit +l'ensemble. + +--- + +## 10. Première synchronisation & vérification + +1. Ouvrir `https://portfolios.figureslibres.io`. +2. Se connecter avec `NUXT_PORTFOLIO_PASSWORD`. +3. Cliquer **« Rafraîchir »** : premier `rsync` vers + `/var/lib/portfolio-generator/data/content-cache/`. Le toast indique le nombre + de fichiers et de projets synchronisés. +4. Créer un portfolio de test et **générer un PDF** pour valider toute la chaîne + (Chromium + Ghostscript). + +--- + +## 11. Sauvegardes + +L'état applicatif vit dans **`/var/lib/portfolio-generator/data/`** : + +| Dossier | Contenu | Sauvegarder ? | +|---------|---------|---------------| +| `portfolios/` | portfolios (JSON) — **la donnée métier** | **Oui** | +| `frozen/` | instantanés figés (contenu + composition) | Oui | +| `exports/` | PDF générés | Optionnel (régénérables) | +| `content-cache/` | miroir rsync de la prod | Non (re-tirable) | +| `cache/` | cache de rendu | Non | + +Sauvegarde à chaud (les écritures des portfolios sont atomiques) : + +```bash +tar czf portfolios-backup-$(date +%F).tar.gz \ + -C /var/lib/portfolio-generator/data portfolios frozen +``` + +--- + +## 12. Mises à jour (au quotidien) + +Depuis le poste dev, une fois le travail prêt : + +```bash +git push prod main:prod +``` + +Le hook redéploie tout seul (`git reset --hard` + `npm ci` + `npm run build` + +redémarrage). Les données de `/var/lib/portfolio-generator/data/` sont préservées. +Suivre : `journalctl -u portfolio-generator -f`. + +--- + +## 13. Dépannage + +| Symptôme | Piste | +|----------|-------| +| Le push ne déploie pas | Vérifier qu'on pousse bien vers la branche **`prod`** (`git push prod main:prod`) et que `post-receive` est exécutable. | +| `deploy.sh` échoue sur `systemctl restart` | Vérifier l'entrée `sudoers.d/portfolio-generator` (§5.5). | +| Service qui ne démarre pas | `journalctl -u portfolio-generator -e` ; souvent `.output` absent (build échoué) ou `EnvironmentFile` manquant. | +| Génération PDF en échec | Dépendances Chromium manquantes → `npx playwright install-deps chromium` (§7.2) ; sinon vérifier RAM (OOM) dans `journalctl`. | +| « Rafraîchir » échoue (« Échec rsync ») | Tester la commande SSH (§8.3) ; vérifier `NUXT_SYNC_REMOTE` (chemin absolu, `/` final), les droits de lecture et le pare-feu sortant (22). | +| Copie du lien public impossible | Accès en HTTP/IP → passer par l'URL **HTTPS** (§9). | +| PDF « à jour » ne se met pas à jour | La régénération réutilise l'instantané figé ; utiliser « Réactualiser depuis Grav » pour resynchroniser le contenu. | + +--- + +## 14. Récapitulatif express + +```bash +# ── VM portfolios (root, une fois) ── +apt update && apt install -y curl git rsync openssh-client ghostscript ffmpeg nginx +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt install -y nodejs +adduser --disabled-password --gecos "" portfolio +install -d -o portfolio -g portfolio /home/portfolio/gitBareRepos /var/www/portfolio-generator +install -d -o portfolio -g portfolio /var/lib/portfolio-generator/data +install -d -o portfolio -g portfolio -m 700 /var/lib/portfolio-generator/.ssh +# → créer bare repo + hook + deploy.sh (§5), env + service systemd (§6), +# sudoers (§5.5), clé de synchro (§8.1) + +# ── Poste dev ── +git remote add prod portfolio@:gitBareRepos/portfolio-generator.git +git push prod main:prod + +# ── VM portfolios (après 1er push) ── +sudo npx --prefix /var/www/portfolio-generator/public_html playwright install-deps chromium +sudo systemctl start portfolio-generator + +# ── Côté Grav : déposer la clé publique en lecture seule (§8.2) ── +# ── nginx + certbot en façade (§9), puis « Rafraîchir » dans l'UI ── +``` diff --git a/docs/spec-generateur-portfolios.md b/docs/spec-generateur-portfolios.md new file mode 100644 index 0000000..0d4c2ac --- /dev/null +++ b/docs/spec-generateur-portfolios.md @@ -0,0 +1,579 @@ +# Spécification technique — Générateur de portfolios PDF (Figures Libres) + +> Document de spécification v1 — établi le 2026-07-01, révisé le 2026-07-02. +> Outil compagnon du site Grav « Figures Libres » permettant de composer et générer +> des portfolios PDF à partir des projets saisis dans Grav. + +--- + +## 1. Contexte & objectif + +Le collectif Figures Libres gère ses projets dans un site **Grav** (v1.7.52). Chaque projet +y est déjà décrit (titre, texte, médias, catégorie, tags, date, lien). L'objectif est de +**réutiliser ces contenus pour produire des portfolios PDF sur mesure**, sans ressaisie : + +- sélectionner/désélectionner les projets à inclure ; +- ordonner les pages ; +- choisir, **pour chaque page**, quel projet est présenté avec quel **template** ; +- **curer les médias** affichés page par page (choix + ordre + disposition) ; +- intercaler des **pages libres** (couverture, intro, sommaire) personnalisables ; +- exporter un **PDF léger et net**, destiné à l'envoi par mail. + +L'outil est **séparé de Grav** (pas un plugin) : une application web dédiée qui lit le +contenu Grav en lecture seule. + +--- + +## 2. Décisions validées (récapitulatif des arbitrages) + +| Sujet | Décision | +|---|---| +| Utilisateurs | **Interne au collectif** (quelques utilisateurs de confiance) | +| Qualité PDF | **Écran / partage** (RGB, pas de contrainte imprimeur) | +| Templates | **Définis en code** (pas d'éditeur visuel de template) | +| Composition | **1 projet / page + curation des médias** (sélection + ordre + layout, sans recadrage) | +| Sauvegarde | **Oui** : bibliothèque de portfolios rééditables | +| Pages non-projet | **Couverture, sommaire, pages libres d'intro** (personnalisables dans l'UI) ; séparateurs/4e de couv optionnels | +| Texte des pages | **Auto depuis Grav + override ponctuel par page** | +| Format de page | **Fixe, 16:9 « écran » paysage** (voir §9.4) | +| Fraîcheur des données | **Live + figé à la génération** (le portfolio archive le contenu utilisé) | +| Sections source | **Projets uniquement** (`user/pages/02.projets`) | +| Champs exploités | Titre, texte, médias, catégorie/tags, date, lien externe | +| Médias | UI en basse résolution ; génération en pleine résolution ; **post-traitement du PDF** pour redimensionner chaque image à sa taille d'affichage | +| Personnalisation à la génération | Titre/sous-titre couverture, nom du fichier PDF ; couverture + pages libres personnalisables | +| Langue | **FR uniquement** | +| Fonds de page | **Blanc / teinté / pleine couleur** (prop `background`), dès la v1 | +| Direction artistique | **Sobre** : pas de formes graphiques décoratives ni d'effets « fluo » | +| Stack | **Nuxt 3 + Nuxt UI + Playwright (Chromium) + Paged.js** | +| Auth | **Mot de passe partagé simple** | +| Stockage | **Fichiers JSON + PDF sur disque** (volume Docker) | +| Déploiement | **Serveur perso (Docker + Playwright)**, contenu tiré de la prod en **rsync/SSH lecture seule** | +| Synchro | **Bouton manuel « Rafraîchir »** | +| Templates de départ | **« Visuel dominant + texte »** et **« Grille / planche »** ; layouts d'images variables (§9.5) | +| Références d'inspiration | 2 books dans `docs/portfolios-examples/` — **inspiration non-normative** (§17) | + +--- + +## 3. Utilisateurs & cas d'usage + +**Utilisateur type** : un membre du collectif préparant un portfolio pour une candidature, +un appel d'offres ou un envoi client. + +**Parcours principal :** +1. Se connecter (mot de passe partagé). +2. (Optionnel) Cliquer **« Rafraîchir »** pour re-synchroniser le contenu depuis la prod. +3. Créer un nouveau portfolio (ou dupliquer un existant). +4. Parcourir la **bibliothèque de projets**, filtrer par catégorie/tag, cocher ceux à inclure. +5. Pour chaque page : choisir le **template**, **curer les médias**, ajuster le **texte** si besoin. +6. Insérer des **pages libres** (couverture, intro, sommaire) et les personnaliser. +7. **Réordonner** les pages (glisser-déposer). +8. **Prévisualiser** (rendu WYSIWYG identique au PDF). +9. **Générer le PDF**, renseigner le nom de fichier, télécharger. +10. Le portfolio reste **sauvegardé** et rééditable. + +--- + +## 4. Architecture générale + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Serveur perso (Docker) │ +│ │ +│ ┌────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Nuxt 3 (UI + API) │ │ Cache de contenu (lecture) │ │ +│ │ - Bibliothèque │◀────▶│ user/pages/02.projets/… │ │ +│ │ - Composeur │ │ (md + médias) │ │ +│ │ - Prévisualisation│ └──────────────▲───────────────┘ │ +│ │ - Templates (Vue) │ │ rsync/SSH (RO) │ +│ └─────────┬──────────┘ │ (bouton │ +│ │ rendu HTML │ « Rafraîchir ») │ +│ ▼ │ │ +│ ┌────────────────────┐ ┌──────────────┴───────────────┐ │ +│ │ Playwright/Chromium│ │ Stockage portfolios │ │ +│ │ + Paged.js │ │ data/portfolios/*.json │ │ +│ │ → PDF │─────▶│ data/exports/*.pdf │ │ +│ │ + sharp + gs (opti)│ └──────────────────────────────┘ │ +│ └────────────────────┘ │ +└───────────────────────────────────┬──────────────────────────┘ + │ (SSH lecture seule) + ┌──────────▼───────────┐ + │ Serveur PROD Grav │ + │ figureslibres.cc │ + │ (aucune charge PDF) │ + └───────────────────────┘ +``` + +**Principe clé** : la prod ne fait que **servir le site et exposer son contenu en SSH**. +Toute la charge lourde (Playwright, génération, optimisation PDF) tourne sur le serveur perso. + +--- + +## 5. Topologie de déploiement & sources de contenu + +L'outil supporte **deux sources de contenu** via une même interface d'abstraction +(`ContentSource`), sélectionnée par configuration : + +### 5.1 Mode DEV (immédiat, sans prod) +- Source = **le Grav local présent dans ce dépôt** (`../grav-reference/user/pages/02.projets`). +- Lecture directe du système de fichiers (le dossier est déjà monté sur le serveur). +- **Permet de développer et tester tout l'outil dès maintenant**, avant tout accès prod. + +### 5.2 Mode PROD (cible) +- Source = **cache local alimenté par rsync/SSH** depuis la prod Figures Libres. +- L'outil ne se connecte jamais en écriture à la prod. + +Le passage dev → prod ne change **que la configuration de la source**, pas le modèle de +données ni les templates. + +--- + +## 6. Synchronisation du contenu (mode PROD) + +- **Mécanisme** : `rsync -az --delete -e ssh` tirant `user/pages/02.projets/` (Markdown + + médias) de la prod vers `data/content-cache/`. +- **Déclenchement** : **bouton manuel « Rafraîchir »** dans l'UI (endpoint API qui lance + la commande et renvoie un résumé : nb de fichiers mis à jour, date). +- **Incrémental** : rsync ne transfère que les fichiers modifiés → rapide même avec de + nombreux médias lourds. +- **Lecture seule** : aucune écriture côté prod ; identifiants SSH stockés en secret + (variable d'environnement / fichier non versionné). +- **Note git** : le contenu (`user/pages/*`) est exclu de git côté Grav — rsync est donc + le bon outil (git ne verrait pas les projets ni les médias). + +--- + +## 7. Modèle de données + +### 7.1 Lecture du contenu Grav + +Structure observée dans le Grav Figures Libres : + +``` +user/pages/02.projets/ + 01.culturelle/ ← catégorie (dossier) + atlas-atterrissage/ ← projet (dossier, slug) + reader.md ← frontmatter YAML + corps Markdown + *.png / *.mp4 ← médias (+ variantes @2x/@3x/@4x) + 02.en-francais-au-pluriel/ + reader.md + … + 02.publique/ + 03.sociale/ +``` + +Frontmatter type d'un projet : +```yaml +title: 'En français au pluriel' +aura: + image: Screenshot_….png # visuel de couverture du projet +media_order: 'a.png,b.png,…' # ordre des médias +taxonomy: + category: ['Site web', 'Identité visuelle'] + tag: ['Collectif Rivage'] +date: '09-12-2024 22:19' +published: true +``` ++ corps Markdown (description, éventuel lien externe en fin de texte). + +### 7.2 Modèle interne (TypeScript, indicatif) + +```ts +// --- Contenu importé de Grav (lecture seule) --- +interface Project { + id: string; // slug complet, ex: "02.projets/01.culturelle/atlas-atterrissage" + category: string; // "culturelle" | "publique" | "sociale" + title: string; + bodyMarkdown: string; // texte descriptif + coverImage?: string; // aura.image + media: MediaAsset[]; // dans l'ordre de media_order + categories: string[]; // taxonomy.category + tags: string[]; // taxonomy.tag + date?: string; // ISO + externalUrl?: string; // lien détecté dans le corps + published: boolean; +} + +interface MediaAsset { + filename: string; + type: 'image' | 'video'; + variants: { label: '1x'|'2x'|'3x'|'4x'; path: string; width?: number }[]; + posterPath?: string; // pour les vidéos : image d'aperçu +} + +// --- Composition (créée dans l'outil, sauvegardée) --- +interface Portfolio { + id: string; + name: string; // nom interne + cover: CoverConfig; // titre/sous-titre/… personnalisables + pages: Page[]; // ordre = ordre du PDF + createdAt: string; + updatedAt: string; + frozenContent?: FrozenSnapshot; // rempli à la génération (voir §7.3) +} + +type Page = + | ProjectPage + | FreePage + | CoverPage + | TocPage; + +// Fond de page commun à tous les types (voir §9.5) +type Background = + | { mode: 'white' } + | { mode: 'tint'; color: string } // aplat clair + | { mode: 'color'; color: string }; // pleine couleur + +interface ProjectPage { + kind: 'project'; + projectId: string; + template: 'hero-text' | 'grid'; // templates définis en code + layout: string; // disposition d'images (single|mosaic|grid-2|…), voir §9.5 + mediaSelection: string[]; // filenames choisis + ordonnés (curation, sans recadrage) + subtitleOverride?: string; // sinon dérivé de taxonomy.category + textOverride?: string; // override ponctuel du texte (sinon = Grav) + background: Background; + options?: Record; // réglages mineurs éventuels du template +} + +interface FreePage { // page libre d'intro / texte + kind: 'free'; + template: 'free-text'; + title?: string; + body?: string; // markdown saisi dans l'UI + media?: string[]; // médias optionnels + background: Background; +} + +interface CoverConfig { // page de couverture + title: string; + subtitle?: string; + recipient?: string; // « préparé pour … » + backgroundImage?: string; + background: Background; +} + +interface TocPage { kind: 'toc'; title?: string; background: Background; } +``` + +### 7.3 « Live + figé à la génération » + +- Pendant l'édition, l'outil lit le **contenu live** (cache synchronisé) → les projets + reflètent l'état courant de Grav. +- À la **génération du PDF**, l'outil écrit un **`frozenContent`** dans le portfolio + sauvegardé : copie des textes, listes de médias et fichiers réellement utilisés. +- Conséquence : régénérer un ancien portfolio produit **le même PDF**, même si Grav a + changé depuis. Un bouton « Réactualiser depuis Grav » permettra de re-synchroniser + volontairement un portfolio avec le contenu à jour. + +--- + +## 8. Fonctionnalités détaillées + +### 8.1 Bibliothèque de projets +- Liste des projets par catégorie, avec vignette (basse résolution), titre, tags, date. +- Recherche + filtres (catégorie, tag). +- Sélection/désélection pour ajout au portfolio. + +### 8.2 Composeur de portfolio +- Colonne « pages du portfolio » (liste réordonnable en **glisser-déposer**). +- Ajout de pages : depuis un projet (choix du template) ou page spéciale (couverture, + sommaire, page libre). +- Panneau d'édition de la page sélectionnée : + - **choix du template** (parmi ceux définis en code) ; + - **curation des médias** : cocher/décocher + réordonner les visuels du projet + (aperçus basse résolution), **sans recadrage/zoom** ; + - **disposition (layout)** : choix parmi les layouts du template (§9.5) ; + - **texte** : pré-rempli depuis Grav, éditable (override local, sans toucher Grav) ; + - **fond de page** : blanc / teinté / pleine couleur (`background`) ; + - réglages mineurs du template le cas échéant. + +### 8.3 Pages spéciales / « pages types » +- **Couverture** : titre, sous-titre, destinataire, image de fond — personnalisables. +- **Sommaire** : généré automatiquement depuis les pages-projet (titres + n° de page). +- **Page libre d'intro** : titre + texte Markdown + médias optionnels, saisis dans l'UI. +- (Optionnel v1+) séparateurs de section, 4e de couverture. +- Ces pages sont elles aussi des **templates définis en code**, personnalisables via l'UI. + +### 8.4 Prévisualisation WYSIWYG +- Rendu à l'écran par **les mêmes composants Vue** que ceux utilisés pour le PDF. +- Ce que l'on voit = ce que produira le PDF (mêmes styles, même pagination logique). +- En prévisualisation, les images sont servies en **basse résolution** (fluidité). + +### 8.5 Génération PDF +- Nom de fichier personnalisable ; métadonnées PDF (titre/auteur) renseignées. +- Pipeline détaillé au §10. + +### 8.6 Gestion des portfolios sauvegardés +- Liste des portfolios (nom, date de MAJ, aperçu couverture). +- Actions : ouvrir, dupliquer, renommer, supprimer, régénérer le PDF. + +--- + +## 9. Système de templates + +### 9.1 Principe +- Templates = **composants Vue** dans `generator/components/templates/`. +- Un même composant sert **aperçu écran** et **rendu PDF** (source de vérité unique). +- Contrat d'entrée commun (props) pour être interchangeables dans le composeur. + +### 9.2 Contrat d'interface (props) +```ts +interface TemplateProps { + project?: Project; // pour les pages-projet + subtitle?: string; // sous-titre "type de travail" (dérivé de taxonomy.category) + media: ResolvedMedia[]; // médias curés, déjà résolus à la bonne résolution + layout?: string; // disposition d'images choisie (voir §9.5) + text?: string; // texte effectif (override ou Grav) + background: Background; // fond de page (blanc / teinté / pleine couleur) — voir §7.2 + page: { index: number; total: number }; + theme: FiguresLibresTheme; // charte (couleurs, fonts) — voir §9.3 + renderMode: 'preview' | 'print'; +} +``` + +> **Sous-titre projet** : dans les books existants, chaque page-projet porte un sous-titre +> « type de travail » (ex. « Identité visuelle et rapports d'activités », « Édition / +> principe de la collection »). Il sera dérivé de `taxonomy.category` (joignables) ou +> éditable en override. + +### 9.3 Charte Figures Libres (extraite du thème Grav `figureslibres-v2`) +- **Couleurs** (extraites du thème) : base claire `#f5f5f5`, encre `#0e1229` (bleu nuit). + Couleurs secondaires disponibles : `#ffaeab`, `#feff74`, `#71ff94`, `#fabbde`, `#4bffc9`, + à employer **avec parcimonie** (aplats de fond, filets). **Direction artistique sobre : + pas de formes graphiques décoratives ni d'effets « fluo ».** +- **Polices maison disponibles** (réutilisables, fichiers woff2 présents dans le thème) : + Avara (serif de titre), Moche, Syne, ManifontGrotesk, PlayfairDisplay, Rumeur, Lato. +- Les fonts seront embarquées dans l'app via `@font-face` pour un rendu identique en PDF. + +### 9.4 Format de page (à valider) +- **Format « écran » 16:9 paysage.** +- Recommandation : **canvas de référence 1280×720 pt** (ratio 16:9), avec les **images + redimensionnées à 2× leur boîte d'affichage** (≈ 150–200 dpi effectif) → netteté sans + surpoids. Le PDF garde du **texte vectoriel** (léger et net à tout zoom). +- `@page { size: 1280pt 720pt; margin: 0 }` côté CSS Paged.js (valeurs ajustables). +- 1920×1080 px reste la cible d'affichage logique ; la valeur pt ci-dessus en est + l'équivalent physique pratique pour le PDF. + +### 9.5 Templates de départ (v1) +1. **`hero-text` — Visuel dominant + texte** : un grand visuel + bloc titre/sous-titre/ + description (date, catégorie/tags, lien externe en pied). Le template « projet » de référence. +2. **`grid` — Grille / planche** : plusieurs médias d'un même projet en grille, titre discret. + +**Dispositions d'images (layouts).** L'analyse des books existants (voir §17) montre que +la page-projet réclame des **compositions variables**, pas une grille unique. La curation +médias inclut donc le choix d'une **disposition** parmi un jeu prédéfini, ex. : +- `single` : une image plein cadre (bord à bord ou avec marge) ; +- `mosaic` : mosaïque asymétrique (1 grande + plusieurs petites) ; +- `grid-2` / `grid-3` / `grid-6` : grilles régulières ; +- `image-text` : une image + un bloc de texte latéral. + +Ces layouts sont des variantes internes aux templates (props `layout` + `media`), pas des +templates séparés — ils partagent l'en-tête (titre + sous-titre) et la charte. + +> Les deux **books de référence** sont désormais dans `docs/portfolios-examples/`. Ce sont +> des **inspirations libres**, **pas des gabarits à reproduire** : ils sont en A3/A4 paysage +> (impression), alors que la cible est le **16:9 écran**. On s'en sert pour le vocabulaire de +> pages (§17) et l'esprit graphique, pas pour un pixel-perfect. + +--- + +## 10. Pipeline PDF & optimisation des images + +Objectif : **PDF léger (envoyable par mail) ET images irréprochables**. + +1. **Résolution des médias** : pour chaque image d'une page, calcul de sa **taille + d'affichage réelle** dans le template au format 16:9, puis génération (via **`sharp`**) + d'une version redimensionnée à `taille_affichage × 2` (retina). Les variantes + `@2x/@3x/@4x` de Grav servent de source haute résolution ; on **descend** vers la taille + utile plutôt que d'embarquer des images surdimensionnées. +2. **Rendu HTML** : Nuxt rend la page composée (mêmes composants que l'aperçu), + **Paged.js** gère la pagination (une page CSS = une page PDF). +3. **Impression** : **Playwright/Chromium** `page.pdf()` en mode `print`, format 16:9, + `printBackground: true`. +4. **Post-traitement** : passe **Ghostscript** (`gs`) pour recompresser/normaliser + (downsampling au DPI cible, compression JPEG maîtrisée) et alléger le fichier final. + Métadonnées PDF injectées (titre, auteur). +5. **Sortie** : `data/exports/.pdf`, proposé au téléchargement. + +> Les vidéos `.mp4` sont représentées par un **poster** (image d'aperçu) puisqu'un PDF est +> statique. + +--- + +## 11. Stack technique & arborescence + +> **Principe d'infrastructure : tout est conteneurisé.** Aucun runtime (Node, etc.) ni +> binaire système n'est installé sur l'hôte, qui héberge déjà de nombreux services. Node, +> le build, Playwright/Chromium et Ghostscript s'exécutent dans des conteneurs Docker. + +- **Front + API** : Nuxt 3 (Nitro pour les endpoints serveur), **Nuxt UI** pour les composants. +- **PDF** : Playwright (Chromium) + Paged.js. +- **Images** : sharp ; **Ghostscript** (dans le conteneur) pour l'optimisation PDF. +- **Parsing Grav** : lecture FS + parseur front-matter YAML + Markdown. +- **Synchro** : rsync/ssh (dans le conteneur), déclenché par un endpoint Nitro. + +Arborescence cible (dans ce dépôt, à côté de `grav-reference/` = Grav) : +``` +portfolio-maker/ + grav-reference/ ← Grav de référence (existant, renommé depuis app/) + generator/ ← nouvelle app Nuxt (code) + components/ + templates/ ← hero-text.vue, grid.vue, cover.vue, toc.vue, free-text.vue + composer/ ← UI de composition + server/ + api/ ← endpoints (projects, portfolios, sync, generate) + lib/ ← grav-reader, media-resolver, pdf-pipeline, content-source + data/ + content-cache/ ← contenu synchronisé (prod) — non versionné + portfolios/*.json ← compositions sauvegardées + exports/*.pdf ← PDF générés + nuxt.config.ts + docker/ + grav/Dockerfile ← (existant) image du Grav de référence + generator/Dockerfile ← image du générateur (base image Playwright + Ghostscript) + docker-compose.yml ← services grav-reference + generator + docs/ + spec-generateur-portfolios.md ← ce document +``` + +--- + +## 12. Sécurité & accès +- **Mot de passe partagé** unique (middleware d'auth simple + cookie de session). +- Accessible sur le LAN comme Grav (port dédié, ex. 3000/8090). +- Identifiants SSH de la prod stockés hors dépôt (env/secret), utilisés uniquement pour + rsync en lecture. + +## 13. Stockage +- **Portfolios** : un fichier JSON par portfolio (`data/portfolios/.json`). +- **Exports** : PDF dans `data/exports/`. +- **Cache contenu** : `data/content-cache/` (rsync). +- Tout dans un **volume Docker** → sauvegardable par simple copie de dossier. + +--- + +## 14. Roadmap par phases + +**Phase 0 — Fondations (dev sur Grav local)** +- App Nuxt + Nuxt UI, auth mot de passe. +- `ContentSource` mode DEV : lecture de `../grav-reference/user/pages/02.projets`. +- Parseur Grav (projets, médias, taxonomie, lien). +- Bibliothèque de projets (liste, filtres). + +**Phase 1 — Composition & aperçu** +- Modèle Portfolio + sauvegarde JSON. +- Composeur : ajout/réordonnancement de pages, choix template, curation médias, override texte. +- Templates `hero-text` et `grid` + charte Figures Libres. +- Prévisualisation WYSIWYG. + +**Phase 2 — Génération PDF** +- Pipeline Playwright + Paged.js au format 16:9. +- Optimisation images (sharp) + post-traitement Ghostscript. +- Pages spéciales : couverture, sommaire, page libre. +- Personnalisation (titre/sous-titre, nom de fichier, métadonnées). +- Gel du contenu à la génération (`frozenContent`). + +**Phase 3 — Passage prod & finitions** +- `ContentSource` mode PROD : rsync/SSH + bouton « Rafraîchir ». +- Conteneur Docker + intégration au `docker-compose`. +- Raffinement des templates à partir des PDF d'exemple. +- Gestion de bibliothèque (dupliquer, renommer, supprimer, régénérer). + +--- + +## 15. Décisions ouvertes / à confirmer + +1. **Format de page** : valider `1280×720 pt` (16:9) et le facteur retina ×2 pour les images + (§9.4), ou fournir des dimensions précises attendues. +2. **Sommaire** : numérotation de pages souhaitée ? niveau de détail (par projet, par + section) ? +3. **Plusieurs pages par projet** : les books montrent souvent 1 projet = 1 page, mais + parfois un projet riche s'étale. Confirmer si on autorise plusieurs pages pour un même + projet (actuellement : non, 1 projet = 1 page). +4. **Sous-titre projet** : dériver automatiquement de `taxonomy.category`, ou champ dédié + éditable dans l'UI ? (défaut proposé : dérivé + override.) +5. **Accès prod** : confirmer l'accès **SSH** (et non seulement FTP) pour rsync, + le + chemin exact de `user/pages` sur la prod. + +_Décidé_ : fonds de page **teintés/pleine couleur** proposés **dès la v1** (prop `background` += `white` | `tint` | `color`). Curation médias = **sélection + ordre + choix de layout**, +**sans** recadrage/zoom d'image en v1._ + +--- + +## 16. Annexe — Rappel de la structure de contenu observée + +- Projets sous `user/pages/02.projets/{01.culturelle,02.publique,03.sociale}//reader.md`. +- Médias à côté du `.md`, variantes `@2x/@3x/@4x`, quelques `.mp4`. +- Frontmatter clés utiles : `title`, `aura.image`, `media_order`, `taxonomy.category`, + `taxonomy.tag`, `date`, `published`. +- Le corps Markdown contient la description et, souvent, l'URL externe du projet. + +--- + +## 17. Annexe — Références d'inspiration (books existants) + +> ⚠️ **Non-normatif.** Les deux PDF de `docs/portfolios-examples/` +> (`book_2025_Bagneux.pdf`, 17 p. A4 paysage ; `book2025_FiguresLibres_edition.pdf`, +> 16 p. A3 paysage) sont des **books faits main**, fournis comme **inspiration libre**. +> Ils donnent l'**esprit** et le **vocabulaire de pages** attendus — **pas** des gabarits à +> reproduire au pixel. La cible reste le **16:9 écran**. + +### Taxonomie de pages observée (à couvrir par des "pages types") +| Type de page | Description observée | +|---|---| +| **Couverture** | Date + titre du dossier (client) + coordonnées collectif + grande image. Personnalisable. | +| **Présentation** | Grand titre (« 20 ans d'expérience… ») + colonnes de texte + image latérale pleine hauteur + logo en pastille. | +| **Équipe** | Grille 2 colonnes × N : portrait rond + nom/rôle en gras + bio. Peut couvrir 2 pages. | +| **Colonnes / infos** | Titre + 2–4 colonnes de texte à sous-titres, filet vertical coloré (« Outils et savoir-faire », « Commanditaires »). | +| **Intercalaire-titre** | Grand titre sur page quasi vide (« Portfolio Figures Libres »). | +| **Page-projet** | En-tête (titre + sous-titre "type de travail") + composition d'images variable (voir layouts §9.5) ± bloc texte ± fond teinté. | +| **4e de couverture** | Contact « Figures Libres.cc » + adresse + citation d'un membre. | + +### Grammaire graphique retenue comme inspiration +- **Logo** en pastille (cercle noir ou couleur). +- **Titres** grotesques géométriques arrondis (Syne / Moche) ; **texte courant** en Lato. +- **Fonds** blancs, gris très clairs, beige, ou **pleine couleur** selon la page/projet. +- En-tête projet **titre + sous-titre** systématique ; pagination + mention « FIGURES LIBRES » en pied. +- Compositions d'images **asymétriques et respirées** (une dominante + satellites), pas seulement des grilles régulières. + +--- + +## 18. Notes pour le développement autonome + +**Par où commencer.** Démarrer par la **Phase 0** sur le Grav local. Le CMS de référence est +déjà servi (`docker compose up -d`, http://localhost:8089) et son contenu est lisible dans +`../grav-reference/user/pages/02.projets/`. Aucune connexion prod n'est nécessaire pour les +phases 0 à 2. + +**Contraintes fermes.** +- **Ne jamais écrire dans `grav-reference/`** : c'est une source en **lecture seule**. +- **TOUT tourne dans des conteneurs Docker — rien n'est installé sur l'hôte.** Le serveur + héberge de nombreux services ; on n'y ajoute aucun runtime ni paquet. Node, npm/pnpm, le + build Nuxt, Playwright/Chromium et Ghostscript vivent **dans des conteneurs**. Toute + commande `node`/`npm`/`npx` s'exécute via un conteneur (ex. `docker compose run`), jamais + sur la machine. +- **Base recommandée** : image **officielle Playwright** (`mcr.microsoft.com/playwright`, + Node + Chromium + dépendances déjà présents), + Ghostscript ajouté dans l'image du service. +- **Ne rien installer sur le serveur ni lancer de `sudo` sans validation explicite de + l'utilisateur.** +- **FR uniquement.** **DA sobre** : pas de formes graphiques décoratives, pas d'effets « fluo ». + +**Conventions.** +- Un **template = un composant Vue piloté uniquement par ses props** (`TemplateProps`, + §9.2) : aucun accès direct au système de fichiers depuis un template → testable et + identique en `preview` et `print`. +- Toute lecture de contenu passe par l'abstraction **`ContentSource`** (§5) : brancher DEV + d'abord, PROD ensuite sans toucher au reste. +- Ghostscript et rsync (dans le conteneur) ne servent qu'aux phases 2–3 ; ne pas bloquer + les phases 0–1 dessus. + +**Outils disponibles pour l'agent.** +- **context7** (MCP branché) : doc à jour des libs (Nuxt 3, Nuxt UI, Playwright, Paged.js) — + s'en servir plutôt que deviner les APIs. +- **poppler-utils** : lire les PDF générés pour auto-vérifier le rendu et le comparer aux + books d'inspiration (§17). + +**Definition of done — Phase 0.** La bibliothèque liste les projets réels du +`grav-reference` (les 3 catégories), avec vignette basse résolution, titre, tags et date, +filtrables ; l'auth par mot de passe protège l'accès ; rien n'est écrit côté Grav.