Compare commits
74
Commits
develop
...
f1dbf21723
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1dbf21723 | ||
|
|
28a6daf382 | ||
|
|
bb8fad9ff3 | ||
|
|
b6143c5cf7 | ||
|
|
16d66072b9 | ||
|
|
8de9a81b65 | ||
|
|
44e9576b38 | ||
|
|
fb8c71fc59 | ||
|
|
8ccdda39a9 | ||
|
|
d2bc0d9e46 | ||
|
|
cdd3fa57de | ||
|
|
44dbcfd5cf | ||
|
|
26ce315aa6 | ||
|
|
64e714c1dd | ||
|
|
03f763ab57 | ||
|
|
230f79a8c0 | ||
|
|
89d5f83c7f | ||
|
|
4057f50bd3 | ||
|
|
c32d88bec1 | ||
|
|
a6ba0a6662 | ||
|
|
23b1767e67 | ||
|
|
852a4881a6 | ||
|
|
1c7cc455ab | ||
|
|
90f77da49b | ||
|
|
74ae8c2666 | ||
|
|
2144132c74 | ||
|
|
67cd869d83 | ||
|
|
201cfbbb50 | ||
|
|
4d8fe4895d | ||
|
|
e9fce4be8f | ||
|
|
c3ada04e52 | ||
|
|
38467bea10 | ||
|
|
da2a8aed49 | ||
|
|
e64d4cf003 | ||
|
|
483d226c8b | ||
|
|
069d206e94 | ||
|
|
5ec6474b37 | ||
|
|
98fab43fe5 | ||
|
|
a334c97434 | ||
|
|
84e843479d | ||
|
|
2f444ea7c7 | ||
|
|
8408fd6466 | ||
|
|
dcf64cc924 | ||
|
|
f6339400fa | ||
|
|
8fd000a153 | ||
|
|
95f82e4fee | ||
|
|
a038851895 | ||
|
|
efed0b9033 | ||
|
|
99a8b84164 | ||
|
|
b9e6b4a96c | ||
|
|
c52fa6007d | ||
|
|
d4ff840e13 | ||
|
|
7691cc4139 | ||
|
|
088333e4d4 | ||
|
|
07449de187 | ||
|
|
9d92c4a5b3 | ||
|
|
8f548afb17 | ||
|
|
9caf11c8ab | ||
|
|
c564e7be5f | ||
|
|
3a5c40a886 | ||
|
|
fbc2867163 | ||
|
|
082a17d09b | ||
|
|
97ba6dd04c | ||
|
|
f0338cca5e | ||
|
|
c6b9abf2f3 | ||
|
|
df0409fec3 | ||
|
|
1af00cc64c | ||
|
|
8a38c86794 | ||
|
|
17f39e735d | ||
|
|
7ea19e2247 | ||
|
|
9525ed3953 | ||
|
|
b02368a15b | ||
|
|
1f47533c77 | ||
|
|
07bf07a942 |
@@ -1,3 +1,9 @@
|
||||
# Admin authentication
|
||||
NUXT_ADMIN_PASSWORD=changeme
|
||||
NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars
|
||||
|
||||
# Umami analytics — hébergé sur stats.open.us.org
|
||||
# NUXT_UMAMI_API_KEY : Settings → API Keys dans l'interface Umami (pour /api/stats)
|
||||
NUXT_PUBLIC_UMAMI_URL=https://stats.open.us.org
|
||||
NUXT_PUBLIC_UMAMI_WEBSITE_ID=95ff616d-9ce1-47d9-bca2-f6ddc344a99a
|
||||
NUXT_UMAMI_API_KEY=
|
||||
|
||||
@@ -18,9 +18,16 @@ logs
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# PDF.js (copié depuis node_modules par postinstall)
|
||||
public/pdfjs/pdf.min.mjs
|
||||
public/pdfjs/pdf.worker.min.mjs
|
||||
|
||||
# Sources originales (PDF, JPG — pas servies par l'appli)
|
||||
sources/
|
||||
|
||||
# Runtime data (Docker volume — never committed)
|
||||
data/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -2,71 +2,209 @@
|
||||
|
||||
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
|
||||
|
||||
- **Rôle** : site éditorial en production sur `librodrome.org` et hub d'intégration
|
||||
de l'écosystème (GrateWizard en popup ; liens libreDecision
|
||||
`decision.librodrome.org`, SejeteralO, sweetHome/LaPlanK dans le YAML de `site/`).
|
||||
Cap : plateforme éditoriale de bassin de vie (autonomie collective, économie
|
||||
monnaie libriste, boîte à outils, événementiel).
|
||||
- **Port** : 3000 (dev), 3099 (smoke du build). **Dépôt** : `origin` =
|
||||
`gitea@git.open.us.org:EHV/librodrome.git` ; `main` = production.
|
||||
|
||||
## Protocole de début de session
|
||||
|
||||
1. `git pull --rebase origin main` (règle canon ; voir § Contenu administrable)
|
||||
2. Vérifier que `data/messages.yml` existe — si absent, signaler avant toute opération
|
||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||
|
||||
## Stack
|
||||
|
||||
- **Nuxt 4** (Vue 3, TypeScript, Nitro)
|
||||
- **Modules** : Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image
|
||||
- **Icônes** : Lucide + Phosphor (via @iconify-json)
|
||||
- **Package manager** : pnpm
|
||||
- **Déploiement** : Docker + Traefik, CI via Woodpecker
|
||||
- Nuxt 4 (Vue 3, TypeScript, Nitro) + Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image
|
||||
- Layer `@yvv/nuxt-base` (`extends` dans `nuxt.config.ts`, dépendance `git+https` épinglée `#v0.1.0`)
|
||||
- Icônes : Lucide + Phosphor (via @iconify-json) ; pnpm épinglé 10.29.3 (`packageManager`)
|
||||
- Audience : Umami (`nuxt-umami`)
|
||||
- Déploiement : Woodpecker (`.woodpecker.yml`) — push sur `main` →
|
||||
`docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml up --build -d`,
|
||||
**sans étape de test**. Secrets CI : `NUXT_ADMIN_PASSWORD`, `NUXT_ADMIN_SECRET`.
|
||||
Healthcheck de l'image : `/api/health`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
app/
|
||||
pages/ # Routes : index, lire/, ecouter/, autonomie, evenement, gratewizard, a-propos, admin/
|
||||
components/ # admin, book, content, home, layout, player, song, ui
|
||||
composables/ # useAudioPlayer, useBookData, useGrateWizard, usePlaylist, usePageContent, useSiteContent...
|
||||
assets/css/ # main.css (UnoCSS)
|
||||
pages/
|
||||
numerique/ # index + [slug]
|
||||
economique/ # index, monnaie-libre, commande, productions-collectives
|
||||
modele-eco/ # sommaire + chapitres [slug]
|
||||
citoyenne/ # index + [slug]
|
||||
en-musique/ # player audio
|
||||
admin/ # back-office (pages/, book/, songs, messages, media)
|
||||
components/
|
||||
book/Actions.vue # boutons partagés (player, PDF, chapitres, commande)
|
||||
home/ # BookSection, AxisBlock, AxisGrid, HeroSection, HomeMessages
|
||||
admin/, player/, song/, ui/
|
||||
composables/ # useAudioPlayer, useBookData, useGrateWizard, usePageContent
|
||||
stores/palette.ts # 4 palettes saisonnières (été par défaut, persisté localStorage)
|
||||
assets/css/main.css # UnoCSS + overrides light mode
|
||||
site/
|
||||
pages/ # Contenu YAML administrable par section (sous-dossiers)
|
||||
site.yml # Config globale (nav, footer, GrateWizard)
|
||||
bookplayer.config.yml # Config player/chapitres
|
||||
data/
|
||||
pages/ # Contenu YAML par page (home, lire, ecouter, autonomie, evenement...)
|
||||
site.yml # Config globale du site
|
||||
messages.yml # Runtime — prod : volume nommé librodrome-data sur /app/data — JAMAIS dans git
|
||||
server/
|
||||
api/ # Endpoints API (admin, health)
|
||||
middleware/ # Auth middleware
|
||||
api/content/ # GET pages YAML (chemins imbriqués)
|
||||
api/admin/content/ # PUT pages YAML + liste
|
||||
api/messages/ # GET (publiés) + POST (nouveau message)
|
||||
api/admin/messages/ # GET tous + PUT (type, reply, published) + DELETE
|
||||
utils/content.ts # readDataYaml/writeDataYaml (data/) + readYaml/writeYaml (site/)
|
||||
middleware/redirects.ts # 301 : /gestation, /modele-eco, /decision, /lire
|
||||
docker/
|
||||
Dockerfile # Build multi-stage (dev + prod)
|
||||
docker-compose.yml # Production (Traefik)
|
||||
docker-compose.dev.yml # Dev Docker
|
||||
Dockerfile, docker-compose.yml, docker-compose.dev.yml
|
||||
```
|
||||
|
||||
## Ports dev (CRITIQUE)
|
||||
## Données runtime (CRITIQUE)
|
||||
|
||||
Chaque projet a un port fixe pour éviter les conflits Nuxt auto-increment :
|
||||
|
||||
| Projet | Port | Config |
|
||||
|--------|------|--------|
|
||||
| **librodrome** | **3000** | `nuxt.config.ts` → `devServer.port: 3000` |
|
||||
| **GrateWizard** | **3001** | `package.json` → `next dev --port 3001` |
|
||||
| **SejeteralO frontend** | **3009** | `frontend/nuxt.config.ts` → `devServer.port: 3009` |
|
||||
| **SejeteralO backend** | **8000** | Makefile → `uvicorn --port 8000` |
|
||||
|
||||
Script de gestion : `/home/yvv/Documents/PROD/DEV/dev-ports.sh` (status/kill/clean/start)
|
||||
|
||||
**Ne jamais changer ces ports.** Si un port est occupé, tuer le process parasite plutôt que laisser Nuxt auto-incrémenter.
|
||||
- `data/messages.yml` : en prod, volume nommé `librodrome-data` (`external: true`) monté
|
||||
sur `/app/data` (l'image tourne dans `/app`, le serveur lit `process.cwd()/data`).
|
||||
Créé et semé à la main sur sonic par l'acte `actes/2026-10-26-persistance-messages.md` ;
|
||||
un déploiement sans ce volume échoue au lieu de repartir de la graine.
|
||||
- Persisté entre les rebuilds depuis le 26/10/2026 — **jamais écrasé par les commits ni
|
||||
par le déploiement** (avant, il ne l'a jamais été : voir le dossier d'acte)
|
||||
- Structure message : `{ id, author, email, text, type, published, createdAt, reply: { text, publishedAt } | null }`
|
||||
- Types : `reaction` (ancien, affiché "Réaction", plus proposé dans les formulaires) | `question` | `suggestion` | `retour`
|
||||
- En dev local : `<racine>/data/messages.yml`
|
||||
- Graine : `site/messages.yml` (versionné) copié par `server/plugins/seed-messages.ts`
|
||||
si `data/messages.yml` est absent
|
||||
- ⚠ Hors volume, rien ne persiste : éditions /admin de `site/` et `content/`, médias
|
||||
envoyés par /admin (`/app/public`, jamais servis). Décisions d'Yvv en attente (HANDOFF).
|
||||
- **Avant toute migration de chemin ou écriture sur data/ : demander confirmation**
|
||||
|
||||
## Intégration GrateWizard
|
||||
|
||||
- URL dev configurée dans `app/app.config.ts` → `localhost:3001`
|
||||
- URL prod : `https://gratewizard.ml`
|
||||
- Ouverture en popup via `composables/useGrateWizard.ts`
|
||||
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
|
||||
- URL dev : `app/app.config.ts` → `localhost:3001`
|
||||
- URL prod : `https://gratewizard.axiom-team.fr`
|
||||
|
||||
## Contenu
|
||||
## Contenu administrable
|
||||
|
||||
Le contenu des pages est dans `data/pages/*.yml` et chargé via `composables/usePageContent.ts`. Le contenu riche (articles) utilise Nuxt Content avec des fichiers Markdown.
|
||||
- YAML dans `site/pages/` par section ; API supporte les chemins imbriqués
|
||||
- Admin : `/admin/pages` liste, `/admin/pages/{path}` édite en YAML
|
||||
- Git sync (`server/utils/gitSync.ts`, actif si `ADMIN_GIT_SYNC=true`) : commit
|
||||
`[admin] …` puis push depuis le conteneur → `git pull --rebase` obligatoire en début de session
|
||||
- ⚠ Constat du 26/10/2026 : la CI ne fixe pas `ADMIN_GIT_SYNC` (défaut `false` dans le
|
||||
compose), l'image de prod n'embarque pas `.git`, et l'historique ne contient aucun
|
||||
commit `[admin]`. Les éditions /admin en prod ne reviennent donc pas dans git
|
||||
(voir HANDOFF).
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
pnpm dev # Dev server sur :3000
|
||||
pnpm dev # Dev server :3000
|
||||
pnpm build # Build production
|
||||
pnpm generate # Génération statique
|
||||
PORT=3099 node .output/server/index.mjs # Test build prod (toujours avant commit)
|
||||
```
|
||||
|
||||
## Vérifier
|
||||
|
||||
Niveaux V0 à V4 : gabarit `harness/skills/boucles/_gabarit.md`. Seuls V0 et V3 existent :
|
||||
aucun test automatisé, ni lint ni typecheck (aucun script, pas de `vue-tsc`).
|
||||
|
||||
**V0 — build + smoke** (vert le 26/10/2026, build ≈ 1 min)
|
||||
```bash
|
||||
date; pnpm build # exit 0
|
||||
NUXT_ADMIN_PASSWORD=jetable NUXT_ADMIN_SECRET=jetable PORT=3099 node .output/server/index.mjs &
|
||||
sleep 3; for p in / /api/health /api/admin/messages; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" http://127.0.0.1:3099$p; done # 200, 200, 401
|
||||
kill $(lsof -ti :3099)
|
||||
```
|
||||
Lancé depuis la racine, le serveur lit et écrit le vrai `data/` : aucun POST pendant le
|
||||
smoke. Le build ne lit pas `.env` : sans `NUXT_ADMIN_SECRET`, l'API admin répond 503.
|
||||
|
||||
**V1 — invariants (à construire)** : vitest sur les API messages (POST/GET/DELETE),
|
||||
admin content (PUT YAML → fichier écrit, format préservé), auth (mauvais mot de passe →
|
||||
401). `data/` et `site/` se résolvent depuis `process.cwd()` : lancer depuis un dossier jetable.
|
||||
|
||||
**V2 — parcours (à construire)** : Playwright, chromium bundlé (HANDOFF, Pièges), mobile +
|
||||
desktop, ambiances claire et sombre : accueil sans erreur console, les 3 axes, lecteur PDF,
|
||||
player, formulaire message (data jetable), login admin. Captures dans `/tmp/librodrome-shots/`.
|
||||
|
||||
**V3 — données**
|
||||
```bash
|
||||
sha256sum data/messages.yml # avant et après le run : identiques
|
||||
git status --short # rien sous data/ ni .env
|
||||
git log --oneline origin/main..HEAD # ce que le prochain push mettrait en ligne
|
||||
```
|
||||
|
||||
**V4** : rapport avec sorties réelles et captures ; tout niveau sauté est annoncé.
|
||||
|
||||
**Trous** : la CI déploie sans gate (un commit cassé part en prod) ; V1 et V2 inexistants.
|
||||
Prochaines briques : gate build + tests dans `.woodpecker.yml` avant le `up`, puis V1 et V2
|
||||
exposés en `pnpm verify`.
|
||||
|
||||
**Gates humains (anneau 1)** : push de `main` (= mise en ligne) ; suppressions admin
|
||||
(`server/api/admin/messages/[id].delete.ts`, `media/[...path].delete.ts`,
|
||||
`chapters/[slug].delete.ts`) ; toute écriture sous `site/` ou `content/book/` (contenu de
|
||||
prod) ; `data/messages.yml` ; lien de commande du livre (Bookelis,
|
||||
`site/pages/economique/commande.yml`, aucun paiement sur le site) ; auth admin
|
||||
(`server/middleware/admin-auth.ts`) et secrets `NUXT_ADMIN_*`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Langue du site : français
|
||||
- Commits en français, style concis
|
||||
- CSS via UnoCSS (utility-first), pas de framework CSS externe
|
||||
- Composants Vue SFC avec `<script setup lang="ts">`
|
||||
- **Consignes d'Yvv**, datées et mot pour mot : `docs/consignes-yvv.md`. Les relire avant de
|
||||
toucher au design ; y inscrire toute nouvelle consigne le jour où elle est donnée.
|
||||
- CSS via UnoCSS + variables CSS palettes ; composants Vue SFC `<script setup lang="ts">`
|
||||
- Oiseaux du kit du studio DA (depuis le 03/11/2026, à la place des shadoks) : `UiOiseau`,
|
||||
`app/utils/oiseaux.ts` (copiés tels quels du kit), posés par `UiOiseauPose` près du contenu
|
||||
de leur métier, jamais en rangée. Règles du kit : currentColor, au moins 72 px, opacité
|
||||
0,18–0,28 près du texte et 0,10–0,14 derrière une carte, un oiseau jamais deux fois sur une
|
||||
page, un seul sous 768 px, pas de miroir pour calligraphe et typographe. Consignes :
|
||||
`docs/consignes-yvv.md` § Les oiseaux.
|
||||
- Hexagramme 益 (#42 Yi, Augmentation) dans `layouts/default.vue`
|
||||
- Logotype validé du studio DA (`UiLogotype`) dans `TheHeader.vue`, et `UiSectionSign` du
|
||||
kit — ne pas modifier sans demander. Source du kit :
|
||||
`/home/yvv/Documents/PROD/IMAGES/_atelier/2026-10-27-librodrome-logo/livrables/kit-site/`.
|
||||
|
||||
## SEO & Recherche IA — Checklist permanente
|
||||
|
||||
### À chaque nouvelle page Vue
|
||||
|
||||
1. **Appeler `useSeoPage()`** — jamais `useHead({ title })` seul
|
||||
```ts
|
||||
useSeoPage({
|
||||
title: 'Titre page — Le Librodrome', // 50–60 chars
|
||||
description: 'Description...', // 120–155 chars
|
||||
image: '/images/og-specifique.jpg', // optionnel, sinon og-default.png (logo §)
|
||||
type: 'website' | 'article' | 'book', // défaut: website
|
||||
})
|
||||
```
|
||||
2. **og:image** : utiliser la couverture `/images/Couv-Economie-du-don.jpg` pour les pages livre/musique ; `og-default.png` (logo §) pour les pages site générales
|
||||
3. **type: 'article'** pour les chapitres du modele-eco ; **type: 'book'** pour les index livre
|
||||
|
||||
### À chaque nouveau fichier YAML dans `site/pages/`
|
||||
|
||||
Ajouter le bloc `seo:` en tête :
|
||||
```yaml
|
||||
seo:
|
||||
title: "Titre SEO — 50-60 chars max"
|
||||
description: "Description 120-155 chars, avec mots-clés naturels"
|
||||
# image: /images/og-specifique.jpg # optionnel
|
||||
# keywords: [monnaie libre, TRM, économie du don]
|
||||
```
|
||||
|
||||
### Règles description SEO
|
||||
|
||||
- Commence par un verbe d'action ou une affirmation forte
|
||||
- Contient les mots-clés : économie du don, monnaie libre, TRM, June, bassin de vie, autonomie
|
||||
- 120–155 caractères (ni trop court, ni tronqué)
|
||||
- Différente du titre et du H1
|
||||
|
||||
### JSON-LD automatique
|
||||
|
||||
- JSON-LD `Organization` + `Book` : injecté globalement dans `app.vue` — ne pas redupliquer
|
||||
- Chapitres → og:type `article` via `useSeoPage({ type: 'article' })`
|
||||
- Sitemap : géré par `@nuxtjs/sitemap` — ajouter manuellement les nouvelles routes statiques dans `nuxt.config.ts > sitemap.urls`
|
||||
|
||||
### Analytics — Nouveaux composants
|
||||
|
||||
- Tout bouton CTA externe : `trackCta(label, url)` depuis `useTracking()`
|
||||
- Tout `<a target="_blank">` : capturé automatiquement par `useScrollTracking()` dans `default.vue`
|
||||
- Tout nouveau lecteur média : appeler `trackAudioPlay` / `trackPdfOpen` depuis `useTracking()`
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Contributing — Le Librodrome
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
pages/
|
||||
numerique/ # Section autonomie numérique
|
||||
index.vue # Vue d'ensemble (3 piliers)
|
||||
[slug].vue # Détail pilier (contenu YAML)
|
||||
economique/ # Section autonomie économique
|
||||
index.vue # Vue d'ensemble (monnaie libre, livre, productions)
|
||||
monnaie-libre.vue
|
||||
commande.vue # Commande du livre (Bookelis + librairie)
|
||||
productions-collectives.vue
|
||||
modele-eco/
|
||||
index.vue # Couverture livre + sommaire chapitres
|
||||
[slug].vue # Lecteur de chapitre (Nuxt Content)
|
||||
citoyenne/ # Section autonomie citoyenne
|
||||
index.vue # Vue d'ensemble (decision, tarifs-eau)
|
||||
[slug].vue # Détail (contenu YAML)
|
||||
en-musique/ # Player audio
|
||||
evenement.vue
|
||||
admin/ # Back-office protégé
|
||||
pages/
|
||||
index.vue # Liste toutes les pages YAML
|
||||
[...path].vue # Éditeur YAML (supporte les sous-dossiers)
|
||||
components/
|
||||
book/Actions.vue # Boutons partagés du livre (player, PDF, chapitres, commande)
|
||||
home/BookSection.vue # Bloc couverture + CTAs (utilise BookActions)
|
||||
home/AxisBlock.vue # Bloc axe sur la home (supporte actions lien via `to`)
|
||||
home/AxisGrid.vue # Grille 3 axes + GrateWizard
|
||||
stores/palette.ts # 4 palettes saisonnières (été par défaut)
|
||||
```
|
||||
|
||||
## Contenu YAML
|
||||
|
||||
API : `GET /api/content/pages/{path}` — supporte les chemins imbriqués (`numerique/logiciel-libre`).
|
||||
|
||||
Admin : `PUT /api/admin/content/pages/{path}` + `GET /api/admin/content/pages` (liste).
|
||||
|
||||
Les fichiers YAML sont dans `site/pages/`, organisés en sous-dossiers par section.
|
||||
|
||||
## Palettes
|
||||
|
||||
4 palettes dans `stores/palette.ts` : automne, hiver, printemps, été.
|
||||
- Défaut : **été** (premier visiteur)
|
||||
- Persisté en `localStorage('palette')` pour les visites suivantes
|
||||
- CSS : variables `--color-primary`, `--color-accent`, etc. + classe `.palette-light`/`.palette-dark`
|
||||
- Mécanique DOM (injection des variables, classes `mood-*`/`palette-*`, `color-scheme`, persistance) déléguée à `useMood` du layer `../yvv-nuxt-base` (`extends` dans `nuxt.config.ts`) — les couleurs restent définies ici. ⚠️ le layer est hors du repo : build Docker/CI cassera tant que la stratégie de distribution n'est pas tranchée (voir README du layer)
|
||||
- Overrides light mode dans `main.css` (`.palette-light .text-white` → couleur adaptive)
|
||||
- Admin : tous les `color: white` dans les composants admin sont remplacés par `hsl(var(--color-text))` sauf les boutons sur fond `hsl(var(--color-primary))` (`AdminSaveButton`, `.login-btn`)
|
||||
|
||||
## Navigation contextuelle — pages détail
|
||||
|
||||
`[slug].vue` (numérique, citoyenne) embarque :
|
||||
- **Prev/next** (`ctx-nav`) en haut de page — liens vers les pages adjacentes dans la section
|
||||
- **Sommaire flottant** (`.sommaire-sidebar`, `position: sticky`) dans un aside absolu à droite — visible ≥ 1300px, `overflow: clip` sur le parent pour ne pas casser le sticky
|
||||
|
||||
Les ancres sont des `<div :id="\`s${si}\`" class="section-anchor">` avec `scroll-margin-top: 5.5rem` (offset header fixe).
|
||||
|
||||
## Types de sections YAML
|
||||
|
||||
`[slug].vue` supporte : `arguments`, `fiche`, `equivalents`, `llm`, `atelier`, `links`, `insight`, `tiers`, `projet`, `territoire`.
|
||||
|
||||
`type: territoire` : économies alternatives (euro/Ğ1), grille bouquet de services, tableau matériel dépliable (`<details>`).
|
||||
`type: projet` : carte gestation avec grille de features.
|
||||
|
||||
## Shadoks
|
||||
|
||||
Illustrations SVG inline sur chaque page, thématiques par section :
|
||||
- Numérique index : métiers tech/industrie (codeuse, électricien, soudeuse, cryptographe...)
|
||||
- Numérique détail : postures geek (pinguin+USB, web-of-trust, rubber duck, caféine, rack serveur, debugger loupe)
|
||||
- Économique : artisanat/agriculture (boulangère, potier, forgeron...)
|
||||
- Citoyenne : navigation/justice/théâtre (capitaine, avocate, comédien...)
|
||||
- Modèle éco : livre/édition (typographe, calligraphe, conteuse...)
|
||||
- Événement : spectacle (funambule, accordéoniste, jongleur...)
|
||||
|
||||
Règles : corps compact, longues pattes, grands pieds plats, bec pointu, postures variées.
|
||||
CSS : `position: absolute`, `pointer-events: none`, `opacity 0.18-0.28`, animation float, `display: none` sur mobile.
|
||||
Parent avec `overflow: clip` (pas `overflow: hidden` qui casserait `position: sticky`).
|
||||
|
||||
## Analytics
|
||||
|
||||
`app/composables/useTracking.ts` — wrapper Umami. Module `nuxt-umami` configuré dans `nuxt.config.ts` (host : `stats.open.us.org`).
|
||||
`server/api/stats/index.get.ts` — endpoint public stats (nécessite `NUXT_UMAMI_API_KEY`).
|
||||
|
||||
## Redirections
|
||||
|
||||
`server/middleware/redirects.ts` :
|
||||
- `/gestation/*` → section appropriée
|
||||
- `/modele-eco/*` → `/economique/modele-eco/*`
|
||||
- `/decision` → `/citoyenne/decision`
|
||||
- `/lire/*` → `/economique/modele-eco/*`
|
||||
- `/ecouter/*` → `/en-musique/*`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Commits en français, concis
|
||||
- Build prod vérifié avant push (`PORT=3099 node .output/server/index.mjs`)
|
||||
- Port dev : 3000 (fixe, jamais de fallback)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# HANDOFF — librodrome
|
||||
|
||||
## ÉTAT — 2026-10-26 vers 19h50
|
||||
|
||||
**En ligne** : `4057f50` (code de `a6ba0a6`), les messages des visiteurs sont persistés dans le volume
|
||||
nommé `librodrome-data` sur `/app/data`. Acte exécuté le 26/10, 19h38-19h44, par la
|
||||
session dev-65 (orch), et vérifié : sauvegarde, veille, bascule, redémarrage à froid,
|
||||
1 message conservé, 0 graine rejouée. Dossier : `actes/2026-10-26-persistance-messages.md`.
|
||||
|
||||
**Restent à Yvv** (dossier, § Hors champ) :
|
||||
1. Éditions /admin (`site/`, `content/`), toujours perdues à chaque déploiement :
|
||||
/admin en lecture seule, git sync rétabli, ou volumes (déconseillé).
|
||||
2. Médias /admin : écrits dans `/app/public`, jamais servis.
|
||||
3. Sauvegarde de sonic `~/librodrome-sauvegardes/2026-10-26/` (vraies données de
|
||||
visiteurs) : la garder, ou la supprimer après un déploiement suivant réussi (« supprime »).
|
||||
4. Canon démenti : `/DEV/AGENTS.md` (Démarrage §3) et `harness/conventions/git.md`
|
||||
disent encore que librodrome écrit du YAML en prod via /admin et git sync.
|
||||
|
||||
**Preuve en vraie grandeur, faite** : déploiement suivant (`4057f50`, doc seule, pipeline
|
||||
n° 59, sur le « Déploie » d'Yvv) : conteneur recréé, `messages.yml` inchangé
|
||||
(`c8afc7de…a6b8`), 0 graine rejouée.
|
||||
|
||||
**Local, non poussé** : le commit qui consigne cette preuve (doc seule) ; il part avec
|
||||
le prochain déploiement.
|
||||
|
||||
## REPRISE — bascule quota 2026-09-29 14h05
|
||||
|
||||
**Fait, en production** (librodrome.org, déployé et vérifié ; armé par Yvv) :
|
||||
- `67cd869` : refonte. Accueil = console des trois curseurs, seul accueil
|
||||
(`app/components/home/HomeLanding.vue`, `HomeConsole.vue`,
|
||||
`useHomeData.ts`) ; § Playfair (`UiSectionSign`) ; statuts
|
||||
(`UiStatusBadge`) ; LaPlanK, sweetHome, bouquet aligné sur les stacks
|
||||
HCO/LaPlanK ; correctifs du lecteur (pastille, MP3 direct).
|
||||
- `90f77da` : image OG et favicon avec le §, logo JSON-LD corrigé.
|
||||
- `1c7cc45`, `852a488` et `23b1767` (doc) sont partis avec `a6ba0a6` le 26/10.
|
||||
Dossiers : `actes/2026-09-29-*.md`.
|
||||
|
||||
**Prochaine action** : aucune en cours. Attendre les retours d'Yvv sur la prod.
|
||||
|
||||
**Décisions en attente d'Yvv** :
|
||||
- Qui opère cloud.laplank.org : `vision.md` dit LaPlanK, la fiche sweetHome
|
||||
dit warren. Le site suit Yvv (LaPlanK).
|
||||
- Twenty, ERPNext et budgetPilot sont affichés « en service » dans le bouquet
|
||||
public alors qu'ils servent HCO en interne. Les garder ou passer « au catalogue » ?
|
||||
- Ambiance par défaut « été » (crème + terre cuite) : proposer une autre ?
|
||||
- Lien « Mentions légales » du pied de page → page inexistante (défaut antérieur).
|
||||
- Persistance de la prod (constat du 26/10/2026, d'après les fichiers ; non
|
||||
vérifié sur le serveur) : voir `CLAUDE.md`, § Données runtime et § Contenu
|
||||
administrable. Si l'effet se confirme, chaque déploiement repart d'un conteneur
|
||||
neuf : messages visiteurs re-semés depuis `site/messages.yml`, éditions /admin
|
||||
perdues. Rétablir les montages (`/app/...`) et décider du sort du git sync
|
||||
touche aux données de prod : à trancher par Yvv avant le prochain déploiement.
|
||||
→ **26/10/2026, vérifié sur sonic : confirmé**, puis corrigé et mis en ligne le même
|
||||
jour (voir ÉTAT ci-dessus).
|
||||
|
||||
**Ce qui tourne** : rien (serveur de dev sur 3000 arrêté).
|
||||
|
||||
**Pièges** :
|
||||
- Pousser `main` = mise en ligne sans test → mot « déploie », dossier dans `actes/`.
|
||||
- `git pull --rebase` avant toute modification (le YAML de prod est édité via /admin).
|
||||
- Serveur de dev : après des suppressions ou renommages de composants, le SSR
|
||||
reste périmé (faux mismatch d'hydratation) → relancer `pnpm dev`.
|
||||
- Chromium de Playwright MCP absent : utiliser le chromium bundlé
|
||||
`~/.cache/ms-playwright/chromium-1208` + playwright-core
|
||||
(`/tmp/bp-design/node_modules`). Scripts jetables dans `/tmp/lbshot/`.
|
||||
- Le BookPlayer ferme sur Échap après une animation de sortie de 0,35 s :
|
||||
attendre avant d'asserter.
|
||||
- Statuts : champ YAML `status` (+ `since`), qui prime sur `gestation: true`.
|
||||
- Libellés de la console dans `home.yml > console` ; titre de la boîte à
|
||||
outils dans `home.yml > toolbox`.
|
||||
@@ -1,75 +1,72 @@
|
||||
# Nuxt Minimal Starter
|
||||
# Le Librodrome
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
|
||||
|
||||
## Setup
|
||||
## Navigation
|
||||
|
||||
Make sure to install dependencies:
|
||||
Le site est organisé en 3 grandes sections :
|
||||
|
||||
| Section | Route | Contenu |
|
||||
|---------|-------|---------|
|
||||
| **Numérique** | `/numerique` | Autonomie numérique : logiciel libre, authentification WoT, cloud libre |
|
||||
| **Économique** | `/economique` | Création monétaire, monnaie libre, modèle économique (livre + chapitres), productions collectives |
|
||||
| **Citoyenne** | `/citoyenne` | Décision collective (Glibredecision), tarifs de l'eau (SejeteralO) |
|
||||
|
||||
Autres pages : `/en-musique` (player audio), `/evenement`, `/a-propos`, `/messages`.
|
||||
|
||||
Chaque section a une page index et des sous-pages de détail (`/numerique/logiciel-libre`, `/economique/modele-eco`, etc.).
|
||||
|
||||
## Le livre
|
||||
|
||||
- **Lire** : lecteur PDF intégré, chapitres Markdown sous `/economique/modele-eco/[slug]`
|
||||
- **Écouter** : player audio avec 9 morceaux sous `/en-musique`
|
||||
- **Commander** : page `/economique/commande` (Bookelis + librairie)
|
||||
|
||||
## Stack
|
||||
|
||||
- **Nuxt 4** (Vue 3, TypeScript, Nitro)
|
||||
- **UnoCSS** (utility-first) + palettes saisonnières (été par défaut)
|
||||
- **Nuxt Content** pour les chapitres du livre
|
||||
- **Pinia** pour l'état (palette, player)
|
||||
- **pnpm** comme package manager
|
||||
|
||||
## Contenu administrable
|
||||
|
||||
Le contenu des pages est dans `site/pages/` en YAML, organisé par section :
|
||||
|
||||
```
|
||||
site/pages/
|
||||
home.yml # Page d'accueil
|
||||
numerique.yml # Index numérique
|
||||
numerique/*.yml # Sous-pages
|
||||
economique.yml # Index économique
|
||||
economique/*.yml # Sous-pages (modele-eco, monnaie-libre, commande...)
|
||||
citoyenne.yml # Index citoyenne
|
||||
citoyenne/*.yml # Sous-pages (decision, tarifs-eau)
|
||||
en-musique.yml, evenement.yml, gratewizard.yml
|
||||
```
|
||||
|
||||
Administration via `/admin/pages` (éditeur YAML, authentifié).
|
||||
|
||||
Le hero de la home (`home.yml`) supporte deux blocs dépliables :
|
||||
- `approach` + `axes` : approche par dimension (numérique → code source, etc.)
|
||||
- `audience` + `addressees` : à qui s'adresse le projet (collectifs, entreprises, collectivités)
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
pnpm dev # Dev server sur :3000
|
||||
pnpm build # Build production
|
||||
```
|
||||
|
||||
## Development Server
|
||||
Port réservé : **3000** (ne pas changer).
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
## Analytics
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
Umami hébergé sur `stats.open.us.org`. Module `nuxt-umami` configuré dans `nuxt.config.ts`.
|
||||
Stats publiques exposées via `/api/stats` (nécessite `NUXT_UMAMI_API_KEY`).
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
## Déploiement
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
Docker + Traefik, CI via Woodpecker. Domaine : `librodrome.org`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Acte — mise en ligne de l'image de partage et du favicon au vrai §
|
||||
Classe : déploie (push de main = mise en ligne de librodrome.org)
|
||||
Projet : librodrome Date : 2026-09-29
|
||||
Armé le 2026-09-29 par Yvv : « Déploie. […] Vérifie juste l'og. »
|
||||
|
||||
## Intention
|
||||
Remplacer le § « spaghetti » partout où il restait : image OG, favicon,
|
||||
fichiers orphelins ; corriger l'URL du logo JSON-LD (404).
|
||||
|
||||
## Commandes exactes
|
||||
```bash
|
||||
git push origin 90f77da49b1c77faf9807ba48740b0abd22aff46:main
|
||||
```
|
||||
|
||||
## Contenu sortant
|
||||
Commit og (app/app.vue, useSeoPage.ts, public/og-default.png, favicon.ico,
|
||||
3 fichiers retirés de public/images), le dossier de l'acte précédent (clos)
|
||||
et ce dossier. Scan data/ .env .db clés : vide.
|
||||
|
||||
## Effet attendu
|
||||
Aperçus de partage et onglet avec le nouveau §. Réversible (git revert).
|
||||
|
||||
## Rollback
|
||||
```bash
|
||||
git revert --no-edit HEAD~1 && git push origin HEAD:main
|
||||
```
|
||||
|
||||
## Vérifications post-acte
|
||||
**Exécuté le 2026-09-29** : `67cd869..90f77da -> main`, prod à jour en ~160 s.
|
||||
|
||||
- [x] og:image en prod = `https://librodrome.org/og-default.png?v=2` ; JSON-LD logo idem ; fichier servi 200, identique octet pour octet au local
|
||||
- [x] favicon.ico (identique) et favicon.svg servis 200
|
||||
@@ -0,0 +1,37 @@
|
||||
# Acte — mise en ligne de la refonte (accueil console, contenus LaPlanK/sweetHome)
|
||||
Classe : déploie (le push de main déploie librodrome.org, sans étape de test)
|
||||
Projet : librodrome Date : 2026-09-29
|
||||
Armé le 2026-09-29 par Yvv : « Tu déploies directement stp. […] Déploie »
|
||||
|
||||
## Intention
|
||||
Mettre en ligne la refonte : accueil « console des curseurs », vrai §, statuts
|
||||
de production, bouquet sweetHome aligné sur les stacks réelles, LaPlanK,
|
||||
correctifs lecteur (pastille, MP3 direct) et couverture du livre.
|
||||
|
||||
## Commandes exactes
|
||||
```bash
|
||||
git push origin 67cd869d833bcb7924c6458f4d4b1928bb59e62a:main
|
||||
```
|
||||
|
||||
## Contenu sortant
|
||||
5 commits (c3ada04, e9fce4b, 4d8fe48, 201cfbb + ce dossier) : app/, site/
|
||||
(YAML de contenu), nuxt.config.ts, uno.config.ts, public/favicon.svg.
|
||||
Scan `data/ .env .db .enc clés IDENTIFIANTS` sur `git diff origin/main..HEAD` : **vide**.
|
||||
`git pull --rebase origin main` juste avant : à jour (aucune édition admin
|
||||
en prod à intégrer).
|
||||
|
||||
## Effet attendu
|
||||
librodrome.org reconstruit par Woodpecker. Irréversibilité : réversible.
|
||||
|
||||
## Rollback
|
||||
```bash
|
||||
git revert --no-edit c3ada04^..HEAD && git push origin HEAD:main
|
||||
```
|
||||
(ou redéployer 38467be)
|
||||
|
||||
## Vérifications post-acte
|
||||
**Exécuté le 2026-09-29** : `38467be..67cd869 -> main`.
|
||||
|
||||
- [x] Nouveau bundle servi ~60 s après le push (`entry.CIXHEUkt` → `entry.D1w8lqIL`)
|
||||
- [x] 17 pages × 2 ambiances (été, hiver) en prod : 0 erreur console, 0 hydratation, 0 débordement ; captures accueil desktop + mobile conformes
|
||||
- [x] BookPlayer en prod : lecture MP3 (200), suivant, sommaire, fermeture, Échap ; PDF 200 ; /api/messages 200
|
||||
@@ -0,0 +1,281 @@
|
||||
# Acte — persistance des messages de librodrome.org (volume nommé sur /app/data)
|
||||
Classe : déploie (un volume créé et semé sur sonic, puis le push de `main`, qui met en ligne sans étape de test)
|
||||
Projet : librodrome Date : 2026-10-26
|
||||
**État : EXÉCUTÉ** le 26/10/2026, 19h38-19h44, par la session dev-65 (orch, sous
|
||||
mandat de rôle, relecture indépendante inscrite) ; préparé vers 18h par la session de
|
||||
diagnostic. Section d'exécution en fin de dossier.
|
||||
|
||||
## Constat (diagnostic du 26/10/2026, en lecture seule)
|
||||
|
||||
| Fait | Preuve (26/10/2026, sauf date indiquée) |
|
||||
|---|---|
|
||||
| La prod tourne sur sonic | `getent ahostsv4 librodrome.org` → 161.97.174.60 (sonic, `harness/conventions/infra.md`) |
|
||||
| Un seul conteneur : `lpk-librodrome-main-app-1`, image `lpk-librodrome-main-app` (`sha256:8fe50a5b7c65…`), créé le 2026-09-29T06:59:03Z | `docker ps -a`, `docker inspect` |
|
||||
| Répertoire de travail `/app` ; le serveur lit et écrit `process.cwd()/data`, donc `/app/data` | `docker inspect` (`WorkingDir=/app`) ; `server/utils/content.ts`, `server/plugins/seed-messages.ts` |
|
||||
| Montages : deux binds seulement, `…/LPK/librodrome/data → /src/data` et `…/LPK/librodrome/docker/public → /src/public`, aucun volume nommé | `docker inspect` (`.Mounts`, `.HostConfig.Binds`) |
|
||||
| Les sources de ces binds sont des dossiers vides de root sous `/woodpecker/src/git.open.us.org/LPK/librodrome/`, créés par le démon le 2026-08-12 à 00h58m36Z, la seconde où s'est terminé le pipeline #55. L'ancien chemin `EHV/librodrome/` est vide lui aussi (créé le 23/03 à 03h10m44Z, fin du #36 à 03h10m45Z). | `ls -la --time-style=full-iso`, `find` sur sonic ; API CI. Mécanisme : la CI lance compose depuis son espace de travail, et un chemin relatif y désigne un chemin de l'hôte qui n'existe pas (déjà noté en 8a38c86, 28/02). |
|
||||
| `/app/data/messages.yml` vit dans la couche du conteneur, et non dans un montage : 1099 octets, daté du 2026-09-29T06:59:05Z, sha256 `c8afc7de…a6b8`, identique à la graine `site/messages.yml`, avec 1 message (celui d'Yvv) | `docker exec … sha256sum`, `stat`, `grep -c` ; `docker diff` → `A /app/data/messages.yml` |
|
||||
| Graine rejouée au premier démarrage du conteneur actuel | `docker logs` : `2026-09-29T06:59:05Z [seed-messages] data/messages.yml initialisé depuis site/messages.yml`, une seule fois. Les redémarrages des 12/10 et 18/10 (×4) ne l'ont pas rejouée : un redémarrage garde la couche, une recréation (un déploiement) la détruit. |
|
||||
| Aucune édition /admin dans le conteneur actuel | `docker diff` : rien sous `/app/site` ni `/app/content` |
|
||||
| `ADMIN_GIT_SYNC=false`, pas de `/app/.git`, et aucun commit `[admin]` dans l'historique | `docker inspect` (variable non secrète) ; `ls -d /app/.git` → absent ; `git log --all --grep='^\[admin\]'` → 0 |
|
||||
| Les secrets `NUXT_ADMIN_PASSWORD` et `NUXT_ADMIN_SECRET` sont définis ; les variables Umami sont vides | `docker inspect`, longueur seulement (aucune valeur relevée) |
|
||||
| 42 déploiements réussis sur `main` du 25/02 au 29/09 (#4 à #57) ; les derniers : #46 le 24/04, #50 le 24/07 (manuel, 10 s : recréation non vérifiée), #55 le 12/08, #56 et #57 le 29/09 | `https://ci.open.us.org/api/repos/1/pipelines` (lecture anonyme, Woodpecker 3.19.0) |
|
||||
|
||||
**Diagnostic** : la persistance de la prod est cassée, et elle l'a toujours été.
|
||||
Avant le 19/03 (`b9e6b4a`), les messages s'écrivaient dans `site/messages.yml`, dans
|
||||
le conteneur ; depuis, dans `/app/data`, dans le conteneur aussi. Chaque déploiement
|
||||
a donc remis les messages à zéro, puis à la graine à partir du 28/03 (`f633940`).
|
||||
Les éditions /admin de `site/` et `content/` ont subi le même sort, et ne sont jamais
|
||||
revenues dans git.
|
||||
|
||||
**Pertes** : depuis le 29/09, rien n'a été perdu (aucun message reçu, aucune édition).
|
||||
Avant cette date, ce qui a été perdu n'est pas vérifiable : les conteneurs détruits
|
||||
emportent leur couche, et les journaux n'en gardent pas trace (le journal de
|
||||
l'application commence le 29/09, celui de fabio le 07/10).
|
||||
|
||||
## Intention
|
||||
Monter les messages des visiteurs sur un volume nommé `librodrome-data` en
|
||||
`/app/data`, pour qu'ils survivent aux déploiements. Il faut le faire maintenant :
|
||||
au prochain push, tout message reçu d'ici là serait perdu. Le code est prêt sur la
|
||||
branche locale `fix/persistance-messages` (a6ba0a6), et il a été répété en local.
|
||||
|
||||
## Avant — prémisses vivantes
|
||||
|
||||
Sur le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome` (attendu en commentaire) :
|
||||
|
||||
```bash
|
||||
git status --short # P1 : vide
|
||||
git fetch -q origin && git rev-parse origin/main # P2 : 90f77da49b1c77faf9807ba48740b0abd22aff46
|
||||
# sinon ARRÊT : quelqu'un a poussé (pull --rebase, rebaser la branche, refaire le dossier)
|
||||
git rev-parse fix/persistance-messages # P3 : a6ba0a6662d54e6ed42ef26ce9e1411e873a2484
|
||||
git merge-base --is-ancestor origin/main a6ba0a6662d54e6ed42ef26ce9e1411e873a2484; echo $? # P4 : 0
|
||||
git diff --name-only origin/main a6ba0a6662d54e6ed42ef26ce9e1411e873a2484 \
|
||||
| grep -E '(^|/)data/|\.env|\.db$|\.enc$|\.key$|\.pem$|id_rsa|id_ed25519|IDENTIFIANTS'; echo $? # P5 : 1 (rien)
|
||||
curl -4 -s 'https://ci.open.us.org/api/repos/1/pipelines?perPage=3' \
|
||||
| python3 -I -c 'import sys,json; [print(p["number"], p["status"]) for p in json.load(sys.stdin)]' # P6 : « 57 success » en tête, rien en running ni pending
|
||||
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health # P7 : 200 (server/api/health.ts ne fait que répondre)
|
||||
curl -4 -s https://librodrome.org/api/messages \
|
||||
| python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))' # P8 : 1 au 26/10 (lecture seule : server/api/messages/index.get.ts)
|
||||
```
|
||||
Pour P8, un autre nombre veut dire que des messages ont été publiés depuis : le
|
||||
noter, il doit se retrouver après.
|
||||
|
||||
Sur sonic, les prémisses sont vérifiées par le script de l'étape 1, qui s'arrête en
|
||||
les nommant : conteneur en marche, `WorkingDir=/app`, `/app/data` non monté, volume
|
||||
`librodrome-data` absent, dossier de sauvegarde absent, `docker diff` vide sous
|
||||
`/app/site` et `/app/content`. Une édition /admin trouvée arrête l'acte : elle doit
|
||||
d'abord passer dans git, sinon ce déploiement l'effacerait.
|
||||
|
||||
Contenu sortant (`git log origin/main..a6ba0a6 --stat`, relevé le 26/10) :
|
||||
```
|
||||
a6ba0a6 2026-10-26 docker : messages persistés dans un volume nommé monté sur /app/data
|
||||
docker/docker-compose.dev.yml | 3 ++-
|
||||
docker/docker-compose.yml | 16 ++++++++++++++--
|
||||
23b1767 2026-10-26 claude.md : fiche et boucle de vérification fusionnées
|
||||
CLAUDE.md | 76 +++… HANDOFF.md | 11 +++…
|
||||
852a488 2026-09-29 handoff : bloc REPRISE (bascule quota 2026-09-29)
|
||||
HANDOFF.md | 39 +++…
|
||||
1c7cc45 2026-09-29 actes : og + favicon — exécuté et vérifié en production
|
||||
actes/2026-09-29-og-logo-mise-en-ligne.md | 8 +++++---
|
||||
```
|
||||
Le diff du compose retire deux lignes, les montages morts `../data:/src/data` et
|
||||
`./public:/src/public`, et en ajoute une, `data:/app/data`, plus le bloc `volumes:`
|
||||
(`librodrome-data`, `external: true`). Ce dossier n'est pas dans le push : il est
|
||||
sur `main` en local, et partira avec le déploiement suivant.
|
||||
|
||||
## Commandes exactes
|
||||
|
||||
Depuis le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome`. Les scripts sont
|
||||
dans `actes/annexes/`, et chacun se retire de `/tmp` à la fin.
|
||||
|
||||
```bash
|
||||
# Étape 1 — sauvegarde, puis volume créé et semé (sonic ; le conteneur en service n'est pas touché)
|
||||
scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-avant.sh yvv@161.97.174.60:/tmp/lb-avant.sh \
|
||||
&& scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-veille.sh yvv@161.97.174.60:/tmp/lb-veille.sh \
|
||||
&& scp -q -o BatchMode=yes actes/annexes/2026-10-26-persistance-messages-apres.sh yvv@161.97.174.60:/tmp/lb-apres.sh \
|
||||
&& ssh -o BatchMode=yes yvv@161.97.174.60 'bash /tmp/lb-avant.sh' </dev/null
|
||||
# attendu : « sauvegarde : N message(s), sha256 … », « volume librodrome-data semé : sha256 … » (même empreinte), « ÉTAPE 1 OK »
|
||||
|
||||
# Étape 2 — veille sur l'ancien conteneur, jusqu'à sa disparition (30 min au plus)
|
||||
ssh -o BatchMode=yes yvv@161.97.174.60 'setsid nohup bash /tmp/lb-veille.sh >/tmp/lb-veille.log 2>&1 &' </dev/null
|
||||
|
||||
# Étape 3 — mise en ligne (push du SHA vérifié), puis attente du pipeline
|
||||
for i in 1 2 3 4 5; do if git push -q origin a6ba0a6662d54e6ed42ef26ce9e1411e873a2484:main; then break; fi; sleep 10; done; git status -sb | head -1
|
||||
for i in $(seq 1 40); do s=$(curl -4 -s -m 20 'https://ci.open.us.org/api/repos/1/pipelines?perPage=5' | python3 -I -c 'import sys,json; print(next((p["status"] for p in json.load(sys.stdin) if p["commit"].startswith("a6ba0a6662d5")), "absent"))'); echo "$(date +%T) $s"; case "$s" in success|failure|error|killed|declined) break;; esac; sleep 15; done
|
||||
# attendu : « success » en 3 min environ (#56 : 3 min 18 s, #57 : 2 min 36 s)
|
||||
|
||||
# Étape 4 — vérification sur sonic : bascule, veille, redémarrage à froid
|
||||
ssh -o BatchMode=yes yvv@161.97.174.60 'cat /tmp/lb-veille.log; bash /tmp/lb-apres.sh && rm -f /tmp/lb-veille.log' </dev/null
|
||||
# attendu : « ancien conteneur remplacé », « veille : dernière relève = sauvegarde », deux fois « fichier du volume = sauvegarde », « ÉTAPE 4 OK »
|
||||
|
||||
# Étape 5 — vu de l'extérieur
|
||||
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health
|
||||
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/
|
||||
curl -4 -s https://librodrome.org/api/messages | python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))'
|
||||
# attendu : 200, 200, le nombre relevé en P8
|
||||
|
||||
# Étape 6 — branche locale sur la tête déployée, puis compte rendu
|
||||
git switch main && git rebase a6ba0a6662d54e6ed42ef26ce9e1411e873a2484
|
||||
```
|
||||
|
||||
Si le pipeline échoue à l'étape 3 (par exemple sur `external volume "librodrome-data"
|
||||
not found`), l'ancien conteneur continue de servir, sans changement : la répétition
|
||||
l'a montré. La veille s'arrête seule au bout de 30 min. On ne relance rien : on
|
||||
rapporte.
|
||||
|
||||
## Effet attendu
|
||||
- librodrome.org est reconstruit par Woodpecker. `/app/data` devient le volume
|
||||
`librodrome-data`, et les messages survivent désormais aux déploiements, aux
|
||||
redémarrages et à `docker compose down -v` (volume `external`).
|
||||
- Les montages morts `/src/data` et `/src/public` disparaissent. Les dossiers vides
|
||||
de root sous `/woodpecker/src/git.open.us.org/{EHV,LPK}/librodrome/` restent sur
|
||||
l'hôte, sans effet ; les retirer demande root (hors acte).
|
||||
- Ce qui ne change pas : les éditions /admin de `site/` et `content/` restent perdues
|
||||
à chaque déploiement, les médias envoyés par /admin ne sont toujours pas servis, et
|
||||
`ADMIN_GIT_SYNC` reste à `false` (décisions d'Yvv, voir plus bas).
|
||||
- Fenêtre : entre la sauvegarde et la bascule (3 min environ), un message posté va
|
||||
dans l'ancien conteneur. La veille le relève toutes les 5 s, et l'étape 4 s'arrête
|
||||
s'il y en a un. Le risque résiduel tient en quelques secondes : la dernière relève,
|
||||
puis l'arrêt du conteneur. Le débit observé est de 0 message en 27 jours.
|
||||
- Irréversibilité : réversible (voir Retour). Ni l'acte ni son retour ne suppriment le
|
||||
volume ni la sauvegarde.
|
||||
|
||||
## Vérifications après
|
||||
- [x] P7 et P8 rejoués : `200`, et le même nombre de messages publiés
|
||||
- [x] `lb-apres.sh` : `/app/data` monté sur le volume `librodrome-data`, aucun autre montage, 0 ligne `seed-messages`, fichier du volume = sauvegarde, idem après `docker restart`
|
||||
- [x] La veille a vu partir l'ancien conteneur, et sa dernière relève est identique à la sauvegarde
|
||||
- [x] Pipeline du commit `a6ba0a6662d5` : `success`
|
||||
- [x] Sauvegarde présente sur sonic : `~/librodrome-sauvegardes/2026-10-26/` (700, fichiers 600 ; `messages.yml`, `SHA256SUMS`, `ancien-conteneur.id`, relève de la veille)
|
||||
- [x] Au déploiement suivant, quel qu'il soit : `docker exec lpk-librodrome-main-app-1 sha256sum /app/data/messages.yml` inchangé, et aucune ligne `seed-messages`. C'est la preuve en vraie grandeur.
|
||||
|
||||
## Retour
|
||||
|
||||
Retour du code, par un nouvel acte « déploie » :
|
||||
```bash
|
||||
git switch main && git revert --no-edit a6ba0a6662d54e6ed42ef26ce9e1411e873a2484 \
|
||||
&& for i in 1 2 3 4 5; do if git push -q origin "$(git rev-parse HEAD)":main; then break; fi; sleep 10; done
|
||||
```
|
||||
Effet du retour, répété en local le 26/10 : le conteneur est recréé sans le volume
|
||||
et re-semé depuis la graine. Le volume `librodrome-data` reste intact, avec tous les
|
||||
messages reçus depuis la bascule ; redéployer le correctif les remonte.
|
||||
|
||||
Arrêt après l'étape 1, avant le push : rien à défaire en production, puisque le
|
||||
conteneur n'a pas changé. Le volume et la sauvegarde restent ; un second passage de
|
||||
l'étape 1 s'arrêtera sur « le volume existe déjà », ce qui est voulu. Les retirer est
|
||||
un acte « supprime » à part.
|
||||
|
||||
La sauvegarde de sonic contient de vraies données de visiteurs (auteurs, adresses).
|
||||
Elle reste sur sonic, et ne quitte pas le serveur, jusqu'à ce qu'Yvv décide ; sa
|
||||
suppression est un acte « supprime ».
|
||||
|
||||
## Répétition (26/10/2026, sur le poste)
|
||||
Faite avec le vrai docker (29.4.1, compose v5.1.3) et les noms exacts de la prod :
|
||||
projet `lpk-librodrome-main`, conteneur `lpk-librodrome-main-app-1`, image locale
|
||||
`lpk-librodrome-main-app` (`node:22-bookworm-slim` plus le `.output`, `site/` et
|
||||
`content/` du dépôt, en couche d'image comme en prod). L'ancien conteneur venait de
|
||||
l'ancien compose (`main`), la bascule du compose d'a6ba0a6, et `HOME` pointait sur un
|
||||
faux dossier.
|
||||
- Nominal : étape 1 OK (sauvegarde, puis volume semé à la même empreinte), veille
|
||||
arrêtée à la bascule, étape 4 OK avant et après le redémarrage à froid, 0 graine
|
||||
rejouée.
|
||||
- Nouveau compose sans volume : `external volume "librodrome-data" not found`,
|
||||
sortie 1, ancien conteneur intact (même identifiant).
|
||||
- Étape 1 rejouée après la bascule : ARRÊT « /app/data est déjà monté ».
|
||||
- Édition /admin présente dans la couche : ARRÊT, aucun volume créé.
|
||||
- Message posté dans l'ancien conteneur après la sauvegarde : relevé par la veille
|
||||
(2 messages), étape 4 en ARRÊT avec la consigne.
|
||||
- Message posté après la bascule, puis `--force-recreate` : conservé, sans graine ;
|
||||
le volume survit à `down -v` ; le retour à l'ancien compose re-sème sans toucher au
|
||||
volume.
|
||||
- Second déroulé, après correction de la veille (elle relevait par nom, et le nom
|
||||
désigne le nouveau conteneur dès la bascule ; elle relève maintenant par
|
||||
identifiant, et laisse `veille.fin`), à 16h58-16h59Z :
|
||||
- étape 4 lancée avant le déploiement : ARRÊT « conteneur non recréé » ;
|
||||
- message posté après la bascule : la dernière relève reste égale à la
|
||||
sauvegarde, l'étape 4 passe avec la NOTE « fichier du volume changé (2
|
||||
messages) », avant et après le redémarrage ;
|
||||
- message tardif dans l'ancien conteneur : ARRÊT, comme au premier déroulé.
|
||||
- Non déroulés : la sortie de la veille au bout de 30 min sans bascule, et
|
||||
l'arrêt « la veille n'a pas constaté la bascule » (fenêtre de moins de 5 s
|
||||
entre la recréation et la fin de la veille).
|
||||
- Répétition retirée du poste : conteneur, volume et image locale supprimés, ainsi
|
||||
que `/tmp/lbacte` (rien que du factice et une copie du build).
|
||||
|
||||
Différences avec la prod : node 22 au lieu de 23.11 (sans effet sur les montages ni
|
||||
sur la graine), compose du poste au lieu de celui de l'image `docker:dind` de la CI,
|
||||
et pas de proxy fabio devant le conteneur.
|
||||
|
||||
## Hors champ — décisions d'Yvv
|
||||
1. **Éditions /admin** (`site/`, `content/`) : perdues à chaque déploiement, jamais
|
||||
remontées dans git. Trois voies : (a) /admin en lecture seule en prod, et le
|
||||
contenu ne s'édite plus que par git ; (b) rétablir le git sync : un clone avec
|
||||
droit d'écriture sur `LPK/librodrome` (acte « accorde »), `.git` monté,
|
||||
`ADMIN_GIT_SYNC=true` en CI, et chaque édition devient un push, donc un
|
||||
déploiement ; (c) des volumes sur `site/` et `content/`, ce qui fait deux
|
||||
vérités face à git (déconseillé).
|
||||
2. **Médias /admin** : `upload.post.ts` écrit dans `/app/public`, qui n'existe pas
|
||||
dans l'image, et Nitro ne sert que le manifeste figé à la construction
|
||||
(`.output/server/chunks/nitro/nitro.mjs`, `readAsset` et `assets[id]`). Un envoi
|
||||
n'est donc jamais servi. `/admin/media` lit `/app/public` : déduit du code, il
|
||||
doit répondre 500 (non sondé).
|
||||
3. **Canon démenti** : `/DEV/AGENTS.md` (Démarrage §3) et
|
||||
`harness/conventions/git.md` (« Début de session », point 1) disent que librodrome
|
||||
écrit du YAML en prod via /admin et git sync. C'est faux depuis l'origine
|
||||
(preuves ci-dessus). Même chose pour `CLAUDE.md` du dépôt, § Données runtime :
|
||||
« Persisté entre les rebuilds — jamais écrasé ».
|
||||
4. **Sauvegarde sur sonic** : la garder, ou la supprimer après un déploiement
|
||||
suivant réussi (acte « supprime »).
|
||||
|
||||
## Exécution
|
||||
|
||||
Acte exécuté le 26/10/2026, 19h38-19h44, par la session dev-65 (orch, mandat de rôle
|
||||
du 26-27/10). Relecture indépendante de a6ba0a6 et des scripts : un sous-agent neuf,
|
||||
verdict « ok » à 18h48, inscrite dans `state/relectures.json`. Sorties relevées par
|
||||
orch, puis consignées par la session librodrome-e9.
|
||||
|
||||
- **Prémisses** (19:38:45) : P1 vide, P2 `90f77da49b1c…`, P3 `a6ba0a6662d5…`, P4 `0`,
|
||||
P5 `1`, P6 `57 success` / `56 success` / `55 success`, P7 `200`, P8 `1`.
|
||||
Contrôle ajouté (relecture, m1) : `docker diff` du conteneur, 0 entrée sous
|
||||
`/app/(public|site|content)`.
|
||||
- **Étape 1** (19:39:43-19:39:51) : « sauvegarde : 1 message(s), sha256
|
||||
c8afc7de728fb9d57f6565a36b40f16fe7beb438768f8f9127183bea9569a6b8, 1099 2026-09-29
|
||||
06:59:05.542963509 +0000 » ; « volume librodrome-data semé : sha256 c8afc7de…a6b8 » ;
|
||||
« ÉTAPE 1 OK ».
|
||||
- **Étape 2** : veille lancée vers 19h40.
|
||||
- **Étape 3** : boucle de push lancée seule. Écart au déroulé : sans le
|
||||
`; git status -sb | head -1` final, que le garde refuse sous mandat. `origin/main` =
|
||||
`a6ba0a6662d54e6ed42ef26ce9e1411e873a2484`. Pipeline n° 58 : running à 19:42:41,
|
||||
success à 19:43:51.
|
||||
- **Étape 4** (jusqu'à 19:44:14) : « ancien conteneur remplacé, constaté à
|
||||
2026-10-26T18:43:46Z » ; « veille : dernière relève = sauvegarde
|
||||
(2026-10-26T18:43:41Z) » ; bascule, puis redémarrage à froid, deux fois « fichier du
|
||||
volume = sauvegarde (1 message(s)) » ; « ÉTAPE 4 OK ».
|
||||
Contrôle indépendant (relecture, m2) : journal du conteneur lu (2 lignes), 0 ligne
|
||||
`seed-messages` ; seul montage, le volume `librodrome-data` sur `/app/data` ; conteneur
|
||||
créé le 2026-10-26T18:43:43Z.
|
||||
- **Sauvegarde** sur sonic : `~/librodrome-sauvegardes/2026-10-26/` (700, fichiers 600 :
|
||||
`SHA256SUMS`, `ancien-conteneur.id`, `derniere-releve.date`,
|
||||
`messages-derniere-releve.yml`, `messages.stat`, `messages.yml`, `veille.fin`).
|
||||
`/tmp/lb-*` retirés, aucune veille en cours.
|
||||
- **Étape 5** (19:44:41) : health `200`, accueil `200`, messages `1`.
|
||||
- **Étape 6** : `main` locale rebasée sur `a6ba0a6` sans conflit ; ce dossier (`52f0a00`)
|
||||
devient `c32d88b`.
|
||||
|
||||
Mineurs de la relecture, non bloquants, à reprendre si l'on réutilise ces scripts :
|
||||
m1 `avant.sh` ne regarde pas `/app/public`, où /admin écrit ses médias (paré à la
|
||||
main, ci-dessus) ; m2 `|| true` sur `docker logs` dans `apres.sh` (paré par le contrôle
|
||||
indépendant) ; m3 pas de témoin de fin d'étape 1 ; m4 `CLAUDE.md` décrivait encore
|
||||
`../data:/src/data` (corrigé dans ce commit) ; m5 les statuts `blocked` et `skipped` ne
|
||||
sont pas prévus par la boucle de l'étape 3 ; m6 le dossier de sauvegarde est à retirer
|
||||
à la main si l'étape 1 échoue.
|
||||
|
||||
**Preuve en vraie grandeur** : faite au déploiement suivant, par la session
|
||||
librodrome-e9, sur le « Déploie » d'Yvv tapé dans cette session (26/10, vers 19h50 :
|
||||
« il bosse, je le court-circuite. Déploie […] »). Push de `4057f50` (doc seule) ; le
|
||||
pipeline n° 59 passe de running (19:51:39) à success (19:53:49). Sur sonic, en lecture
|
||||
seule : le conteneur `4f97cff481d8…`, créé à 18:43:43Z, est remplacé par `154b4906183c…`,
|
||||
créé à 18:53:43Z ; seul montage, `librodrome-data` sur `/app/data` ; `messages.yml` reste
|
||||
à `c8afc7de…a6b8` avant comme après ; 0 ligne `seed-messages` (journal : 1 ligne). Vu de
|
||||
l'extérieur (19:54:44) : health `200`, accueil `200`, messages `1`.
|
||||
|
||||
Reste ouvert : les décisions du § Hors champ.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Acte — Console de l'accueil : la graduation d'Yvv au survol
|
||||
Classe : déploie
|
||||
Projet : librodrome Date : 2026-11-03
|
||||
**État : EXÉCUTÉ (03/11, 06h11-06h16), par orch (session dev-63) sous mandat « déploie » (inscrit du 03/11 au 08/11), après la relecture neuve « 64e714c : ok »**
|
||||
|
||||
## Intention
|
||||
Mettre en ligne la console de l'accueil selon la consigne d'Yvv du 03/11 : au survol,
|
||||
numérique monte à 3/4, économique à 1/2, citoyenne à 2/3. Chaque curseur reste à son cran,
|
||||
et un nouveau survol rejoue sa montée. Le panneau suit le même geste. Avant le premier geste,
|
||||
l'animation lente du 29/09 continue, et l'axe en avant monte à son cran, jamais au-dessus.
|
||||
Les consignes sont écrites mot pour mot dans `docs/consignes-yvv.md`. La première relecture
|
||||
(« 230f79a : à reprendre ») a été traitée par `03f763a` et `64e714c`.
|
||||
|
||||
## Avant — prémisses vivantes
|
||||
|
||||
Sur le poste, dans `/home/yvv/Documents/PROD/DEV/librodrome`. L'attendu, relevé le 03/11 à
|
||||
05h57, est en commentaire :
|
||||
|
||||
```bash
|
||||
git fetch -q origin && git rev-parse origin/main # P1 : 4057f50bd37fa8a3ba3ea9dc6c9c38d51d4f6434
|
||||
# sinon ARRÊT : quelqu'un a poussé
|
||||
git merge-base --is-ancestor origin/main 64e714c1dde9897e46d36caf6cab737799081c96; echo $? # P2 : 0
|
||||
git diff --name-only origin/main 64e714c1dde9897e46d36caf6cab737799081c96 \
|
||||
| grep -E '(^|/)data/|\.env|\.db$|\.enc$|\.key$|\.pem$|id_rsa|id_ed25519|IDENTIFIANTS'; echo $? # P3 : 1 (rien)
|
||||
curl -4 -s 'https://ci.open.us.org/api/repos/1/pipelines?perPage=3' \
|
||||
| python3 -I -c 'import sys,json; [print(p["number"], p["status"]) for p in json.load(sys.stdin)]' # P4 : « 59 success » en tête, rien en cours
|
||||
curl -4 -s -o /dev/null -w '%{http_code}\n' https://librodrome.org/api/health # P5 : 200
|
||||
curl -4 -s https://librodrome.org/api/messages \
|
||||
| python3 -I -c 'import sys,json; print(len(json.load(sys.stdin)))' # P6 : 1
|
||||
ssh -o BatchMode=yes yvv@161.97.174.60 'docker diff lpk-librodrome-main-app-1 | grep -E " /app/(site|content)(/|$)"; echo $?; docker exec lpk-librodrome-main-app-1 sha256sum /app/data/messages.yml' </dev/null
|
||||
# P7 : « 1 » (aucune édition /admin, que ce déploiement effacerait)
|
||||
# puis c8afc7de728fb9d57f6565a36b40f16fe7beb438768f8f9127183bea9569a6b8 /app/data/messages.yml
|
||||
```
|
||||
|
||||
Une édition /admin trouvée en P7 arrête l'acte : elle doit d'abord passer dans git.
|
||||
|
||||
Relecture : `~/harness/etat/relectures.json` doit porter « 64e714c : ok », d'un relecteur neuf.
|
||||
|
||||
Contenu sortant (`git log origin/main..64e714c --stat`, relevé le 03/11 à 05h57) :
|
||||
```
|
||||
64e714c console : l'animation monte l'axe en avant à son cran
|
||||
03f763a console : graduation d'Yvv, la montée se rejoue à chaque survol
|
||||
230f79a console : au survol, le curseur monte sans faire descendre les autres
|
||||
89d5f83 actes : persistance des messages — preuve au déploiement suivant
|
||||
actes/2026-10-26-persistance-messages.md | 14 ++-
|
||||
app/components/home/HomeConsole.vue | 166 ++++++++++++++++++++++++-----
|
||||
docs/consignes-yvv.md | 50 ++++++++++
|
||||
```
|
||||
Ni `data/`, ni `site/`, ni configuration, ni secret. Ce dossier n'est pas dans le push : il
|
||||
partira avec le déploiement suivant.
|
||||
|
||||
## Commandes exactes
|
||||
|
||||
```bash
|
||||
# 1. La mise en ligne : la poussée du SHA relu
|
||||
cd /home/yvv/Documents/PROD/DEV/librodrome && for i in 1 2 3 4 5; do if git push -q origin 64e714c1dde9897e46d36caf6cab737799081c96:main; then break; fi; sleep 10; done
|
||||
|
||||
# 2. Attente du pipeline (environ 3 min)
|
||||
for i in $(seq 1 40); do s=$(curl -4 -s -m 20 'https://ci.open.us.org/api/repos/1/pipelines?perPage=5' | python3 -I -c 'import sys,json; print(next((p["status"] for p in json.load(sys.stdin) if p["commit"].startswith("64e714c1dde9")), "absent"))'); echo "$(date +%T) $s"; case "$s" in success|failure|error|killed|declined) break;; esac; sleep 15; done
|
||||
# attendu : success
|
||||
|
||||
# 3. Branche main locale sur la tête déployée (avance rapide seule ; main n'est extraite nulle part)
|
||||
git -C /home/yvv/Documents/PROD/DEV/librodrome fetch . 64e714c1dde9897e46d36caf6cab737799081c96:main
|
||||
```
|
||||
|
||||
Si le pipeline échoue, l'ancien conteneur continue de servir, sans changement : on ne
|
||||
relance rien, on rapporte.
|
||||
|
||||
## Effet attendu
|
||||
- librodrome.org est reconstruit. Seule la console de l'accueil change de comportement.
|
||||
Les messages restent sur le volume `librodrome-data`, sans être touchés.
|
||||
- Irréversibilité : réversible (voir Retour).
|
||||
|
||||
## Vérifications après
|
||||
- [x] Pipeline du commit `64e714c1dde9` : `success`
|
||||
- [x] P5 et P6 rejoués : `200`, puis le même nombre de messages publiés
|
||||
- [x] Sur sonic : `messages.yml` a la même empreinte qu'en P7, et le journal du conteneur n'a aucune ligne `seed-messages`
|
||||
- [x] Vu de l'extérieur, sur https://librodrome.org, dans un navigateur sans tête : au survol d'économique, `--level` passe de 0 à 0,50 ; les deux autres ne bougent pas ; le panneau « Autonomie économique » s'affiche
|
||||
|
||||
## Retour
|
||||
Par un nouvel acte « déploie » :
|
||||
```bash
|
||||
git switch main && git revert --no-edit 64e714c1dde9897e46d36caf6cab737799081c96 03f763a 230f79a \
|
||||
&& for i in 1 2 3 4 5; do if git push -q origin "$(git rev-parse HEAD)":main; then break; fi; sleep 10; done
|
||||
```
|
||||
Le retour ne touche ni le volume ni les messages.
|
||||
|
||||
## Exécution
|
||||
|
||||
Acte exécuté le 03/11/2026, 06h11-06h16, par orch (session dev-63, e66db8ca), sous mandat
|
||||
« déploie » ; consigne d'Yvv du 03/11 à 06h00 : « livre et déploie tout ce que tu peux ».
|
||||
|
||||
- Relecture : « 230f79a : à reprendre » (05h34), puis « 64e714c : ok » (06h05), d'un sous-agent
|
||||
neuf, inscrite dans `~/harness/etat/relectures.json`. Légers, pour un lot suivant : un curseur
|
||||
non touché peut se figer à mi-course si le premier geste tombe pendant la rotation ; un
|
||||
nouveau survol à moins de 500 ms ne rejoue pas la montée ; un clic sans survol ni focus
|
||||
affiche le panneau sans monter le curseur ; la flèche part de l'axe sélectionné.
|
||||
- Prémisses rejouées à 06h11:39 : P1 `4057f50…`, P2 0, P3 1, P4 « 59 success », P5 200, P6 1,
|
||||
P7 1 et `c8afc7de…a6b8`.
|
||||
- Poussée de `64e714c1dde9897e46d36caf6cab737799081c96:main` à 06h11:5x, passée du premier coup.
|
||||
- Pipeline n° 60 : `running` de 06h12:08, `success` à 06h14:27.
|
||||
- Après : `/api/health` 200, `/` 200, 1 message publié ; conteneur `Up 20 seconds (healthy)` ;
|
||||
`messages.yml` `c8afc7de…a6b8`, inchangé ; 0 ligne `seed-messages`.
|
||||
- Vu de l'extérieur (Chromium sans tête, https://librodrome.org, 1280 px), `--level` :
|
||||
repos 0,75 / 0,336 / 0,17, panneau numérique ; survol d'économique 0,75 / **0,50** / 0,17,
|
||||
panneau économique ; survol de citoyenne 0,75 / 0,50 / **0,667** ; survol de numérique
|
||||
0,75 / 0,50 / 0,667 ; 4 s après la sortie, inchangé ; 0 erreur dans la console.
|
||||
- `main` local avancé sur `64e714c` (`git fetch . 64e714c…:main`).
|
||||
- Ce dossier n'est pas en ligne : il partira avec le déploiement suivant.
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Acte 2026-10-26-persistance-messages — étape 4, sur sonic, sous yvv, après le déploiement.
|
||||
# Vérifie la bascule, puis un redémarrage à froid, puis revérifie.
|
||||
# Lancement : ssh yvv@161.97.174.60 'bash /tmp/lb-apres.sh' </dev/null
|
||||
set -euo pipefail
|
||||
|
||||
C=lpk-librodrome-main-app-1
|
||||
V=librodrome-data
|
||||
D="$HOME/librodrome-sauvegardes/2026-10-26"
|
||||
|
||||
stop() { echo "ARRÊT : $*" >&2; exit 1; }
|
||||
|
||||
check() {
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$C")" = true ] || stop "$C ne tourne pas"
|
||||
src=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Type}} {{.Name}}{{end}}{{end}}' "$C")
|
||||
[ "$src" = "volume $V" ] || stop "/app/data n'est pas le volume $V (trouvé : '$src')"
|
||||
others=$(docker inspect -f '{{range .Mounts}}{{.Destination}} {{end}}' "$C")
|
||||
[ "$others" = "/app/data " ] || stop "montages inattendus : $others"
|
||||
s=$(docker logs "$C" 2>&1 | grep -c 'seed-messages' || true)
|
||||
[ "$s" = 0 ] || stop "la graine a été rejouée ($s ligne(s) seed-messages)"
|
||||
hc=$(docker exec "$C" sha256sum /app/data/messages.yml | cut -d' ' -f1)
|
||||
nc=$(docker exec "$C" grep -c '^ - id:' /app/data/messages.yml || true)
|
||||
if [ "$hc" = "$h" ]; then
|
||||
echo " fichier du volume = sauvegarde ($nc message(s))"
|
||||
else
|
||||
echo " NOTE : le fichier du volume a changé depuis la sauvegarde ($nc message(s)) : message reçu après la bascule, à regarder"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_up() {
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$C" node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
stop "$C ne répond pas sur /api/health après 60 s"
|
||||
}
|
||||
|
||||
[ -s "$D/ancien-conteneur.id" ] || stop "étape 1 non jouée"
|
||||
old=$(cat "$D/ancien-conteneur.id")
|
||||
h=$(cut -d' ' -f1 "$D/SHA256SUMS")
|
||||
new=$(docker inspect -f '{{.Id}}' "$C")
|
||||
[ "$new" != "$old" ] || stop "conteneur non recréé : le déploiement n'a pas eu lieu"
|
||||
|
||||
# La veille doit avoir vu l'ancien conteneur partir, sans message arrivé après la sauvegarde.
|
||||
[ -s "$D/veille.fin" ] || stop "la veille n'a pas constaté la bascule (encore en cours ?) : attendre 10 s et relancer cette étape"
|
||||
[ -s "$D/messages-derniere-releve.yml" ] || stop "aucune relève de la veille"
|
||||
hl=$(sha256sum "$D/messages-derniere-releve.yml" | cut -d' ' -f1)
|
||||
[ "$hl" = "$h" ] || stop "un message est arrivé dans l'ancien conteneur après la sauvegarde ($(cat "$D/derniere-releve.date")) : le reporter dans le volume avant toute autre chose (hors acte, décision d'Yvv)"
|
||||
echo "veille : dernière relève = sauvegarde ($(head -1 "$D/derniere-releve.date"))"
|
||||
|
||||
echo "bascule :"
|
||||
wait_up
|
||||
check
|
||||
|
||||
echo "redémarrage à froid :"
|
||||
docker restart "$C" >/dev/null
|
||||
wait_up
|
||||
check
|
||||
|
||||
rm -f -- "$0"
|
||||
echo "ÉTAPE 4 OK"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Acte 2026-10-26-persistance-messages — étape 1, sur sonic, sous yvv.
|
||||
# Prémisses, sauvegarde des messages, volume librodrome-data créé et semé.
|
||||
# Ne touche pas au conteneur en service (lectures seulement).
|
||||
# Lancement : ssh yvv@161.97.174.60 'bash /tmp/lb-avant.sh' </dev/null
|
||||
set -euo pipefail
|
||||
|
||||
C=lpk-librodrome-main-app-1
|
||||
IMG=lpk-librodrome-main-app
|
||||
V=librodrome-data
|
||||
D="$HOME/librodrome-sauvegardes/2026-10-26"
|
||||
|
||||
stop() { echo "ARRÊT : $*" >&2; exit 1; }
|
||||
|
||||
# --- Prémisses
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$C")" = true ] || stop "$C ne tourne pas"
|
||||
[ "$(docker inspect -f '{{.Config.WorkingDir}}' "$C")" = /app ] || stop "WorkingDir de $C différent de /app"
|
||||
m=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Type}}{{end}}{{end}}' "$C")
|
||||
[ -z "$m" ] || stop "/app/data est déjà monté ($m) : l'acte a déjà été joué ?"
|
||||
if docker volume inspect "$V" >/dev/null 2>&1; then stop "le volume $V existe déjà"; fi
|
||||
[ ! -e "$D" ] || stop "$D existe déjà"
|
||||
dd=$(docker diff "$C")
|
||||
modifs=$(printf '%s\n' "$dd" | grep -E '^[ACD] /app/(site|content)(/|$)' || true)
|
||||
if [ -n "$modifs" ]; then
|
||||
echo "$modifs" >&2
|
||||
stop "éditions /admin présentes dans $C et absentes de git : les reporter dans git avant de déployer"
|
||||
fi
|
||||
|
||||
# --- Sauvegarde (reste sur sonic, dossier 700, fichiers 600)
|
||||
umask 077
|
||||
mkdir -p "$D"
|
||||
docker inspect -f '{{.Id}}' "$C" > "$D/ancien-conteneur.id"
|
||||
docker exec "$C" cat /app/data/messages.yml > "$D/messages.yml"
|
||||
docker exec "$C" stat -c '%s %y' /app/data/messages.yml > "$D/messages.stat"
|
||||
[ -s "$D/messages.yml" ] || stop "sauvegarde vide"
|
||||
n=$(grep -c '^ - id:' "$D/messages.yml" || true)
|
||||
h=$(sha256sum "$D/messages.yml" | cut -d' ' -f1)
|
||||
echo "$h messages.yml" > "$D/SHA256SUMS"
|
||||
echo "sauvegarde : $n message(s), sha256 $h, $(cat "$D/messages.stat")"
|
||||
|
||||
# --- Volume créé et semé avec la sauvegarde
|
||||
docker volume create "$V" >/dev/null
|
||||
docker run --rm -i --entrypoint sh -v "$V":/data "$IMG" -c 'cat > /data/messages.yml' < "$D/messages.yml"
|
||||
hv=$(docker run --rm --entrypoint sha256sum -v "$V":/data:ro "$IMG" /data/messages.yml | cut -d' ' -f1)
|
||||
[ "$hv" = "$h" ] || stop "empreinte du volume ($hv) différente de la sauvegarde ($h)"
|
||||
echo "volume $V semé : sha256 $hv"
|
||||
|
||||
rm -f -- "$0"
|
||||
echo "ÉTAPE 1 OK"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Acte 2026-10-26-persistance-messages — étape 2, sur sonic, sous yvv, en arrière-plan.
|
||||
# Relève toutes les 5 s le fichier de messages de l'ancien conteneur, jusqu'à ce
|
||||
# que le déploiement le remplace (ou 30 min au plus). Lectures seulement, sauf
|
||||
# l'écriture de la relève dans le dossier de sauvegarde.
|
||||
# Lancement : ssh yvv@161.97.174.60 'setsid nohup bash /tmp/lb-veille.sh >/tmp/lb-veille.log 2>&1 &' </dev/null
|
||||
set -uo pipefail
|
||||
|
||||
C=lpk-librodrome-main-app-1
|
||||
D="$HOME/librodrome-sauvegardes/2026-10-26"
|
||||
|
||||
[ -s "$D/ancien-conteneur.id" ] || { echo "ARRÊT : étape 1 non jouée"; exit 1; }
|
||||
old=$(cat "$D/ancien-conteneur.id")
|
||||
umask 077
|
||||
i=0
|
||||
while [ "$(docker inspect -f '{{.Id}}' "$C" 2>/dev/null)" = "$old" ] && [ "$i" -lt 360 ]; do
|
||||
# par l'identifiant : après la bascule, le nom désigne déjà le nouveau conteneur
|
||||
if docker exec "$old" cat /app/data/messages.yml > "$D/.releve.tmp" 2>/dev/null && [ -s "$D/.releve.tmp" ]; then
|
||||
mv "$D/.releve.tmp" "$D/messages-derniere-releve.yml"
|
||||
date -u +%FT%TZ > "$D/derniere-releve.date"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 5
|
||||
done
|
||||
rm -f "$D/.releve.tmp"
|
||||
if [ "$i" -ge 360 ]; then
|
||||
echo "FIN SANS BASCULE : 30 min écoulées, l'ancien conteneur tourne toujours"
|
||||
else
|
||||
date -u +%FT%TZ > "$D/veille.fin"
|
||||
echo "ancien conteneur remplacé, constaté à $(cat "$D/veille.fin")"
|
||||
fi
|
||||
rm -f -- "$0"
|
||||
+9
-10
@@ -6,13 +6,6 @@ export default defineAppConfig({
|
||||
},
|
||||
header: {
|
||||
height: '4rem',
|
||||
nav: [
|
||||
{ label: 'Autonomie', to: '/autonomie' },
|
||||
{ label: 'Modèle éco', to: '/modele-eco' },
|
||||
{ label: 'En musique', to: '/en-musique' },
|
||||
{ label: 'Évènement', to: '/evenement' },
|
||||
{ label: 'À propos', to: '/a-propos' },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
credits: '© 2026 Le Librodrome — Productions collectives',
|
||||
@@ -21,10 +14,16 @@ export default defineAppConfig({
|
||||
],
|
||||
},
|
||||
gratewizard: {
|
||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.ml',
|
||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
|
||||
popup: {
|
||||
width: 420,
|
||||
height: 720,
|
||||
width: 480,
|
||||
height: 860,
|
||||
},
|
||||
},
|
||||
libredecision: {
|
||||
url: 'https://decision.librodrome.org',
|
||||
},
|
||||
sejeteral0: {
|
||||
url: 'https://collectivites.librodrome.org',
|
||||
},
|
||||
})
|
||||
|
||||
+52
-4
@@ -17,12 +17,60 @@
|
||||
const paletteStore = usePaletteStore()
|
||||
onMounted(() => paletteStore.applyToDOM())
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org'
|
||||
|
||||
// Global SEO defaults — surchargeables page par page via useSeoPage()
|
||||
useHead({
|
||||
titleTemplate: (title) => {
|
||||
return title ? `${title} — Le Librodrome` : 'Le librodrome'
|
||||
titleTemplate: (title) => title ? `${title} — Le Librodrome` : 'Le Librodrome',
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
ogSiteName: 'Le Librodrome',
|
||||
ogType: 'website',
|
||||
ogLocale: 'fr_FR',
|
||||
ogImage: `${siteUrl}/og-default.png?v=3`,
|
||||
ogImageWidth: 1200,
|
||||
ogImageHeight: 630,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterSite: '@librodrome',
|
||||
})
|
||||
|
||||
// JSON-LD — Organisation + Livre
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': `${siteUrl}/#organization`,
|
||||
name: 'Le Librodrome',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/og-default.png?v=3`,
|
||||
},
|
||||
description: 'Plateforme d\'autonomie numérique, économique et citoyenne à l\'échelle des bassins de vie.',
|
||||
},
|
||||
{
|
||||
'@type': 'Book',
|
||||
'@id': `${siteUrl}/economique/modele-eco#book`,
|
||||
name: 'Une économie du don — enfin concevable',
|
||||
author: { '@type': 'Person', name: 'Yvv' },
|
||||
publisher: { '@id': `${siteUrl}/#organization` },
|
||||
isbn: '979-1-042-45206-3',
|
||||
inLanguage: 'fr',
|
||||
image: `${siteUrl}/images/Couv-Economie-du-don.jpg`,
|
||||
url: `${siteUrl}/economique/modele-eco`,
|
||||
description: 'Un livre et 9 chansons pour explorer les fondements d\'une économie fondée sur le don.',
|
||||
license: 'https://creativecommons.org/licenses/by-nc/4.0/',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
meta: [
|
||||
{ name: 'description', content: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.' },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
/* This file provides fallback and utility classes */
|
||||
|
||||
.font-display {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
+33
-5
@@ -5,9 +5,9 @@
|
||||
:root {
|
||||
--color-primary: 18 80% 45%;
|
||||
--color-accent: 32 85% 50%;
|
||||
--color-bg: 20 10% 7%;
|
||||
--color-surface: 20 10% 12%;
|
||||
--color-surface-light: 20 8% 17%;
|
||||
--color-bg: 215 8% 22%;
|
||||
--color-surface: 213 7% 27%;
|
||||
--color-surface-light: 210 6% 32%;
|
||||
--color-text: 0 0% 100%;
|
||||
--color-text-muted: 0 0% 65%;
|
||||
|
||||
@@ -15,14 +15,33 @@
|
||||
--player-height: 0rem;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
--font-display: 'Outfit', sans-serif;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-display: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
--font-sans: 'Public Sans', system-ui, sans-serif;
|
||||
--font-logo: 'Syne', sans-serif;
|
||||
|
||||
/* One hue per axis — overridden by each palette (stores/palette.ts) */
|
||||
--axis-num: 198 70% 30%;
|
||||
--axis-eco: 24 88% 42%;
|
||||
--axis-cit: 150 45% 28%;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
/* A page whose birds may stand behind a card (UiOiseauPose, pose « derriere »):
|
||||
the stacking context they go back into, above the page background */
|
||||
.oiseaux-scene {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Fader level of the home console — registered so it can transition smoothly */
|
||||
@property --level {
|
||||
syntax: '<number>';
|
||||
inherits: true;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -85,6 +104,11 @@ a {
|
||||
background: hsl(var(--color-text) / 0.25);
|
||||
}
|
||||
|
||||
/* ═══ Axis scopes — each axis page takes its own hue as primary ═══ */
|
||||
.axis-scope--num { --color-primary: var(--axis-num); }
|
||||
.axis-scope--eco { --color-primary: var(--axis-eco); }
|
||||
.axis-scope--cit { --color-primary: var(--axis-cit); }
|
||||
|
||||
/* ═══ Light mode overrides ═══ */
|
||||
.palette-light {
|
||||
color-scheme: light;
|
||||
@@ -104,9 +128,13 @@ a {
|
||||
.palette-light .text-white\/45 { color: hsl(var(--color-text) / 0.52) !important; }
|
||||
.palette-light .text-white\/50 { color: hsl(var(--color-text) / 0.58) !important; }
|
||||
.palette-light .text-white\/60 { color: hsl(var(--color-text) / 0.68) !important; }
|
||||
.palette-light .text-white\/65 { color: hsl(var(--color-text) / 0.73) !important; }
|
||||
.palette-light .text-white\/70 { color: hsl(var(--color-text) / 0.78) !important; }
|
||||
.palette-light .text-white\/55 { color: hsl(var(--color-text) / 0.63) !important; }
|
||||
.palette-light .text-white\/75 { color: hsl(var(--color-text) / 0.83) !important; }
|
||||
.palette-light .text-white\/80 { color: hsl(var(--color-text) / 0.88) !important; }
|
||||
.palette-light .text-white\/85 { color: hsl(var(--color-text) / 0.92) !important; }
|
||||
.palette-light .text-white\/90 { color: hsl(var(--color-text) / 0.95) !important; }
|
||||
|
||||
/* white backgrounds → surface tones with more contrast */
|
||||
.palette-light .bg-white\/5 { background-color: hsl(var(--color-primary) / 0.05) !important; }
|
||||
|
||||
@@ -58,7 +58,7 @@ function addItem() {
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@@ -66,9 +66,9 @@ function addItem() {
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.list-item > :deep(*:first-child) {
|
||||
@@ -101,9 +101,9 @@ function addItem() {
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(20 8% 22%);
|
||||
border: 1px dashed hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@@ -111,7 +111,7 @@ function addItem() {
|
||||
}
|
||||
|
||||
.list-add:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
color: hsl(12 76% 68%);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,21 +36,21 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,15 +37,15 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
@@ -55,6 +55,6 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
|
||||
.field-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.admin-section {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
@@ -32,14 +32,14 @@ defineProps<{
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
cursor: pointer;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-section-header:hover {
|
||||
background: hsl(20 8% 8%);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.admin-section[open] .section-arrow {
|
||||
|
||||
@@ -98,7 +98,7 @@ const renderedHtml = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.md-editor {
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -107,8 +107,8 @@ const renderedHtml = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: hsl(20 8% 6%);
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-bg));
|
||||
border-bottom: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.md-tabs {
|
||||
@@ -122,24 +122,24 @@ const renderedHtml = computed(() => {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.md-tab--active {
|
||||
color: white;
|
||||
background: hsl(20 8% 10%);
|
||||
color: hsl(var(--color-text));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.md-fullscreen {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.md-fullscreen:hover,
|
||||
.md-fullscreen--active { color: white; }
|
||||
.md-fullscreen--active { color: hsl(var(--color-text)); }
|
||||
|
||||
.md-body {
|
||||
display: flex;
|
||||
@@ -151,14 +151,14 @@ const renderedHtml = computed(() => {
|
||||
}
|
||||
|
||||
.md-body--split .md-preview {
|
||||
border-left: 1px solid hsl(20 8% 14%);
|
||||
border-left: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.md-body--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: hsl(20 8% 4%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.md-body--fullscreen .md-textarea,
|
||||
@@ -170,8 +170,8 @@ const renderedHtml = computed(() => {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
@@ -189,6 +189,6 @@ const renderedHtml = computed(() => {
|
||||
min-height: 24rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: hsl(20 8% 4%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,18 +102,18 @@ function formatSize(bytes: number): string {
|
||||
.filter-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn--active {
|
||||
background: hsl(12 76% 48% / 0.15);
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
color: hsl(12 76% 68%);
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
@@ -123,7 +123,7 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
.media-card {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -131,18 +131,18 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
border-color: hsl(20 8% 22%);
|
||||
border-color: hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.media-card--selected {
|
||||
border-color: hsl(12 76% 48%);
|
||||
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3);
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.media-thumb img {
|
||||
@@ -156,8 +156,8 @@ function formatSize(bytes: number): string {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 6%);
|
||||
color: hsl(20 8% 40%);
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.media-info {
|
||||
@@ -169,7 +169,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
.media-name {
|
||||
font-size: 0.72rem;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -177,7 +177,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
.media-size {
|
||||
font-size: 0.65rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
@@ -186,9 +186,9 @@ function formatSize(bytes: number): string {
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
|
||||
@@ -73,7 +73,7 @@ async function upload(files: FileList) {
|
||||
|
||||
<style scoped>
|
||||
.upload-zone {
|
||||
border: 2px dashed hsl(20 8% 22%);
|
||||
border: 2px dashed hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -82,12 +82,12 @@ async function upload(files: FileList) {
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.upload-zone--active {
|
||||
border-color: hsl(12 76% 48%);
|
||||
background: hsl(12 76% 48% / 0.05);
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
@@ -95,7 +95,7 @@ async function upload(files: FileList) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ defineEmits<{
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
@@ -40,7 +40,7 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
|
||||
@@ -23,13 +23,25 @@
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Pages</p>
|
||||
<NuxtLink to="/admin/pages" class="sidebar-link" exact-active-class="sidebar-link--active">
|
||||
<div class="i-lucide-file-text h-4 w-4" />
|
||||
Toutes les pages
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-home h-4 w-4" />
|
||||
Accueil
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/modele-eco" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-book-open h-4 w-4" />
|
||||
Modèle éco
|
||||
<NuxtLink to="/admin/pages/numerique" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-monitor h-4 w-4" />
|
||||
Numérique
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/economique" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-coins h-4 w-4" />
|
||||
Économique
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/citoyenne" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-landmark h-4 w-4" />
|
||||
Citoyenne
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/en-musique" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-headphones h-4 w-4" />
|
||||
@@ -79,8 +91,8 @@ async function logout() {
|
||||
|
||||
<style scoped>
|
||||
.admin-sidebar {
|
||||
background: hsl(20 8% 5%);
|
||||
border-right: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-bg));
|
||||
border-right: 1px solid hsl(var(--color-surface-light));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
@@ -90,7 +102,7 @@ async function logout() {
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
border-bottom: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@@ -104,7 +116,7 @@ async function logout() {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
padding: 1rem 0.75rem 0.375rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -116,7 +128,7 @@ async function logout() {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
@@ -126,18 +138,18 @@ async function logout() {
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.sidebar-link--active {
|
||||
background: hsl(12 76% 48% / 0.12);
|
||||
color: hsl(12 76% 68%);
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid hsl(20 8% 14%);
|
||||
border-top: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:gap-4">
|
||||
<UiBaseButton @click="$emit('open-player')">
|
||||
<div class="i-lucide-play mr-2 h-5 w-5" />
|
||||
Présentation musicale
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
|
||||
<div class="i-lucide-book-open mr-2 h-5 w-5" />
|
||||
Lire le livre
|
||||
</UiBaseButton>
|
||||
<UiBaseButton v-if="showChapters" variant="ghost" to="/economique/modele-eco">
|
||||
<div class="i-lucide-list mr-2 h-5 w-5" />
|
||||
Présentation des chapitres
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" to="/economique/commande">
|
||||
<div class="i-lucide-shopping-bag mr-2 h-5 w-5" />
|
||||
Commandez le livre
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
showChapters?: boolean
|
||||
}>(), {
|
||||
showChapters: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="pdf-overlay">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="pdf-reader"
|
||||
@@ -22,38 +21,58 @@
|
||||
<!-- PDF embed -->
|
||||
<div class="pdf-viewport">
|
||||
<iframe
|
||||
:key="pdfUrl"
|
||||
:src="pdfUrl"
|
||||
class="pdf-frame"
|
||||
:title="bpContent?.pdf.iframeTitle"
|
||||
ref="iframeRef"
|
||||
@load="iframeRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const props = defineProps<{ modelValue: boolean; page?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
const { trackPdfOpen, trackPdfClose } = useTracking()
|
||||
|
||||
const overlayRef = ref<HTMLElement>()
|
||||
const iframeRef = ref<HTMLIFrameElement>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const pdfUrl = '/pdf/une-economie-du-don.pdf'
|
||||
const pdfUrl = computed(() => {
|
||||
const base = bookData.getPdfUrl()
|
||||
const viewerBase = `/pdfjs/viewer.html?file=${encodeURIComponent(base)}`
|
||||
if (props.page) return `${viewerBase}#page=${props.page}`
|
||||
return viewerBase
|
||||
})
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Tracking state
|
||||
let pdfOpenedAt = 0
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
trackPdfOpen(props.page ? `chapter-p${props.page}` : 'direct')
|
||||
pdfOpenedAt = Date.now()
|
||||
}
|
||||
else if (pdfOpenedAt > 0) {
|
||||
trackPdfClose(0, Date.now() - pdfOpenedAt)
|
||||
pdfOpenedAt = 0
|
||||
}
|
||||
if (import.meta.client) {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
@@ -91,7 +110,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
@@ -130,15 +149,4 @@ onUnmounted(() => {
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Overlay transitions */
|
||||
.pdf-overlay-enter-active {
|
||||
animation: pdf-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.pdf-overlay-leave-active {
|
||||
animation: pdf-enter 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
|
||||
}
|
||||
@keyframes pdf-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Song disc -->
|
||||
<div v-if="currentSong" class="reader-song">
|
||||
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
|
||||
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
|
||||
<img src="/images/Couv-Economie-du-don.jpg" alt="" class="reader-disc-img" />
|
||||
<div class="reader-disc-hole" />
|
||||
</div>
|
||||
<span class="reader-song-name">{{ currentSong.title }}</span>
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
const { trackPlayerOpen, trackPlayerChapter, trackPlayerMode } = useTracking()
|
||||
|
||||
const COL_GAP = 80
|
||||
|
||||
@@ -167,6 +168,7 @@ const scrollPercent = ref(0)
|
||||
|
||||
// When switching back to paginated, recalc pages
|
||||
watch(readingMode, async (mode) => {
|
||||
trackPlayerMode(mode === 'scroll' ? 'scroll' : 'guided')
|
||||
if (mode === 'paginated') {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
@@ -288,6 +290,7 @@ function goToTrack(idx: number) {
|
||||
// Play the song
|
||||
const song = tracks.value[idx]?.song
|
||||
if (song) {
|
||||
trackPlayerChapter(song.id)
|
||||
_skipSongWatch = true
|
||||
audioPlayer.loadAndPlay(song)
|
||||
}
|
||||
@@ -375,9 +378,19 @@ function onTouchEnd(e: TouchEvent) {
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
// While the reader is open, the persistent mini player (above it) lifts up
|
||||
// so it does not cover the reader navigation.
|
||||
if (import.meta.client) {
|
||||
watch(isOpen, (open) => {
|
||||
document.documentElement.classList.toggle('book-player-open', open)
|
||||
}, { immediate: true })
|
||||
onUnmounted(() => document.documentElement.classList.remove('book-player-open'))
|
||||
}
|
||||
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
showSommaire.value = false
|
||||
trackPlayerOpen()
|
||||
await initBookData()
|
||||
await nextTick()
|
||||
overlayRef.value?.focus()
|
||||
@@ -447,7 +460,7 @@ onUnmounted(() => {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: hsl(20 8% 3%);
|
||||
background: hsl(var(--color-bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -538,7 +551,7 @@ onUnmounted(() => {
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
font-size: 0.68rem;
|
||||
color: hsl(20 8% 28%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -582,7 +595,7 @@ onUnmounted(() => {
|
||||
width: 2.25rem; height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: hsl(20 8% 45%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
@@ -602,7 +615,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.reader-bar-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(0.85rem, 2.5vw, 1.1rem);
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
@@ -617,7 +630,7 @@ onUnmounted(() => {
|
||||
.reader-bar-pages {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -626,16 +639,16 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 72;
|
||||
background: hsl(20 8% 3% / 0.6);
|
||||
background: hsl(var(--color-bg) / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
}
|
||||
.sommaire-panel {
|
||||
width: min(300px, 80vw);
|
||||
height: 100%;
|
||||
background: hsl(20 8% 6% / 0.95);
|
||||
background: hsl(var(--color-surface) / 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
border-right: 1px solid hsl(20 8% 14%);
|
||||
border-right: 1px solid hsl(var(--color-surface-light));
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -643,10 +656,10 @@ onUnmounted(() => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sommaire-title {
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(20 8% 45%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -658,7 +671,7 @@ onUnmounted(() => {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -667,7 +680,7 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
.sommaire-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
background: hsl(var(--color-surface));
|
||||
color: white;
|
||||
}
|
||||
.sommaire-item--active {
|
||||
@@ -683,8 +696,8 @@ onUnmounted(() => {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: hsl(20 8% 40%);
|
||||
background: hsl(20 8% 12%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: hsl(var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sommaire-item--active .sommaire-num {
|
||||
@@ -713,7 +726,7 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
overflow: hidden auto;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(20 8% 5% / 0.4);
|
||||
background: hsl(var(--color-bg) / 0.4);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
@@ -809,7 +822,7 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: linear-gradient(to left, hsl(20 8% 3% / 0.4), transparent);
|
||||
background: linear-gradient(to left, hsl(var(--color-bg) / 0.4), transparent);
|
||||
transition: opacity 0.15s;
|
||||
border-radius: 0 0.75rem 0.75rem 0;
|
||||
}
|
||||
@@ -833,7 +846,7 @@ onUnmounted(() => {
|
||||
width: 2.5rem; height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: hsl(20 8% 45%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
@@ -861,7 +874,7 @@ onUnmounted(() => {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid hsl(20 8% 22%);
|
||||
border: 2px solid hsl(var(--color-surface-light));
|
||||
box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
|
||||
}
|
||||
.reader-disc.spinning {
|
||||
@@ -878,12 +891,12 @@ onUnmounted(() => {
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.5rem; height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(20 8% 5%);
|
||||
border: 1.5px solid hsl(20 8% 18%);
|
||||
background: hsl(var(--color-bg));
|
||||
border: 1.5px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
.reader-song-name {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -8,25 +8,39 @@
|
||||
· {{ readingTime }}
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="chapter-title font-display font-bold leading-tight tracking-tight text-white">
|
||||
<h1 class="chapter-title relative font-display font-bold leading-tight tracking-tight text-white">
|
||||
{{ title }}
|
||||
<!-- The calligrapher in the left margin, feet on the title's rule (rule e) -->
|
||||
<UiOiseauPose nom="calligraphe" pose="marge-g" ecart="40px" :w="112" />
|
||||
</h1>
|
||||
<p v-if="description" class="mt-3 text-lg text-white/60">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Songs + PDF actions row. The box maker in the right margin, feet at the bottom of the
|
||||
buttons (every chapter has its PDF page) -->
|
||||
<div class="relative mt-4 flex flex-wrap items-center gap-2">
|
||||
<UiOiseauPose nom="cartonnier" pose="marge-d" ecart="40px" :w="76" retourne />
|
||||
<!-- Associated songs badges -->
|
||||
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
class="song-badge-btn"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="i-lucide-music h-3 w-3" />
|
||||
{{ song.title }}
|
||||
</button>
|
||||
|
||||
<!-- PDF button -->
|
||||
<button v-if="pdfPage" class="pdf-btn" @click="showPdf = true">
|
||||
<div class="i-lucide-book-open h-3.5 w-3.5" />
|
||||
<span>Lire dans le PDF</span>
|
||||
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +58,12 @@ const props = defineProps<{
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
|
||||
|
||||
const showPdf = ref(false)
|
||||
|
||||
function playSong(song: Song) {
|
||||
loadAndPlay(song)
|
||||
@@ -59,4 +76,57 @@ function playSong(song: Song) {
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.song-badge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary) / 0.85);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.25);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.song-badge-btn:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pdf-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(0 0% 96%);
|
||||
color: hsl(0 0% 15%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid hsl(0 0% 88%);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pdf-btn:hover {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px hsl(0 0% 100% / 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pdf-page-badge {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(0 0% 88%);
|
||||
color: hsl(0 0% 35%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="chapter in chapters" :key="chapter.path">
|
||||
<NuxtLink
|
||||
:to="`/modele-eco/${chapter.stem?.split('/').pop()}`"
|
||||
:to="`/economique/modele-eco/${chapter.stem?.split('/').pop()}`"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-white/5"
|
||||
active-class="bg-primary/10 text-primary font-medium"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="chapter-toc" ref="tocRoot">
|
||||
<button class="toc-toggle" @click="open = !open" :aria-expanded="open">
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
<span>Sommaire</span>
|
||||
<div class="i-lucide-chevron-down h-3.5 w-3.5 toc-chevron" :class="{ 'toc-chevron--open': open }" />
|
||||
</button>
|
||||
|
||||
<Transition name="toc-dropdown">
|
||||
<div v-if="open" class="toc-panel">
|
||||
<nav>
|
||||
<a
|
||||
v-for="item in headings"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="toc-item"
|
||||
:class="{ 'toc-item--h2': item.level === 2 }"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TocHeading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const headings = ref<TocHeading[]>([])
|
||||
const tocRoot = ref<HTMLElement>()
|
||||
|
||||
function extractHeadings() {
|
||||
const article = document.querySelector('.prose-lyrics')
|
||||
if (!article) return
|
||||
|
||||
const nodes = article.querySelectorAll('h1, h2')
|
||||
headings.value = Array.from(nodes)
|
||||
.filter(el => el.id)
|
||||
.map(el => ({
|
||||
id: el.id,
|
||||
text: el.textContent?.trim() ?? '',
|
||||
level: parseInt(el.tagName[1]),
|
||||
}))
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (tocRoot.value && !tocRoot.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Wait for content to render
|
||||
nextTick(() => {
|
||||
extractHeadings()
|
||||
// Retry after a short delay in case content loads async
|
||||
setTimeout(extractHeadings, 500)
|
||||
})
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-toc {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toc-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(20 8% 10%);
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
color: hsl(20 8% 60%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toc-toggle:hover {
|
||||
border-color: hsl(20 8% 30%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toc-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 8%);
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 65%);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-item--h2 {
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.toc-dropdown-enter-active {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toc-dropdown-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
.toc-dropdown-enter-from,
|
||||
.toc-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -131,10 +131,9 @@ const { data: content } = await usePageContent('home')
|
||||
}
|
||||
|
||||
.book-cover-img {
|
||||
width: 200%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
|
||||
@@ -1,63 +1,47 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<section :class="compact ? 'section-book-compact' : 'section-padding'">
|
||||
<div class="container-content">
|
||||
<div class="grid items-center gap-12 md:grid-cols-2">
|
||||
<div :class="['grid items-center', compact ? 'gap-5 md:grid-cols-[auto_1fr]' : 'gap-12 md:grid-cols-2']">
|
||||
<!-- Book cover -->
|
||||
<UiScrollReveal>
|
||||
<div class="book-cover-wrapper relative">
|
||||
<!-- Shadok pumper -->
|
||||
<svg class="shadok-pumper" viewBox="0 0 200 240" fill="none" aria-hidden="true">
|
||||
<ellipse cx="100" cy="130" rx="55" ry="65" fill="currentColor" opacity="0.9"/>
|
||||
<ellipse cx="100" cy="60" rx="30" ry="28" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="88" cy="54" r="6" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="112" cy="54" r="6" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="90" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="114" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
|
||||
<polygon points="100,68 115,78 85,78" fill="currentColor" opacity="0.6"/>
|
||||
<line x1="80" y1="192" x2="70" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="120" y1="192" x2="130" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
|
||||
<line x1="70" y1="230" x2="55" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="130" y1="230" x2="145" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="155" y1="110" x2="190" y2="90" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="190" y1="90" x2="190" y2="120" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
|
||||
<rect x="180" y="118" width="18" height="40" rx="3" fill="currentColor" opacity="0.4"/>
|
||||
</svg>
|
||||
<div class="book-cover-3d">
|
||||
<img
|
||||
:src="content?.book.coverImage"
|
||||
:alt="content?.book.coverAlt"
|
||||
class="book-cover-img"
|
||||
<div
|
||||
:class="['book-cover-3d', compact && 'book-cover-3d--compact']"
|
||||
:style="{ backgroundImage: `url(${content?.book.coverImage})` }"
|
||||
role="img"
|
||||
:aria-label="content?.book.coverAlt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Content + CTAs -->
|
||||
<div>
|
||||
<UiScrollReveal>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.book.title }}
|
||||
<p :class="['font-mono tracking-widest text-accent uppercase', compact ? 'text-xs mb-1' : 'text-sm mb-2']">
|
||||
{{ content?.book.kicker }}
|
||||
</p>
|
||||
<h2 :class="['book-heading font-display font-bold tracking-tight text-white', compact && 'book-heading--compact']">
|
||||
<template v-if="compact">
|
||||
<span class="block">{{ titleLine1 }}</span>
|
||||
<span class="block" style="color: hsl(var(--color-text) / 0.55)">{{ titleLine2 }}</span>
|
||||
</template>
|
||||
<template v-else>{{ content?.book.title }}</template>
|
||||
</h2>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<p class="mt-4 text-lg leading-relaxed text-white/60">
|
||||
<p :class="['leading-relaxed', compact ? 'mt-2 text-sm' : 'mt-4 text-lg text-white/60']"
|
||||
:style="compact ? 'color: hsl(var(--color-text-muted)); font-size: 0.85rem' : ''">
|
||||
{{ content?.book.description }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<UiBaseButton @click="$emit('open-player')">
|
||||
<div class="i-lucide-play mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.player }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
|
||||
<div class="i-lucide-book-open mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.pdf }}
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
<BookActions
|
||||
:show-chapters="showChapters"
|
||||
@open-player="$emit('open-player')"
|
||||
@open-pdf="$emit('open-pdf')"
|
||||
/>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,15 +50,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
showChapters?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
showChapters: true,
|
||||
compact: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const { data: content } = await usePageContent('home')
|
||||
|
||||
const titleParts = computed(() => {
|
||||
const title = content.value?.book.title ?? ''
|
||||
const idx = title.indexOf('—')
|
||||
if (idx === -1) return [title, '']
|
||||
return [title.slice(0, idx).trim(), '— ' + title.slice(idx + 1).trim()]
|
||||
})
|
||||
const titleLine1 = computed(() => titleParts.value[0])
|
||||
const titleLine2 = computed(() => titleParts.value[1])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-book-compact {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.book-cover-wrapper {
|
||||
perspective: 800px;
|
||||
display: flex;
|
||||
@@ -82,7 +87,12 @@ const { data: content } = await usePageContent('home')
|
||||
}
|
||||
|
||||
.book-cover-3d {
|
||||
aspect-ratio: 3 / 4;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 1756 / 2480;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
@@ -91,7 +101,10 @@ const { data: content } = await usePageContent('home')
|
||||
0 0 0 1px hsl(var(--color-text) / 0.08);
|
||||
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
box-shadow 0.5s ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.book-cover-3d--compact {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.book-cover-3d:hover {
|
||||
@@ -101,35 +114,13 @@ const { data: content } = await usePageContent('home')
|
||||
0 0 0 1px hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.book-cover-img {
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
.book-heading {
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
}
|
||||
|
||||
.shadok-pumper {
|
||||
position: absolute;
|
||||
right: 3%;
|
||||
bottom: 8%;
|
||||
width: clamp(90px, 12vw, 180px);
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float 10s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes shadok-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-pumper { display: none; }
|
||||
.book-heading--compact {
|
||||
font-size: clamp(0.95rem, 2.5vw, 1.15rem);
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<UiScrollReveal>
|
||||
<div class="gw-card relative overflow-hidden">
|
||||
<!-- Shadok blob -->
|
||||
<svg class="shadok-blob" viewBox="0 0 200 180" fill="none" aria-hidden="true">
|
||||
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" fill="currentColor" opacity="0.12"/>
|
||||
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
|
||||
<circle cx="100" cy="80" r="8" fill="currentColor" opacity="0.08"/>
|
||||
<circle cx="120" cy="110" r="6" fill="currentColor" opacity="0.06"/>
|
||||
<circle cx="80" cy="105" r="5" fill="currentColor" opacity="0.07"/>
|
||||
<circle cx="95" cy="72" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="108" cy="70" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="96" cy="71" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="109" cy="69" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
</svg>
|
||||
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8 relative z-1">
|
||||
<!-- Icon -->
|
||||
<div class="gw-icon-wrapper">
|
||||
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<span class="inline-block mb-2 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.grateWizardTeaser.kicker }}
|
||||
</span>
|
||||
<h3 class="heading-h3 font-display font-bold text-white">
|
||||
{{ content?.grateWizardTeaser.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-white/60 md:text-base leading-relaxed">
|
||||
{{ content?.grateWizardTeaser.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<UiBaseButton :href="url" target="_blank" @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
{{ content?.grateWizardTeaser.cta.launch }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" :to="content?.grateWizardTeaser.cta.more.to">
|
||||
{{ content?.grateWizardTeaser.cta.more.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { url, launch } = useGrateWizard()
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gw-card {
|
||||
border: 1px solid hsl(40 80% 50% / 0.2);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, hsl(40 80% 50% / 0.05), hsl(40 80% 50% / 0.02));
|
||||
box-shadow: 0 0 40px hsl(40 80% 50% / 0.05);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.gw-card:hover {
|
||||
border-color: hsl(40 80% 50% / 0.35);
|
||||
box-shadow: 0 0 60px hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
|
||||
.heading-h3 {
|
||||
font-size: clamp(1.25rem, 3vw, 1.625rem);
|
||||
}
|
||||
|
||||
.gw-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
border: 1px solid hsl(40 80% 50% / 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shadok-blob {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: -20%;
|
||||
width: clamp(120px, 16vw, 220px);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-drift 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-drift {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-blob { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden section-padding">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
|
||||
|
||||
<!-- Shadok bird decoration -->
|
||||
<svg class="shadok-bird" viewBox="0 0 180 260" fill="none" aria-hidden="true">
|
||||
<ellipse cx="90" cy="100" rx="45" ry="40" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="130" cy="60" r="22" fill="currentColor" opacity="0.8"/>
|
||||
<path d="M110 85 Q125 70 128 63" stroke="currentColor" stroke-width="8" stroke-linecap="round" opacity="0.7" fill="none"/>
|
||||
<circle cx="136" cy="55" r="5" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="137" cy="54" r="2" fill="currentColor" opacity="0.6"/>
|
||||
<polygon points="150,58 175,50 152,65" fill="currentColor" opacity="0.6"/>
|
||||
<line x1="75" y1="138" x2="60" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="105" y1="138" x2="115" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<circle cx="66" cy="190" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="111" cy="190" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<path d="M60 230 L45 233 M60 230 L55 236 M60 230 L65 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M115 230 L100 233 M115 230 L110 236 M115 230 L120 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M48 95 Q20 80 15 65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5" fill="none"/>
|
||||
<path d="M48 100 Q22 92 10 85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4" fill="none"/>
|
||||
<path d="M48 105 Q25 102 12 100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3" fill="none"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok boulanger: character with oven and bread -->
|
||||
<svg class="shadok-boulanger" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
|
||||
<ellipse cx="120" cy="68" rx="18" ry="22" fill="currentColor" opacity="0.35"/>
|
||||
<rect x="105" y="78" width="30" height="5" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<path d="M110 88 Q114 84 118 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M124 88 Q128 84 132 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M112 102 Q120 108 128 102" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<line x1="160" y1="145" x2="190" y2="135" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<rect x="185" y="125" width="40" height="10" rx="5" fill="currentColor" opacity="0.4" transform="rotate(-15 205 130)"/>
|
||||
<line x1="80" y1="148" x2="55" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="105" y1="200" x2="95" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="135" y1="200" x2="145" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<rect x="30" y="180" width="50" height="40" rx="4" fill="currentColor" opacity="0.25"/>
|
||||
<rect x="35" y="185" width="40" height="20" rx="2" fill="currentColor" opacity="0.15"/>
|
||||
<ellipse cx="55" cy="195" rx="12" ry="6" fill="currentColor" opacity="0.12"/>
|
||||
</svg>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="container-content relative z-10 px-4">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<UiScrollReveal>
|
||||
<p class="mb-3 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
{{ content?.hero.kicker }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<h1 class="font-display font-extrabold leading-tight tracking-tight">
|
||||
<span class="hero-title text-gradient">{{ content?.hero.title }}</span>
|
||||
</h1>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<p class="mt-6 text-lg leading-relaxed text-white/60 md:text-xl">
|
||||
{{ content?.hero.subtitle }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="300">
|
||||
<p class="mt-4 text-base leading-relaxed text-white/45">
|
||||
{{ content?.hero.footnote }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="400">
|
||||
<div class="mt-8 flex justify-center">
|
||||
<UiBaseButton variant="ghost" :to="content?.hero.cta.to">
|
||||
{{ content?.hero.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<HomeMessages />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-title {
|
||||
font-size: clamp(2.25rem, 7vw, 4rem);
|
||||
}
|
||||
|
||||
.shadok-bird {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
top: 12%;
|
||||
width: clamp(110px, 15vw, 220px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float 8s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes shadok-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
.shadok-boulanger {
|
||||
position: absolute;
|
||||
left: 3%;
|
||||
bottom: 8%;
|
||||
width: clamp(100px, 13vw, 190px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-boulanger 9s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-boulanger {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-bird { display: none; }
|
||||
.shadok-boulanger { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<blockquote v-if="lines?.length" class="cites">
|
||||
<p v-for="(line, i) in lines" :key="i" class="cite" :class="{ 'cite--lead': i === 0 }">{{ line }}</p>
|
||||
</blockquote>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
lines: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cites {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.cite {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: clamp(1.15rem, 2.4vw, 1.55rem);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: hsl(var(--color-text) / 0.62);
|
||||
}
|
||||
|
||||
.cite + .cite {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.cite--lead {
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.6rem, 3.6vw, 2.3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(var(--color-text));
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,708 @@
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="console"
|
||||
:class="{ 'console--compact': compact, 'console--still': reduced, 'console--hand': engaged }"
|
||||
@pointerenter="pointerIn = true"
|
||||
@pointerleave="pointerIn = false"
|
||||
@focusin="focusIn = true"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<div class="console-board">
|
||||
<!-- Scale, read bottom to top: from asservissement to émancipation -->
|
||||
<div class="console-scale" aria-hidden="true">
|
||||
<span class="console-scale-bottom">{{ bottomLabel }}</span>
|
||||
<span class="console-scale-line" />
|
||||
<span class="console-scale-top">{{ topLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="console-faders"
|
||||
role="tablist"
|
||||
:aria-label="`Les trois axes — ${bottomLabel} ou ${topLabel}`"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-for="(axis, i) in axes"
|
||||
:id="`fader-${uid}-${axis.key}`"
|
||||
:key="axis.key"
|
||||
ref="tabs"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="fader"
|
||||
:class="{ 'fader--active': axis.key === active, 'fader--held': held.has(axis.key), 'fader--rising': rising.has(axis.key), 'fader--stopped': stopped.has(axis.key) }"
|
||||
:style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }"
|
||||
:data-axis="axis.key"
|
||||
:aria-selected="axis.key === active"
|
||||
:aria-controls="`panel-${uid}`"
|
||||
:tabindex="axis.key === active ? 0 : -1"
|
||||
@pointerdown="lastPointer = $event.pointerType"
|
||||
@pointerenter="onEnter($event, axis.key)"
|
||||
@pointerleave="hoverKey = null"
|
||||
@focus="onFocus($event, axis.key)"
|
||||
@click="onClick($event, axis.key)"
|
||||
>
|
||||
<span class="fader-track" aria-hidden="true">
|
||||
<span class="fader-fill" />
|
||||
<span class="fader-cap" />
|
||||
</span>
|
||||
<span class="fader-label">
|
||||
<span class="fader-prefix">{{ prefix }}</span>
|
||||
<span class="fader-name">{{ axis.short }}</span>
|
||||
</span>
|
||||
<span class="fader-lever">{{ axis.lever }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="current"
|
||||
:id="`panel-${uid}`"
|
||||
role="tabpanel"
|
||||
class="console-panel"
|
||||
:style="{ '--hue': `var(${current.hue})` }"
|
||||
:aria-labelledby="`fader-${uid}-${current.key}`"
|
||||
>
|
||||
<Transition name="panel" mode="out-in">
|
||||
<div :key="current.key" class="panel-inner">
|
||||
<p class="panel-upstream">
|
||||
<span class="panel-upstream-label">{{ upstreamLabel }}</span>
|
||||
{{ current.lever }}
|
||||
</p>
|
||||
<h2 class="panel-title">{{ current.title }}</h2>
|
||||
<p v-if="current.promise" class="panel-promise">{{ current.promise }}</p>
|
||||
|
||||
<ul class="panel-tools">
|
||||
<li v-for="tool in current.items" :key="tool.label">
|
||||
<NuxtLink v-if="tool.to" :to="tool.to" class="panel-tool">
|
||||
<span class="panel-tool-name">{{ tool.label }}</span>
|
||||
<span v-if="tool.presentation" class="panel-tool-app">{{ tool.presentation.title }}</span>
|
||||
<span v-if="tool.status || tool.gestation" class="panel-tool-state">{{ (tool.status || 'En gestation').toLowerCase() }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="current.key === 'numerique' && stacks?.length" class="panel-stacks">
|
||||
<span class="panel-stacks-label">{{ stacksLabel }}</span>
|
||||
<span v-for="s in stacks.slice(0, maxStacks)" :key="s.name" class="panel-stack">{{ s.name }}</span>
|
||||
<NuxtLink v-if="stacks.length > maxStacks && stacksTo" :to="stacksTo" class="panel-stacks-more">
|
||||
+{{ stacks.length - maxStacks }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<NuxtLink :to="current.to" class="panel-more">
|
||||
Voir l'axe {{ current.short }}
|
||||
<span class="i-lucide-arrow-right h-4 w-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AxisKey, HomeAxis, HomeStack } from '~/composables/useHomeData'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
axes: HomeAxis[]
|
||||
prefix?: string
|
||||
topLabel?: string
|
||||
bottomLabel?: string
|
||||
upstreamLabel?: string
|
||||
stacks?: HomeStack[]
|
||||
stacksLabel?: string
|
||||
stacksTo?: string
|
||||
maxStacks?: number
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
prefix: 'Autonomie',
|
||||
topLabel: 'Émancipation',
|
||||
bottomLabel: 'Asservissement',
|
||||
maxStacks: 8,
|
||||
upstreamLabel: 'en amont',
|
||||
stacksLabel: 'En service',
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const active = defineModel<AxisKey>({ default: 'numerique' })
|
||||
const uid = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const tabs = ref<HTMLButtonElement[]>([])
|
||||
|
||||
const current = computed(() => props.axes.find(a => a.key === active.value) ?? props.axes[0])
|
||||
|
||||
// ── Motion ──────────────────────────────────────────────────────────────
|
||||
// Yvv's instructions, word for word: docs/consignes-yvv.md (29/09 and 03/11).
|
||||
//
|
||||
// Before any gesture (29/09): the cursors move on their own, slowly; the low
|
||||
// ones drift, and every few beats another axis is pushed up to its own
|
||||
// graduation (03/11), the level the hand gives it.
|
||||
// SSR renders every cursor at the bottom, so the first move is the rise.
|
||||
//
|
||||
// The visitor's hand takes over for good (03/11): from the first hover,
|
||||
// keyboard focus or tap on a cursor, the rotation stops. The touched cursor
|
||||
// replays its rise, from the bottom to its own graduation, and its panel
|
||||
// shows. The others are never lowered by the hand (orch decision, 03/11
|
||||
// 13h10): a cursor already touched stays at its graduation; a cursor never
|
||||
// touched that the rotation was taking down stops where it is at the first
|
||||
// gesture; one it was taking up finishes, gently, its rise to the level the
|
||||
// rotation gave it, then stays there.
|
||||
const BEAT_MS = 2600
|
||||
const BEATS_PER_AXIS = 3
|
||||
/** Graduation of each axis, from asservissement (0) to émancipation (1) */
|
||||
const GRADUATION: Record<AxisKey, number> = { numerique: 3 / 4, economie: 1 / 2, politique: 2 / 3 }
|
||||
/** The focus and the click of one tap, within this delay, give one rise */
|
||||
const SAME_GESTURE_MS = 500
|
||||
|
||||
const levels = reactive<Partial<Record<AxisKey, number>>>({})
|
||||
/** Cursors the hand has touched: they no longer glide with the rotation */
|
||||
const held = reactive(new Set<AxisKey>())
|
||||
/** Cursors replaying their rise (fast transition) */
|
||||
const rising = reactive(new Set<AxisKey>())
|
||||
/** Untouched cursors the first gesture stopped on their way down */
|
||||
const stopped = reactive(new Set<AxisKey>())
|
||||
const reduced = ref(false)
|
||||
const visible = ref(true)
|
||||
const engaged = ref(false)
|
||||
const pointerIn = ref(false)
|
||||
const focusIn = ref(false)
|
||||
const hoverKey = ref<AxisKey | null>(null)
|
||||
let lastPointer = ''
|
||||
/** The tab the arrow keys are moving the focus to: its focus is the same gesture */
|
||||
let keyFocus: AxisKey | null = null
|
||||
/** When a keyboard focus last raised each cursor (to pair it with the click of a tap) */
|
||||
const focusRiseAt: Partial<Record<AxisKey, number>> = {}
|
||||
const riseRun: Partial<Record<AxisKey, number>> = {}
|
||||
let beat = 0
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function place() {
|
||||
props.axes.forEach((axis, i) => {
|
||||
// The axis put forward goes up to its own graduation, as under the hand
|
||||
if (axis.key === active.value) {
|
||||
levels[axis.key] = GRADUATION[axis.key] ?? 0.75
|
||||
return
|
||||
}
|
||||
levels[axis.key] = reduced.value ? 0.3 : 0.3 + 0.14 * Math.sin(beat * 0.9 + i * 2.1)
|
||||
})
|
||||
}
|
||||
|
||||
function faderEl(key: AxisKey) {
|
||||
return root.value?.querySelector<HTMLElement>(`[data-axis="${key}"]`) ?? null
|
||||
}
|
||||
|
||||
/** First gesture: the rotation stops for good. An untouched cursor on its way down stops
|
||||
* where it is drawn; one on its way up finishes its glide to the target the rotation gave it.
|
||||
* `touched` is the cursor of this gesture: it replays its rise anyway */
|
||||
function engage(touched?: AxisKey) {
|
||||
if (engaged.value) return
|
||||
engaged.value = true
|
||||
clearInterval(timer)
|
||||
for (const axis of props.axes) {
|
||||
const key = axis.key
|
||||
if (key === touched || held.has(key)) continue
|
||||
const el = faderEl(key)
|
||||
const drawn = el ? Number.parseFloat(getComputedStyle(el).getPropertyValue('--level')) : Number.NaN
|
||||
const target = levels[key] ?? 0
|
||||
if (Number.isFinite(drawn) && drawn > target) {
|
||||
stopped.add(key)
|
||||
levels[key] = drawn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The touched cursor replays its rise: back to the bottom, then up to its graduation */
|
||||
async function rise(key: AxisKey) {
|
||||
held.add(key)
|
||||
stopped.delete(key)
|
||||
const target = GRADUATION[key] ?? 0.75
|
||||
if (reduced.value) {
|
||||
levels[key] = target
|
||||
return
|
||||
}
|
||||
const run = (riseRun[key] ?? 0) + 1
|
||||
riseRun[key] = run
|
||||
rising.delete(key)
|
||||
levels[key] = 0
|
||||
await nextTick()
|
||||
// Commit the bottom (no transition) before the rise starts from it
|
||||
const el = faderEl(key)
|
||||
if (el) getComputedStyle(el).getPropertyValue('--level')
|
||||
if (riseRun[key] !== run) return
|
||||
rising.add(key)
|
||||
levels[key] = target
|
||||
}
|
||||
|
||||
/** A cursor under the visitor's hand: it rises and its panel shows. Every rollover replays
|
||||
* the rise, however soon after the previous one (Yvv, 03/11) */
|
||||
function touch(key: AxisKey) {
|
||||
engage(key)
|
||||
active.value = key
|
||||
rise(key)
|
||||
}
|
||||
|
||||
/** The panel of a cursor already raised by this same gesture: no second rise */
|
||||
function select(key: AxisKey) {
|
||||
engage(key)
|
||||
active.value = key
|
||||
}
|
||||
|
||||
function onEnter(e: PointerEvent, key: AxisKey) {
|
||||
// A finger scrolling over a cursor is not a rollover: taps go through the click
|
||||
if (e.pointerType === 'touch') return
|
||||
hoverKey.value = key
|
||||
touch(key)
|
||||
}
|
||||
|
||||
function isKeyboardFocus(el: HTMLElement) {
|
||||
try {
|
||||
return el.matches(':focus-visible')
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus(e: FocusEvent, key: AxisKey) {
|
||||
// The arrow key that moved the focus here already raised the cursor
|
||||
if (keyFocus === key) return
|
||||
// Keyboard focus is a rollover; a focus from the mouse or a tap is handled elsewhere
|
||||
if (hoverKey.value === key || !isKeyboardFocus(e.currentTarget as HTMLElement)) return
|
||||
focusRiseAt[key] = Date.now()
|
||||
touch(key)
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent, key: AxisKey) {
|
||||
const pointer = (e as PointerEvent).pointerType || lastPointer
|
||||
lastPointer = ''
|
||||
// A tap is a rollover: one rise per tap, even when the browser also gave it a keyboard focus
|
||||
const tapFocused = Date.now() - (focusRiseAt[key] ?? 0) < SAME_GESTURE_MS
|
||||
if (pointer === 'touch' && !tapFocused) return touch(key)
|
||||
// A click with no hover nor focus before it (a screen reader, a script) raises the cursor
|
||||
// with its panel; after a hover or a keyboard focus, the cursor is already up
|
||||
if (hoverKey.value !== key && !held.has(key)) return touch(key)
|
||||
select(key)
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
focusIn.value = !!root.value?.contains(e.relatedTarget as Node | null)
|
||||
}
|
||||
|
||||
function advance() {
|
||||
const idx = props.axes.findIndex(a => a.key === active.value)
|
||||
active.value = props.axes[(idx + 1) % props.axes.length]!.key
|
||||
}
|
||||
|
||||
function onBeat() {
|
||||
if (!visible.value || engaged.value) return
|
||||
beat++
|
||||
if (!pointerIn.value && !focusIn.value && beat % BEATS_PER_AXIS === 0) advance()
|
||||
place()
|
||||
}
|
||||
|
||||
watch(active, () => {
|
||||
if (!engaged.value) place()
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
lastPointer = ''
|
||||
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
|
||||
if (!keys.includes(e.key)) return
|
||||
e.preventDefault()
|
||||
// From the tab that has the focus: a hover may have selected another axis meanwhile
|
||||
const from = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-axis]')?.dataset.axis
|
||||
const idx = props.axes.findIndex(a => a.key === (from ?? active.value))
|
||||
const last = props.axes.length - 1
|
||||
let next = idx
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = idx === last ? 0 : idx + 1
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = idx === 0 ? last : idx - 1
|
||||
if (e.key === 'Home') next = 0
|
||||
if (e.key === 'End') next = last
|
||||
const key = props.axes[next]!.key
|
||||
touch(key)
|
||||
nextTick(() => {
|
||||
// focus() fires the focus event at once: it is part of this key press, not a new rise
|
||||
keyFocus = key
|
||||
tabs.value[next]?.focus()
|
||||
keyFocus = null
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reduced.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
requestAnimationFrame(place)
|
||||
if (reduced.value) return
|
||||
|
||||
timer = setInterval(onBeat, BEAT_MS)
|
||||
const io = new IntersectionObserver(([entry]) => { visible.value = !!entry?.isIntersecting })
|
||||
if (root.value) io.observe(root.value)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
io.disconnect()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.console {
|
||||
--track-h: 11rem;
|
||||
--cap-h: 1.25rem;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.console--compact {
|
||||
--track-h: 8rem;
|
||||
}
|
||||
|
||||
/* ── Board ── */
|
||||
.console-board {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.9rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--color-surface));
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--color-text) / 0.04),
|
||||
0 18px 40px -24px hsl(var(--color-text) / 0.35);
|
||||
}
|
||||
|
||||
/* Vertical scale, read from bottom to top */
|
||||
.console-scale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: var(--track-h);
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console-scale-bottom {
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.console-scale-top {
|
||||
font-weight: 800;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.console-scale-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, hsl(var(--color-text) / 0.12), hsl(var(--color-text) / 0.45));
|
||||
}
|
||||
|
||||
.console-faders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Fader ── */
|
||||
.fader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.25rem 0.25rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
/* --level is a registered property (main.css): the cursors glide */
|
||||
transition: --level 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95);
|
||||
transition-delay: calc(var(--i) * 160ms);
|
||||
}
|
||||
|
||||
/* A cursor the hand has touched no longer glides: it drops to the bottom at once, then
|
||||
replays its rise. At the first gesture, an untouched cursor on its way down stops where it
|
||||
is; one on its way up finishes its glide, then the rotation is over */
|
||||
.fader--held,
|
||||
.fader--stopped {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.fader--held.fader--rising {
|
||||
transition: --level 1.1s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.fader:focus-visible {
|
||||
outline: 2px solid hsl(var(--hue));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fader-track {
|
||||
position: relative;
|
||||
width: 0.5rem;
|
||||
height: var(--track-h);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-text) / 0.1);
|
||||
}
|
||||
|
||||
.fader-fill {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: calc(var(--level) * (var(--track-h) - var(--cap-h)) + var(--cap-h) / 2);
|
||||
border-radius: inherit;
|
||||
background: hsl(var(--hue) / 0.45);
|
||||
transition: background 0.6s ease;
|
||||
}
|
||||
|
||||
.fader--active .fader-fill {
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.fader-cap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 2.6rem;
|
||||
height: var(--cap-h);
|
||||
margin-left: -1.3rem;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--color-bg));
|
||||
box-shadow:
|
||||
0 1px 0 hsl(var(--color-text) / 0.08),
|
||||
0 4px 10px -2px hsl(var(--color-text) / 0.3);
|
||||
transform: translateY(calc(-1 * var(--level) * (var(--track-h) - var(--cap-h))));
|
||||
}
|
||||
|
||||
/* Cap groove */
|
||||
.fader-cap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.45rem;
|
||||
right: 0.45rem;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
margin-top: -1px;
|
||||
border-radius: 2px;
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.fader-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.fader-prefix {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-text) / 0.5);
|
||||
}
|
||||
|
||||
.fader-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: hsl(var(--color-text) / 0.65);
|
||||
transition: color 0.6s ease;
|
||||
}
|
||||
|
||||
.fader--active .fader-name {
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.fader-lever {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-text) / 0.8);
|
||||
background: hsl(var(--hue) / 0.1);
|
||||
transition: background 0.6s ease, color 0.6s ease;
|
||||
}
|
||||
|
||||
.fader--active .fader-lever {
|
||||
color: hsl(var(--color-bg));
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
/* ── Panel ── */
|
||||
.console-panel {
|
||||
position: relative;
|
||||
padding: 1.25rem 1.4rem 1.2rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--hue) / 0.08);
|
||||
}
|
||||
|
||||
.panel-upstream {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.panel-upstream-label {
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.panel-upstream-label::after {
|
||||
content: ' ↑ ';
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.35rem, 2.6vw, 1.7rem);
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.panel-promise {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text) / 0.78);
|
||||
}
|
||||
|
||||
.panel-tools {
|
||||
list-style: none;
|
||||
margin: 0.9rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.panel-tool {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--color-bg) / 0.7);
|
||||
font-size: 0.85rem;
|
||||
color: hsl(var(--color-text));
|
||||
transition: transform 0.1s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-tool:hover {
|
||||
transform: translateY(-1px);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.panel-tool:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel-tool-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-tool-app {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.panel-tool-state {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.panel-stacks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.6rem;
|
||||
margin: 0.9rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.panel-stacks-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.panel-stacks-more {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.panel-stack::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
margin-right: 0.3rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--hue));
|
||||
vertical-align: 0.05em;
|
||||
}
|
||||
|
||||
.panel-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.panel-more:hover span {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.panel-more span {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.panel-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.panel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.console { --track-h: 8.5rem; }
|
||||
.console-board { padding: 1rem 0.6rem 0.75rem; gap: 0.35rem; }
|
||||
.console-scale { font-size: 0.62rem; }
|
||||
.fader-cap { width: 2.2rem; margin-left: -1.1rem; }
|
||||
.fader-name { font-size: 0.88rem; }
|
||||
.fader-prefix { font-size: 0.52rem; letter-spacing: 0.08em; }
|
||||
.fader-lever { font-size: 0.68rem; padding: 0.2rem 0.4rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fader,
|
||||
.fader--held.fader--rising,
|
||||
.fader-fill,
|
||||
.fader-name,
|
||||
.fader-lever,
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div class="format">
|
||||
<header v-if="format && showHeader" class="format-head">
|
||||
<!-- The accordion player at the end of the title, on its baseline: on the event card it stood
|
||||
at the height of the reader, and its ground line touched the card's link (rules c, h).
|
||||
S and from 1200 px: the band above leaves 125 px, and a perched bird keeps a void of its
|
||||
own height above it (rule f); 8 px under the baseline, within the 12 px of rule e -->
|
||||
<h2 class="format-title">{{ format.title }}<UiOiseauPose nom="accordeoniste" pose="perche" bout left="16px" bottom="-8px" :w="72" retourne ton="accent" :seuil="1200" /></h2>
|
||||
<p class="format-text">{{ format.text }}</p>
|
||||
</header>
|
||||
|
||||
<div class="format-grid">
|
||||
<!-- Éditorial : le livre et les chansons -->
|
||||
<article v-if="book" class="format-card format-card--book">
|
||||
<!-- No bird behind the book card (DA, 03/11 after 19h): lowered under the header's first
|
||||
line, the reader only showed her bubble; the page margin and the gutter leave her no
|
||||
side to come out by, and the free top of the card holds the accordion player. She
|
||||
stands on the order page -->
|
||||
<div
|
||||
class="format-cover"
|
||||
role="img"
|
||||
:aria-label="book.coverAlt"
|
||||
:style="{ backgroundImage: `url(${book.coverImage})` }"
|
||||
/>
|
||||
<div class="format-body">
|
||||
<p class="format-kicker">{{ book.kicker }}</p>
|
||||
<h3 class="format-card-title">{{ book.title }}</h3>
|
||||
<p class="format-desc">{{ book.description }}</p>
|
||||
<div class="format-actions">
|
||||
<button type="button" class="fbtn fbtn--main" @click="$emit('open-player')">
|
||||
<span class="i-lucide-play h-4 w-4" aria-hidden="true" />
|
||||
{{ book.cta?.player }}
|
||||
</button>
|
||||
<button type="button" class="fbtn" @click="$emit('open-pdf')">
|
||||
<span class="i-lucide-book-open h-4 w-4" aria-hidden="true" />
|
||||
{{ book.cta?.pdf }}
|
||||
</button>
|
||||
<NuxtLink to="/economique/modele-eco" class="fbtn fbtn--quiet">
|
||||
<span class="i-lucide-list h-4 w-4" aria-hidden="true" />
|
||||
Présentation des chapitres
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/economique/commande" class="fbtn fbtn--quiet">
|
||||
<span class="i-lucide-shopping-bag h-4 w-4" aria-hidden="true" />
|
||||
Commandez le livre
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Événementiel -->
|
||||
<NuxtLink v-if="evenement" :to="evenement.to" class="format-card format-card--event">
|
||||
<p class="format-kicker">
|
||||
{{ evenement.teaser }}
|
||||
<span v-if="evenement.gestation" class="format-state">en gestation</span>
|
||||
</p>
|
||||
<h3 class="format-card-title format-card-title--event">{{ evenement.subtitle }}</h3>
|
||||
<p v-if="evenement.tagline" class="format-tagline">{{ evenement.tagline }}</p>
|
||||
<ul v-if="evenement.config.length" class="format-config">
|
||||
<li v-for="c in evenement.config" :key="c.label">
|
||||
<span :class="`i-lucide-${c.icon}`" class="h-4 w-4" aria-hidden="true" />
|
||||
<span>{{ c.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="format-more">
|
||||
Voir l'événement
|
||||
<span class="i-lucide-arrow-right h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Reproduction + à qui l'on s'adresse -->
|
||||
<div v-if="evenement?.reproduction || hero?.addressees?.length" class="format-repro">
|
||||
<p v-if="evenement?.reproduction" class="format-repro-line">
|
||||
<span class="format-basins" aria-hidden="true"><i v-for="n in 5" :key="n" /></span>
|
||||
{{ evenement.reproduction }}
|
||||
</p>
|
||||
<div v-if="hero?.addressees?.length" class="format-audience">
|
||||
<p class="format-audience-label">{{ hero.audience }}</p>
|
||||
<ul>
|
||||
<li v-for="a in hero.addressees" :key="a.label">
|
||||
<strong>{{ a.label }}</strong> {{ a.value }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
format?: { title: string, text: string }
|
||||
book?: any
|
||||
evenement?: any
|
||||
hero?: any
|
||||
showHeader?: boolean
|
||||
}>(), {
|
||||
showHeader: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.format {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.format-head {
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.format-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.75rem, 4.2vw, 2.75rem);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.format-text {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text) / 0.72);
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.format-grid { grid-template-columns: 1.35fr 1fr; }
|
||||
}
|
||||
|
||||
.format-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.format-card--book {
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.format-cover {
|
||||
flex-shrink: 0;
|
||||
width: clamp(6.5rem, 16vw, 10rem);
|
||||
aspect-ratio: 1756 / 2480;
|
||||
border-radius: 6px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
box-shadow: 0 16px 30px -18px hsl(var(--color-text) / 0.6);
|
||||
}
|
||||
|
||||
.format-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.format-kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--axis-eco));
|
||||
}
|
||||
|
||||
.format-card--event .format-kicker {
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
.format-state {
|
||||
margin-left: 0.35rem;
|
||||
color: hsl(var(--color-text) / 0.5);
|
||||
}
|
||||
|
||||
.format-state::before {
|
||||
content: '◌ ';
|
||||
}
|
||||
|
||||
.format-card-title {
|
||||
margin: 0.3rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.3rem, 2.4vw, 1.6rem);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.12;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.format-card-title--event {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-desc {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text) / 0.72);
|
||||
}
|
||||
|
||||
.format-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-text) / 0.85);
|
||||
background: hsl(var(--color-text) / 0.07);
|
||||
transition: transform 0.1s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.fbtn:hover { transform: translateY(-1px); background: hsl(var(--axis-eco) / 0.14); }
|
||||
.fbtn:active { transform: translateY(0); }
|
||||
|
||||
.fbtn--main {
|
||||
color: hsl(var(--color-bg));
|
||||
background: hsl(var(--axis-eco));
|
||||
}
|
||||
|
||||
.fbtn--main:hover { background: hsl(var(--axis-eco) / 0.88); }
|
||||
|
||||
.fbtn--quiet {
|
||||
font-weight: 400;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.format-card--event {
|
||||
color: hsl(var(--color-text));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.format-card--event:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.format-tagline {
|
||||
margin: 0.2rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.format-config {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(var(--color-text) / 0.78);
|
||||
}
|
||||
|
||||
.format-config li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.format-config li > span:first-child {
|
||||
color: hsl(var(--axis-cit));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.format-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
/* Reproduction */
|
||||
.format-repro {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.format-repro {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.format-repro-line {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.4rem);
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
/* One basin, then its copies */
|
||||
.format-basins {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.format-basins i {
|
||||
display: block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1.5px hsl(var(--axis-cit) / 0.6);
|
||||
}
|
||||
|
||||
.format-basins i:first-child {
|
||||
background: hsl(var(--axis-cit));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.format-audience-label {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--color-text) / 0.5);
|
||||
}
|
||||
|
||||
.format-audience ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.95rem;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.format-audience strong {
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.format-card--book { flex-direction: column; }
|
||||
.format-config { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fbtn, .format-card--event { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="va">
|
||||
<!-- Hero : the headline, and the three cursors to push -->
|
||||
<section class="va-hero">
|
||||
<div class="container-content va-hero-grid">
|
||||
<div class="va-intro">
|
||||
<h1 class="va-h1">
|
||||
<span class="va-h1-a">{{ heading[0] }}</span>
|
||||
<span class="va-h1-b">{{ heading[1] }}</span>
|
||||
</h1>
|
||||
<p class="va-axes">
|
||||
<template v-for="(seg, i) in axesLine" :key="i">
|
||||
<span v-if="seg.hue" class="va-axes-word" :style="{ '--hue': `var(${seg.hue})` }">{{ seg.text }}</span>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
<p class="va-means">{{ heading[4] }}</p>
|
||||
<p v-if="hero.approach" class="va-approach">{{ hero.approach }}</p>
|
||||
</div>
|
||||
|
||||
<div class="va-console">
|
||||
<p class="va-console-title">
|
||||
{{ heading[2] }}
|
||||
</p>
|
||||
<!-- The DJ stands behind the console board, like behind its decks: only its head, with
|
||||
its headphones, shows over the board. Perched on the board, under the sticky header,
|
||||
it had no void of its own height above it (rule f, DA 03/11 after 17h35). The scene
|
||||
keeps it under the board without touching the console -->
|
||||
<div class="va-console-scene">
|
||||
<UiOiseauPose nom="dj" pose="derriere" right="24px" top="-95px" :w="150" retourne ton="accent" />
|
||||
<UiOiseauPose nom="dj" pose="derriere" right="24px" top="-95px" :w="150" retourne ton="accent" mobile-seul :seuil="768" />
|
||||
<HomeConsole
|
||||
v-model="active"
|
||||
:axes="axes"
|
||||
:prefix="consoleLabels?.prefix"
|
||||
:top-label="consoleLabels?.top"
|
||||
:bottom-label="consoleLabels?.bottom"
|
||||
:stacks="bouquet?.inService"
|
||||
:stacks-to="lpk?.to"
|
||||
:stacks-label="lpk ? `En service chez ${lpk.name} :` : 'En service :'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi -->
|
||||
<section class="va-band">
|
||||
<div class="container-content">
|
||||
<HomeCitations :lines="hero.citations ?? []" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Le format -->
|
||||
<section class="va-section">
|
||||
<div class="container-content">
|
||||
<HomeFormat
|
||||
:format="format"
|
||||
:book="book"
|
||||
:evenement="evenement"
|
||||
:hero="hero"
|
||||
@open-player="$emit('open-player')"
|
||||
@open-pdf="$emit('open-pdf')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- La boîte à outils -->
|
||||
<section class="va-section va-section--tools">
|
||||
<div class="container-content">
|
||||
<h2 class="va-h2">{{ toolboxTitle }}</h2>
|
||||
<HomeToolbox
|
||||
:oiseaux="toolboxOiseaux"
|
||||
:axes="axes"
|
||||
:lpk="lpk"
|
||||
:bouquet="bouquet"
|
||||
:gratewizard="gratewizard"
|
||||
anchors
|
||||
@open-player="$emit('open-player')"
|
||||
@open-pdf="$emit('open-pdf')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AxisKey } from '~/composables/useHomeData'
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const { hero, axes, lpk, bouquet, evenement, book, format, gratewizard, consoleLabels, toolboxTitle } = await useHomeData()
|
||||
const heading = computed<string[]>(() => hero.value.heading ?? [])
|
||||
|
||||
// « Autonomie numérique, économique, citoyenne. » — each axis word in its own hue
|
||||
const AXIS_WORDS: Record<string, string> = {
|
||||
numérique: '--axis-num',
|
||||
économique: '--axis-eco',
|
||||
citoyenne: '--axis-cit',
|
||||
}
|
||||
const axesLine = computed(() =>
|
||||
(heading.value[3] ?? '')
|
||||
.split(/(numérique|économique|citoyenne)/)
|
||||
.filter(Boolean)
|
||||
.map(text => ({ text, hue: AXIS_WORDS[text] })))
|
||||
const active = ref<AxisKey>('numerique')
|
||||
|
||||
// The governance crew stands in the empty space under the citoyenne column of the toolbox
|
||||
const toolboxOiseaux: Partial<Record<AxisKey, OiseauPlace[]>> = {
|
||||
politique: [
|
||||
{ nom: 'capitaine', pose: 'libre', left: 'calc(34% - 75px)', bottom: '-190px', w: 150, op: 0.24 },
|
||||
{ nom: 'matelot', pose: 'libre', left: 'calc(80% - 40px)', bottom: '-300px', w: 80, op: 0.26, retourne: true },
|
||||
],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.va-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: clamp(2.5rem, 6vw, 5rem) 1rem clamp(2.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.va-hero-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.va-hero-grid {
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||
gap: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.va-h1 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.va-h1-a {
|
||||
font-weight: 800;
|
||||
font-size: clamp(2.4rem, 6.4vw, 4.6rem);
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.va-h1-b {
|
||||
margin-top: 0.3rem;
|
||||
font-weight: 400;
|
||||
font-size: clamp(1.6rem, 3.8vw, 2.7rem);
|
||||
letter-spacing: -0.025em;
|
||||
color: hsl(var(--color-text) / 0.55);
|
||||
}
|
||||
|
||||
.va-axes {
|
||||
margin: 1.75rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.3rem, 2.6vw, 1.85rem);
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.15;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.va-axes-word {
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.va-means {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 1.05rem;
|
||||
color: hsl(var(--color-text) / 0.6);
|
||||
}
|
||||
|
||||
.va-approach {
|
||||
margin: 1.75rem 0 0;
|
||||
max-width: 26rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text) / 0.8);
|
||||
}
|
||||
|
||||
.va-approach::after {
|
||||
content: ' ↓';
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.va-approach::after { content: ' →'; }
|
||||
}
|
||||
|
||||
.va-console-scene {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.va-console-title {
|
||||
position: relative;
|
||||
margin: 0 0 0.75rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.va-band {
|
||||
padding: clamp(2.5rem, 6vw, 4.5rem) 1rem;
|
||||
background: hsl(var(--color-surface) / 0.55);
|
||||
}
|
||||
|
||||
.va-section {
|
||||
padding: clamp(3rem, 7vw, 5.5rem) 1rem 0;
|
||||
}
|
||||
|
||||
.va-h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.75rem, 4.2vw, 2.75rem);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +1,80 @@
|
||||
<template>
|
||||
<div class="mt-16">
|
||||
<section class="messages-section">
|
||||
<div class="container-content messages-grid">
|
||||
|
||||
<!-- Formulaire -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<UiScrollReveal>
|
||||
<div class="message-form-card">
|
||||
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3>
|
||||
<!-- The postwoman stands on the message form -->
|
||||
<UiOiseauPose nom="factrice" pose="perche" right="30px" :w="76" retourne :seuil="1200" />
|
||||
<h3 class="font-display text-lg font-bold form-title mb-4">Laisser un message</h3>
|
||||
|
||||
<form v-if="!submitted" class="space-y-3" @submit.prevent="send">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
v-model="form.author"
|
||||
type="text"
|
||||
placeholder="Votre nom *"
|
||||
required
|
||||
placeholder="Votre nom"
|
||||
class="msg-input"
|
||||
/>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="Email (optionnel)"
|
||||
class="msg-input"
|
||||
/>
|
||||
</div>
|
||||
<p class="hint-text text-xs -mt-1 px-1">Pour recevoir une réponse, laissez votre e-mail dans le message.</p>
|
||||
<select v-model="form.type" class="msg-input msg-input--select">
|
||||
<option value="question">Question</option>
|
||||
<option value="suggestion">Suggestion</option>
|
||||
<option value="retour">Retour d'expérience</option>
|
||||
</select>
|
||||
<textarea
|
||||
v-model="form.text"
|
||||
placeholder="Votre message *"
|
||||
required
|
||||
placeholder="Votre message"
|
||||
rows="3"
|
||||
class="msg-input resize-none"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary text-sm" :disabled="sending">
|
||||
<button type="submit" class="btn-primary text-sm" :disabled="sending || !canSend">
|
||||
<div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-else class="text-center py-4">
|
||||
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" />
|
||||
<p class="text-white/80">Merci pour votre message !</p>
|
||||
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</p>
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="i-lucide-heart-handshake h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<p class="msg-confirm-title font-display text-lg font-semibold">Merci pour votre message !</p>
|
||||
<p class="msg-confirm-sub text-sm mt-2 max-w-md mx-auto leading-relaxed">
|
||||
Il sera lu et traité dans un délai... humainement raisonnable.
|
||||
</p>
|
||||
<p class="msg-confirm-note text-xs mt-4 italic">
|
||||
Chaque message est un premier pas dans l'aventure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- 2 derniers messages publiés -->
|
||||
<UiScrollReveal v-if="messages?.length" :delay="600">
|
||||
<div class="mt-8 space-y-4">
|
||||
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3>
|
||||
<UiScrollReveal v-if="messages?.length" :delay="100">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-display text-lg font-bold section-title">Derniers messages</h3>
|
||||
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
<!-- En-tête auteur -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
|
||||
<span class="type-pill">{{ typeLabel(msg.type) }}</span>
|
||||
<span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
<!-- Texte du message -->
|
||||
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<!-- Réponse -->
|
||||
<div v-if="msg.reply?.text" class="reply-thread">
|
||||
<div class="reply-connector" aria-hidden="true" />
|
||||
<div class="reply-block">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
|
||||
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
|
||||
</div>
|
||||
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink to="/messages" class="btn-ghost text-sm">
|
||||
Voir tous les messages
|
||||
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
|
||||
@@ -65,16 +83,31 @@
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
const form = reactive({ author: '', email: '', text: '' })
|
||||
const form = reactive({ author: '', text: '', type: 'question' })
|
||||
const sending = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const canSend = computed(() => form.text.trim().length > 0)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
await $fetch('/api/messages', { method: 'POST', body: form })
|
||||
@@ -105,22 +138,59 @@ function formatDate(iso: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Couleurs adaptatives (dark + light) ── */
|
||||
.form-title { color: hsl(var(--color-text)); }
|
||||
.hint-text { color: hsl(var(--color-text) / 0.35); }
|
||||
.section-title { color: hsl(var(--color-text) / 0.85); }
|
||||
.msg-author { color: hsl(var(--color-text) / 0.75); }
|
||||
.msg-date { color: hsl(var(--color-text) / 0.38); }
|
||||
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
|
||||
.msg-confirm-title { color: hsl(var(--color-text) / 0.95); }
|
||||
.msg-confirm-sub { color: hsl(var(--color-text) / 0.6); }
|
||||
.msg-confirm-note { color: hsl(var(--color-primary) / 0.65); }
|
||||
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
|
||||
.reply-author { color: hsl(var(--color-primary) / 0.8); }
|
||||
.reply-text { color: hsl(var(--color-text) / 0.62); }
|
||||
|
||||
/* ── Mise en page ── */
|
||||
.messages-section {
|
||||
padding: clamp(3rem, 7vw, 5.5rem) 1rem 2rem;
|
||||
}
|
||||
|
||||
.messages-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.messages-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
|
||||
}
|
||||
|
||||
/* ── Carte formulaire ── */
|
||||
.message-form-card {
|
||||
position: relative;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-text) / 0.12);
|
||||
min-height: 2.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.msg-input--select {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msg-input::placeholder {
|
||||
@@ -129,13 +199,55 @@ function formatDate(iso: string) {
|
||||
|
||||
.msg-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
/* ── Carte message ── */
|
||||
.message-card {
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
/* ── Réponse avec liaison graphique ── */
|
||||
.reply-thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.reply-connector {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: -0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--color-primary) / 0.35),
|
||||
hsl(var(--color-primary) / 0.15)
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.25);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.6rem 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="tool" :class="{ 'tool--gestation': tool.gestation }">
|
||||
<div class="tool-main">
|
||||
<span class="tool-icon" aria-hidden="true">
|
||||
<span v-if="tool.icon === 'g1'" class="tool-g1">Ğ1</span>
|
||||
<span v-else :class="`i-lucide-${tool.icon}`" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="tool-text">
|
||||
<component :is="tool.to ? NuxtLink : 'span'" :to="tool.to" class="tool-name">
|
||||
{{ tool.label }}
|
||||
</component>
|
||||
<UiStatusBadge class="tool-status" :status="tool.status" :gestation="tool.gestation" :since="tool.since" />
|
||||
<p class="tool-desc">{{ tool.description }}</p>
|
||||
<p v-if="tool.presentation" class="tool-app">
|
||||
<strong>{{ tool.presentation.title }}</strong> — {{ tool.presentation.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tool.actions?.length" class="tool-actions">
|
||||
<template v-for="action in tool.actions" :key="action.label">
|
||||
<a
|
||||
v-if="action.href"
|
||||
:href="action.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--main': action.highlight }"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else-if="action.to"
|
||||
:to="action.to"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--quiet': action.secondary }"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--main': action.highlight }"
|
||||
@click="$emit('action', action.id)"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-for="action in tool.actions.filter(a => a.note)" :key="`note-${action.label}`" class="tool-note">
|
||||
{{ action.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HomeTool } from '~/composables/useHomeData'
|
||||
|
||||
defineProps<{
|
||||
tool: HomeTool
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
action: [id: string]
|
||||
}>()
|
||||
|
||||
const NuxtLink = resolveComponent('NuxtLink')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--color-bg) / 0.55);
|
||||
}
|
||||
|
||||
.tool-main {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--hue) / 0.12);
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-g1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tool-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
a.tool-name:hover {
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.tool-note {
|
||||
align-self: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--color-text) / 0.68);
|
||||
}
|
||||
|
||||
.tool-app {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--color-text) / 0.6);
|
||||
}
|
||||
|
||||
.tool-app strong {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.7rem;
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-text) / 0.8);
|
||||
background: hsl(var(--color-text) / 0.06);
|
||||
transition: transform 0.1s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
background: hsl(var(--hue) / 0.14);
|
||||
}
|
||||
|
||||
.tool-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tool-btn--main {
|
||||
color: hsl(var(--color-bg));
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-btn--main:hover {
|
||||
background: hsl(var(--hue) / 0.88);
|
||||
}
|
||||
|
||||
.tool-btn--quiet {
|
||||
font-weight: 400;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-btn { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="toolbox-wrap">
|
||||
<div class="toolbox" :class="{ 'toolbox--single': axes.length === 1 }">
|
||||
<section
|
||||
v-for="axis in axes"
|
||||
:id="anchors ? axis.to.slice(1) : undefined"
|
||||
:key="axis.key"
|
||||
class="toolbox-axis"
|
||||
:style="{ '--hue': `var(${axis.hue})` }"
|
||||
:aria-labelledby="`tb-${uid}-${axis.key}`"
|
||||
>
|
||||
<UiOiseauPose v-for="o in oiseaux?.[axis.key] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" class="toolbox-oiseau" />
|
||||
<header class="toolbox-head">
|
||||
<p class="toolbox-lever">{{ axis.lever }}</p>
|
||||
<h3 :id="`tb-${uid}-${axis.key}`" class="toolbox-title">
|
||||
<NuxtLink :to="axis.to">{{ axis.title }}</NuxtLink>
|
||||
</h3>
|
||||
<p v-if="showPromise && axis.promise" class="toolbox-promise">{{ axis.promise }}</p>
|
||||
</header>
|
||||
|
||||
<ul class="toolbox-tools">
|
||||
<li v-for="tool in axis.items" :key="tool.label">
|
||||
<HomeToolItem :tool="tool" @action="onAction" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Numérique : the stacks LaPlanK serves -->
|
||||
<NuxtLink
|
||||
v-if="axis.key === 'numerique' && lpk && bouquet"
|
||||
:to="lpk.to"
|
||||
class="toolbox-lpk"
|
||||
>
|
||||
<span class="toolbox-lpk-head">
|
||||
<strong>{{ lpk.name }}</strong> <span>{{ lpk.text }}</span>
|
||||
</span>
|
||||
<span class="toolbox-lpk-stacks">
|
||||
<span v-for="s in bouquet.inService" :key="s.name" class="toolbox-lpk-stack">{{ s.name }}</span>
|
||||
</span>
|
||||
<span class="toolbox-lpk-more">
|
||||
{{ bouquet.title }} · {{ bouquet.stacks.length }}
|
||||
<span class="i-lucide-arrow-right h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Économique : grateWizard -->
|
||||
<button
|
||||
v-if="axis.key === 'economie' && gratewizard"
|
||||
type="button"
|
||||
class="toolbox-gw"
|
||||
@click="launch()"
|
||||
>
|
||||
<span class="i-lucide-sparkles h-4 w-4" aria-hidden="true" />
|
||||
<span class="toolbox-gw-text">
|
||||
<strong>{{ gratewizard.title }}</strong>
|
||||
<span>{{ gratewizard.subtitle }}</span>
|
||||
</span>
|
||||
<span class="i-lucide-arrow-up-right h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AxisKey, HomeAxis } from '~/composables/useHomeData'
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
axes: HomeAxis[]
|
||||
lpk?: { name: string, text: string, to: string }
|
||||
bouquet?: { title: string, inService: { name?: string }[], stacks: unknown[] } | null
|
||||
gratewizard?: { title: string, subtitle: string }
|
||||
showPromise?: boolean
|
||||
anchors?: boolean
|
||||
/** Birds standing by an axis column (home page) */
|
||||
oiseaux?: Partial<Record<AxisKey, OiseauPlace[]>>
|
||||
}>(), {
|
||||
showPromise: true,
|
||||
anchors: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const uid = useId()
|
||||
const { launch } = useGrateWizard()
|
||||
|
||||
function onAction(id: string) {
|
||||
if (id === 'open-player') emit('open-player')
|
||||
else if (id === 'open-pdf') emit('open-pdf')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbox-wrap {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.toolbox {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@container (min-width: 60rem) {
|
||||
.toolbox:not(.toolbox--single) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* The birds under a column only make sense with the three columns side by side */
|
||||
@container (max-width: 59.99rem) {
|
||||
.toolbox-oiseau { display: none; }
|
||||
}
|
||||
|
||||
.toolbox-axis {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--color-surface));
|
||||
box-shadow: inset 0 3px 0 hsl(var(--hue));
|
||||
scroll-margin-top: calc(var(--header-height) + 1rem);
|
||||
}
|
||||
|
||||
.toolbox-lever {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.toolbox-title {
|
||||
margin: 0.15rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.toolbox-title a:hover {
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.toolbox-promise {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.toolbox-tools {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* LaPlanK */
|
||||
.toolbox-lpk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--hue) / 0.1);
|
||||
color: hsl(var(--color-text));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.toolbox-lpk:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.toolbox-lpk-head {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.toolbox-lpk-head strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.toolbox-lpk-stacks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.toolbox-lpk-stack {
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
background: hsl(var(--color-bg) / 0.75);
|
||||
}
|
||||
|
||||
.toolbox-lpk-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
/* grateWizard */
|
||||
.toolbox-gw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
color: hsl(var(--hue));
|
||||
background: hsl(var(--hue) / 0.1);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.toolbox-gw:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.toolbox-gw-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
}
|
||||
|
||||
.toolbox-gw-text strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toolbox-lpk, .toolbox-gw { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +1,7 @@
|
||||
<template>
|
||||
<footer class="footer-wrap border-t border-[hsl(var(--color-text)/0.1)] bg-[hsl(var(--color-surface))]">
|
||||
<!-- Shadok pattern -->
|
||||
<svg class="footer-shadok-pattern" viewBox="0 0 400 80" fill="none" aria-hidden="true">
|
||||
<g transform="translate(20,10)">
|
||||
<ellipse cx="15" cy="25" rx="12" ry="14" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="15" cy="10" r="7" fill="currentColor" opacity="0.16"/>
|
||||
<line x1="10" y1="38" x2="8" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
|
||||
<line x1="20" y1="38" x2="22" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
|
||||
</g>
|
||||
<g transform="translate(80,15)">
|
||||
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
|
||||
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
|
||||
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
</g>
|
||||
<g transform="translate(140,8)">
|
||||
<ellipse cx="15" cy="25" rx="11" ry="13" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="15" cy="10" r="6.5" fill="currentColor" opacity="0.15"/>
|
||||
<line x1="10" y1="37" x2="7" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
<line x1="20" y1="37" x2="23" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
</g>
|
||||
<g transform="translate(210,18)">
|
||||
<ellipse cx="15" cy="20" rx="10" ry="11" fill="currentColor" opacity="0.14"/>
|
||||
<circle cx="15" cy="7" r="5.5" fill="currentColor" opacity="0.11"/>
|
||||
<line x1="10" y1="30" x2="9" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
|
||||
<line x1="20" y1="30" x2="21" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
|
||||
</g>
|
||||
<g transform="translate(270,12)">
|
||||
<ellipse cx="15" cy="24" rx="12" ry="14" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="15" cy="9" r="7" fill="currentColor" opacity="0.15"/>
|
||||
<line x1="10" y1="37" x2="7" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
<line x1="20" y1="37" x2="23" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
</g>
|
||||
<g transform="translate(340,16)">
|
||||
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
|
||||
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
|
||||
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
</g>
|
||||
</svg>
|
||||
<!-- The frieze of the birds (studio DA), standing on the bottom edge -->
|
||||
<UiOiseau nom="frise" class="footer-shadok-pattern" />
|
||||
<div class="container-content px-4 py-8 relative z-1">
|
||||
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
|
||||
<!-- Credits -->
|
||||
@@ -47,9 +10,9 @@
|
||||
</p>
|
||||
|
||||
<!-- Links -->
|
||||
<nav class="flex items-center gap-4" aria-label="Liens du pied de page">
|
||||
<nav v-if="links.length" class="flex items-center gap-4" aria-label="Liens du pied de page">
|
||||
<NuxtLink
|
||||
v-for="link in site?.footer.links"
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]"
|
||||
@@ -64,6 +27,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
const router = useRouter()
|
||||
|
||||
/** The footer links of site.yml, without those to a page the site does not have yet: the link
|
||||
* to /mentions-legales (03/11: no such page, its text is still to be written) shows again by
|
||||
* itself once the page exists */
|
||||
interface FooterLink { label: string, to: string }
|
||||
const links = computed<FooterLink[]>(() => (((site.value as any)?.footer?.links ?? []) as FooterLink[])
|
||||
.filter(l => /^(https?:|mailto:)/.test(l.to) || router.resolve(l.to).matched.length > 0))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -72,13 +43,14 @@ const { data: site } = await useSiteContent()
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The whole frieze fits in the footer (mask: contain), never cropped; pale under the links */
|
||||
.footer-shadok-pattern {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0.75rem;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
height: calc(100% - 0.75rem);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
@@ -2,29 +2,37 @@
|
||||
<header class="sticky top-0 z-40 border-b border-[hsl(var(--color-text)/0.08)] bg-[hsl(var(--color-bg)/0.85)] backdrop-blur-xl">
|
||||
<div class="container-content flex h-[var(--header-height)] items-center justify-between px-4">
|
||||
<!-- Logo -->
|
||||
<NuxtLink to="/" class="logo-link flex items-center gap-2.5">
|
||||
<svg class="logo-icon" viewBox="0 0 64 80" fill="none" aria-hidden="true">
|
||||
<path d="M38 8 C28 6 18 10 18 20 C18 28 26 32 34 34 C42 36 48 40 48 48 C48 52 46 55 42 57 L44 40 C44 36 40 32 34 30 C28 28 22 24 22 18 C22 14 24 11 28 10Z" fill="currentColor" opacity="0.9"/>
|
||||
<path d="M26 72 C36 74 46 70 46 60 C46 52 38 48 30 46 C22 44 16 40 16 32 C16 28 18 25 22 23 L20 40 C20 44 24 48 30 50 C36 52 42 56 42 62 C42 66 40 69 36 70Z" fill="currentColor" opacity="0.9"/>
|
||||
<path d="M20 16 C20 8 28 4 36 6 C42 8 46 14 44 20" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
|
||||
<path d="M44 64 C44 72 36 76 28 74 C22 72 18 66 20 60" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
|
||||
<path d="M36 4 Q42 2 46 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
<path d="M28 76 Q22 78 18 74" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
</svg>
|
||||
<span class="logo-text">{{ site?.identity.name }}</span>
|
||||
<NuxtLink to="/" class="logo-link flex items-center" :aria-label="site?.identity.name || 'Le librodrome'">
|
||||
<UiLogotype class="logo-lockup" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
|
||||
<nav class="nav-main hidden md:flex items-center gap-1" aria-label="Navigation principale">
|
||||
<!-- Autonomie : prefix + axis buttons -->
|
||||
<span class="nav-prefix">Autonomie :</span>
|
||||
<NuxtLink
|
||||
v-for="item in site?.navigation"
|
||||
v-for="(item, i) in axes"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost text-sm"
|
||||
class="btn-ghost text-sm nav-axis"
|
||||
:style="{ '--hue': `var(${AXIS_HUES[i] ?? '--color-primary'})` }"
|
||||
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Separator + extra nav -->
|
||||
<span class="nav-sep" />
|
||||
<NuxtLink
|
||||
v-for="item in extra"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost btn-ghost--muted text-sm"
|
||||
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<UiPaletteSelector />
|
||||
</nav>
|
||||
|
||||
@@ -39,13 +47,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" />
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="allNav" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
const axes = computed(() => (site.value as any)?.navigation?.axes ?? [])
|
||||
const extra = computed(() => (site.value as any)?.navigation?.extra ?? [])
|
||||
const allNav = computed(() => [...axes.value, ...extra.value])
|
||||
|
||||
// Same order as navigation.axes in site.yml: numérique, économique, citoyenne
|
||||
const AXIS_HUES = ['--axis-num', '--axis-eco', '--axis-cit']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -56,18 +71,68 @@ const isMobileMenuOpen = ref(false)
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 1.6rem;
|
||||
height: 2rem;
|
||||
color: hsl(var(--color-primary));
|
||||
/* The validated lockup (studio DA, 27/10): the Geste already leans 25°, no rotation on hover */
|
||||
.logo-lockup {
|
||||
height: 2.3rem;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
.logo-link:hover .logo-lockup {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logo-lockup { transition: none; }
|
||||
}
|
||||
|
||||
.nav-prefix {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.03em;
|
||||
color: hsl(var(--color-text) / 0.4);
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-axis::before {
|
||||
content: '';
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
margin-right: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.nav-sep {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: hsl(var(--color-text) / 0.12);
|
||||
margin: 0 0.375rem;
|
||||
}
|
||||
|
||||
.btn-ghost--muted {
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
/* From 768 to 1023 px the kit's logotype (166 px) and the whole navigation share the bar:
|
||||
tighter buttons instead of an overflow; the logotype of the studio DA kit stays as it is */
|
||||
@media (max-width: 1023px) {
|
||||
.nav-main {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-axis,
|
||||
.btn-ghost--muted {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-sep {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -179,6 +179,7 @@ onClickOutside(widgetRef, () => {
|
||||
.mini-player {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
transition: bottom 0.3s ease;
|
||||
right: max(1rem, calc((100vw - 80rem) / 2));
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
@@ -186,6 +187,11 @@ onClickOutside(widgetRef, () => {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* Book player open: stay above its navigation bar */
|
||||
:global(html.book-player-open .mini-player) {
|
||||
bottom: 5.5rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
PILL
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary/80"
|
||||
>
|
||||
<div class="i-lucide-music h-2.5 w-2.5" />
|
||||
{{ song.title }}
|
||||
@@ -17,7 +17,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
</script>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// One of the 61 birds (studio DA, validated 2026-10-28), drawn from /images/oiseaux/<nom>.svg as a
|
||||
// mask filled with currentColor : it takes the colour the page gives it, like the shadoks did.
|
||||
// The classes of the figure it replaces keep its place, size, opacity and animation.
|
||||
import { OISEAUX } from '~/utils/oiseaux'
|
||||
|
||||
const props = defineProps<{ nom: string }>()
|
||||
const o = computed(() => OISEAUX[props.nom])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="o"
|
||||
class="oiseau"
|
||||
aria-hidden="true"
|
||||
:style="{ aspectRatio: o.ratio, '--oiseau': `url(/images/oiseaux/${props.nom}.svg)` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oiseau {
|
||||
display: block;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: var(--oiseau) center / contain no-repeat;
|
||||
mask: var(--oiseau) center / contain no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
// A bird of the librodrome placed on its block (composition of 03/11, docs/consignes-yvv.md):
|
||||
// it stands on a real edge of the page, next to the content its trade is about.
|
||||
// The host block is the positioning context: position relative, overflow visible.
|
||||
//
|
||||
// Poses
|
||||
// perche on the block's top edge (bottom: 100%), x with left / right
|
||||
// marge-g beside the block, on its left; feet at the block's bottom, raised by `bottom`
|
||||
// marge-d the same, on its right
|
||||
// derriere large and pale, behind the block (z-index -1): only what sticks out shows.
|
||||
// Needs a stacking context above the block (isolation: isolate) and a block
|
||||
// that creates none itself (no z-index, transform, opacity, filter).
|
||||
// libre placed only by left / right / top / bottom (an empty area next to its block)
|
||||
//
|
||||
// `bout`: a perched bird at the end (or the start) of a line of a title. The component is then a
|
||||
// zero-size anchor in the text, at its baseline, written right after the text (the gap is `left`,
|
||||
// 12 px by default) or right before it (the gap is `right`): the bird follows the end of the text
|
||||
// at every width, wherever the line breaks (rule e, docs/da-oiseaux.md).
|
||||
//
|
||||
// Birds in the margin, behind a block or free need room around the column: they show from
|
||||
// 1200 px wide, the perched ones from 768 px (`seuil` overrides, for instance 1024 for a perched
|
||||
// bird whose block has no void above it until the grid splits in two); under 768 px, only a
|
||||
// bird marked `mobile`.
|
||||
// A perched bird needs, above its edge, a void at least as tall as itself (DA rule of 03/11);
|
||||
// where blocks stand close, the bird goes in the margin or behind the block.
|
||||
//
|
||||
// Drawing rules of the studio DA (kit README, §2.5): one colour (currentColor), proportions
|
||||
// intact and never cropped, 72 px wide at least, opacity 0.18-0.28 next to text and 0.10-0.14
|
||||
// large behind a card, mirror allowed except for the calligraphe and the typographe.
|
||||
// Birds standing on an edge do not move; only the one behind a card drifts a little (Yvv, 03/11).
|
||||
import { OISEAUX } from '~/utils/oiseaux'
|
||||
|
||||
type Pose = 'perche' | 'marge-g' | 'marge-d' | 'derriere' | 'libre'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
nom: string
|
||||
pose?: Pose
|
||||
/** Width in px: S 72-90, M 108-156, L 200-250 */
|
||||
w?: number
|
||||
left?: string
|
||||
right?: string
|
||||
top?: string
|
||||
bottom?: string
|
||||
/** Gap between the block and a bird in the margin */
|
||||
ecart?: string
|
||||
retourne?: boolean
|
||||
/** Shown under 768 px (one bird per page at most) */
|
||||
mobile?: boolean
|
||||
/** Shown under 768 px only: the phone place of a bird placed elsewhere on wider screens */
|
||||
mobileSeul?: boolean
|
||||
/** Narrowest window (px) where the bird shows: 1200 by default for the margin, behind
|
||||
* and free poses, 768 for a perched bird */
|
||||
seuil?: 768 | 1024 | 1200
|
||||
ton?: 'primary' | 'accent'
|
||||
op?: number
|
||||
/** At the end (or the start) of a line of text: written in the text, without a space */
|
||||
bout?: boolean
|
||||
}>(), { pose: 'perche', w: 80, ecart: '2.5rem', ton: 'primary' })
|
||||
|
||||
/** These two write and compose from left to right: never mirrored */
|
||||
const NO_MIRROR = new Set(['calligraphe', 'typographe'])
|
||||
|
||||
const sol = computed(() => OISEAUX[props.nom]?.sol ?? 5)
|
||||
|
||||
const style = computed(() => {
|
||||
const s: Record<string, string> = { 'width': `${Math.max(72, props.w)}px`, '--sol': `${sol.value}%` }
|
||||
if (props.pose === 'perche') s.bottom = props.bout ? '0px' : '100%'
|
||||
if (props.bout && props.left === undefined && props.right === undefined) s.left = '12px'
|
||||
if (props.pose === 'marge-g') s.right = `calc(100% + ${props.ecart})`
|
||||
if (props.pose === 'marge-d') s.left = `calc(100% + ${props.ecart})`
|
||||
if (props.pose === 'marge-g' || props.pose === 'marge-d') s.bottom = '0px'
|
||||
for (const k of ['left', 'right', 'top', 'bottom'] as const) {
|
||||
if (props[k] !== undefined) s[k] = props[k]!
|
||||
}
|
||||
if (props.op !== undefined) s.opacity = String(props.op)
|
||||
return s
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'oiseau-pose',
|
||||
`oiseau-pose--${props.pose}`,
|
||||
{
|
||||
'oiseau-pose--retourne': props.retourne && !NO_MIRROR.has(props.nom),
|
||||
'oiseau-pose--mobile': props.mobile || props.mobileSeul,
|
||||
'oiseau-pose--mobile-seul': props.mobileSeul,
|
||||
'oiseau-pose--large': (props.seuil ?? (props.pose === 'perche' ? 768 : 1200)) >= 1200,
|
||||
'oiseau-pose--moyen': props.seuil === 1024,
|
||||
'oiseau-pose--accent': props.ton === 'accent',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="bout" class="oiseau-bout"><UiOiseau :nom="nom" :class="classes" :style="style" :data-oiseau="nom" :data-pose="pose" /></span>
|
||||
<UiOiseau v-else :nom="nom" :class="classes" :style="style" :data-oiseau="nom" :data-pose="pose" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The anchor of a bird at the end of a line: no size, on the text's baseline */
|
||||
.oiseau-bout {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.oiseau-pose {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
opacity: 0.24;
|
||||
/* The drawing's ground line lands on the edge */
|
||||
transform: translateY(var(--sol));
|
||||
}
|
||||
|
||||
.oiseau-pose--accent {
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.oiseau-pose--perche {
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.oiseau-pose--derriere {
|
||||
z-index: -1;
|
||||
opacity: 0.12;
|
||||
animation: oiseau-derive 12s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.oiseau-pose--retourne {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
@keyframes oiseau-derive {
|
||||
from { translate: 0 0; }
|
||||
to { translate: 0 -6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.oiseau-pose--large {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.oiseau-pose--moyen {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.oiseau-pose--mobile-seul {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.oiseau-pose:not(.oiseau-pose--mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oiseau-pose--derriere {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<span v-if="label" class="status" :class="`status--${tone}`">
|
||||
<span class="status-dot" aria-hidden="true" />
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="since" class="status-since">· {{ since }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Project maturity, as written in YAML: `status: En production` (+ `since`),
|
||||
// `status: En développement`, or the legacy `gestation: true`.
|
||||
const props = defineProps<{
|
||||
status?: string
|
||||
gestation?: boolean
|
||||
since?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.status || (props.gestation ? 'En gestation' : ''))
|
||||
|
||||
const tone = computed(() => {
|
||||
const s = label.value.toLowerCase()
|
||||
if (s.includes('production')) return 'prod'
|
||||
if (s.includes('développement') || s.includes('developpement')) return 'dev'
|
||||
return 'gest'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
color: hsl(var(--color-text) / 0.75);
|
||||
background: hsl(var(--color-text) / 0.06);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* En production : plein */
|
||||
.status--prod .status-dot { background: hsl(var(--axis-cit)); }
|
||||
.status--prod { color: hsl(var(--axis-cit)); background: hsl(var(--axis-cit) / 0.1); }
|
||||
|
||||
/* En développement : demi-plein */
|
||||
.status--dev .status-dot {
|
||||
background: linear-gradient(to top, hsl(var(--axis-eco)) 50%, transparent 50%);
|
||||
box-shadow: inset 0 0 0 1.5px hsl(var(--axis-eco));
|
||||
}
|
||||
.status--dev { color: hsl(var(--axis-eco)); background: hsl(var(--axis-eco) / 0.1); }
|
||||
|
||||
/* En gestation : cercle */
|
||||
.status--gest .status-dot { box-shadow: inset 0 0 0 1.5px hsl(var(--color-text) / 0.45); }
|
||||
|
||||
.status-since {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,12 @@ import type { Song } from '~/types/song'
|
||||
|
||||
let audio: HTMLAudioElement | null = null
|
||||
let animationFrameId: number | null = null
|
||||
// Track which milestones have been fired for the current song
|
||||
const firedMilestones = new Set<number>()
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const store = usePlayerStore()
|
||||
const { trackAudioPlay, trackAudioComplete, trackAudioProgress } = useTracking()
|
||||
|
||||
function getAudio(): HTMLAudioElement {
|
||||
if (!audio) {
|
||||
@@ -17,6 +20,10 @@ export function useAudioPlayer() {
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
if (store.currentSong) {
|
||||
trackAudioComplete(store.currentSong.id, store.currentSong.title)
|
||||
}
|
||||
firedMilestones.clear()
|
||||
const next = store.nextSong()
|
||||
if (next) {
|
||||
loadAndPlay(next)
|
||||
@@ -36,6 +43,16 @@ export function useAudioPlayer() {
|
||||
const update = () => {
|
||||
if (audio && !audio.paused) {
|
||||
store.setCurrentTime(audio.currentTime)
|
||||
// Track progress milestones (25 / 50 / 75 %)
|
||||
if (audio.duration > 0 && store.currentSong) {
|
||||
const pct = (audio.currentTime / audio.duration) * 100
|
||||
for (const milestone of [25, 50, 75] as const) {
|
||||
if (pct >= milestone && !firedMilestones.has(milestone)) {
|
||||
firedMilestones.add(milestone)
|
||||
trackAudioProgress(store.currentSong.id, milestone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
}
|
||||
@@ -52,34 +69,23 @@ export function useAudioPlayer() {
|
||||
async function loadAndPlay(song: Song) {
|
||||
const el = getAudio()
|
||||
store.setSong(song)
|
||||
firedMilestones.clear()
|
||||
|
||||
// Try OGG first, fall back to MP3
|
||||
const oggPath = song.file.replace(/\.mp3$/, '.ogg')
|
||||
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis')
|
||||
|
||||
el.src = canOgg ? oggPath : song.file
|
||||
// Only MP3 files are published (no .ogg on disk nor in production):
|
||||
// asking for OGG first cost a 404 and a delay on every track.
|
||||
el.src = song.file
|
||||
el.volume = store.volume
|
||||
|
||||
try {
|
||||
await el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
}
|
||||
catch {
|
||||
// If OGG failed, try MP3
|
||||
if (el.src !== song.file) {
|
||||
el.src = song.file
|
||||
try {
|
||||
await el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
trackAudioPlay(song.id, song.title)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Playback failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
getAudio().pause()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Song } from '~/types/song'
|
||||
import type { ChapterSongLink, BookConfig } from '~/types/book'
|
||||
import type { ChapterSongLink, ChapterPageLink, BookConfig } from '~/types/book'
|
||||
|
||||
let _configCache: BookConfig | null = null
|
||||
|
||||
@@ -13,9 +13,11 @@ async function loadConfig(): Promise<BookConfig> {
|
||||
author: parsed.book.author,
|
||||
description: parsed.book.description,
|
||||
coverImage: parsed.book.coverImage,
|
||||
pdfFile: parsed.book.pdfFile,
|
||||
chapters: [],
|
||||
songs: parsed.songs as Song[],
|
||||
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
||||
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
|
||||
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
||||
}
|
||||
|
||||
@@ -76,6 +78,14 @@ export function useBookData() {
|
||||
return link?.chapterSlug
|
||||
}
|
||||
|
||||
function getChapterPage(chapterSlug: string): number | undefined {
|
||||
return config.value?.chapterPages.find(cp => cp.chapterSlug === chapterSlug)?.page
|
||||
}
|
||||
|
||||
function getPdfUrl(): string {
|
||||
return config.value?.pdfFile || '/pdf/une-economie-du-don.pdf'
|
||||
}
|
||||
|
||||
function getBookMeta() {
|
||||
if (!config.value) return null
|
||||
return {
|
||||
@@ -98,5 +108,7 @@ export function useBookData() {
|
||||
getChapterForSong,
|
||||
getPlaylistOrder,
|
||||
getBookMeta,
|
||||
getChapterPage,
|
||||
getPdfUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
export function useGrateWizard() {
|
||||
const appConfig = useAppConfig()
|
||||
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
const { url } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
|
||||
function launch(e?: Event) {
|
||||
const left = Math.round((window.screen.width - popup.width) / 2)
|
||||
const top = Math.round((window.screen.height - popup.height) / 2)
|
||||
const win = window.open(
|
||||
url,
|
||||
'grateWizard',
|
||||
`width=${popup.width},height=${popup.height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=yes,resizable=yes`,
|
||||
)
|
||||
if (win) e?.preventDefault()
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
e?.preventDefault()
|
||||
}
|
||||
|
||||
return { url, launch }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Normalised view of the home content, shared by the three home variants.
|
||||
// Everything comes from YAML (site/pages/home.yml, site/site.yml,
|
||||
// site/pages/evenement.yml, site/pages/numerique/cloud-libre.yml) — no copy here.
|
||||
|
||||
export type AxisKey = 'numerique' | 'economie' | 'politique'
|
||||
export type StackStatus = 'service' | 'catalogue' | 'avenir'
|
||||
|
||||
export interface HomeAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
highlight?: boolean
|
||||
secondary?: boolean
|
||||
to?: string
|
||||
href?: string
|
||||
/** Short status line shown under the button (e.g. the v1 in production) */
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface HomeTool {
|
||||
label: string
|
||||
description: string
|
||||
to?: string
|
||||
href?: string
|
||||
gestation?: boolean
|
||||
/** « En production », « En développement »… — wins over `gestation` */
|
||||
status?: string
|
||||
since?: string
|
||||
icon?: string
|
||||
presentation?: { title: string, text: string }
|
||||
actions?: HomeAction[]
|
||||
}
|
||||
|
||||
export interface HomeStack {
|
||||
role: string
|
||||
name?: string
|
||||
status: StackStatus
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface HomeAxis {
|
||||
key: AxisKey
|
||||
/** Short label as used in the navigation ("numérique", "économique", "citoyenne") */
|
||||
short: string
|
||||
title: string
|
||||
icon: string
|
||||
to: string
|
||||
/** What is "le plus en amont" on this axis (hero.axes) */
|
||||
lever: string
|
||||
promise: string
|
||||
/** CSS custom property holding the axis hue */
|
||||
hue: string
|
||||
items: HomeTool[]
|
||||
}
|
||||
|
||||
const AXIS_META: Record<AxisKey, { heroLabel: string, to: string, hue: string, short: string }> = {
|
||||
numerique: { heroLabel: 'numérique', to: '/numerique', hue: '--axis-num', short: 'numérique' },
|
||||
economie: { heroLabel: 'économie', to: '/economique', hue: '--axis-eco', short: 'économique' },
|
||||
politique: { heroLabel: 'citoyenne', to: '/citoyenne', hue: '--axis-cit', short: 'citoyenne' },
|
||||
}
|
||||
|
||||
export const AXIS_ORDER: AxisKey[] = ['numerique', 'economie', 'politique']
|
||||
|
||||
export async function useHomeData() {
|
||||
const [{ data: home }, { data: site }, { data: event }, { data: cloud }] = await Promise.all([
|
||||
usePageContent('home'),
|
||||
useSiteContent(),
|
||||
usePageContent('evenement'),
|
||||
usePageContent('numerique/cloud-libre'),
|
||||
])
|
||||
|
||||
const hero = computed(() => (home.value as any)?.hero ?? {})
|
||||
|
||||
const axes = computed<HomeAxis[]>(() => {
|
||||
const raw = (home.value as any)?.axes ?? {}
|
||||
const levers: { label: string, value: string }[] = hero.value.axes ?? []
|
||||
return AXIS_ORDER.filter(k => raw[k]).map((key) => {
|
||||
const meta = AXIS_META[key]
|
||||
return {
|
||||
key,
|
||||
short: meta.short,
|
||||
title: raw[key].title,
|
||||
icon: raw[key].icon,
|
||||
to: meta.to,
|
||||
lever: levers.find(l => l.label === meta.heroLabel)?.value ?? '',
|
||||
promise: raw[key].promise ?? '',
|
||||
hue: meta.hue,
|
||||
items: raw[key].items ?? [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const lpk = computed(() => (site.value as any)?.lpk as
|
||||
{ name: string, text: string, role: string, to: string } | undefined)
|
||||
|
||||
/** Bouquet sweetHome, flattened — the stacks served by LaPlanK */
|
||||
const bouquet = computed(() => {
|
||||
const section = ((cloud.value as any)?.sections ?? []).find((s: any) => s.type === 'territoire')
|
||||
const b = section?.bouquet
|
||||
if (!b) return null
|
||||
const stacks: (HomeStack & { category: string })[] = []
|
||||
for (const cat of b.categories ?? []) {
|
||||
for (const item of cat.items ?? []) {
|
||||
if (typeof item === 'string') continue
|
||||
stacks.push({ ...item, category: cat.label })
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: b.title as string,
|
||||
statuses: (b.statuses ?? {}) as Record<StackStatus, string>,
|
||||
categories: (b.categories ?? []) as { label: string, icon: string, items: HomeStack[] }[],
|
||||
stacks,
|
||||
inService: stacks.filter(s => s.status === 'service' && s.name),
|
||||
}
|
||||
})
|
||||
|
||||
const evenement = computed(() => {
|
||||
const e = event.value as any
|
||||
const teaser = (home.value as any)?.evenement
|
||||
if (!e && !teaser) return null
|
||||
const politique = (e?.axes ?? []).find((a: any) => a.label === 'Politique')
|
||||
return {
|
||||
to: teaser?.to ?? '/evenement',
|
||||
title: e?.title ?? teaser?.title,
|
||||
teaser: teaser ? `${teaser.title} ${teaser.subtitle}` : '',
|
||||
subtitle: e?.subtitle ?? '',
|
||||
tagline: e?.tagline ?? '',
|
||||
leitmotiv: e?.leitmotiv ?? '',
|
||||
description: e?.description ?? '',
|
||||
gestation: e?.gestation ?? teaser?.gestation,
|
||||
config: (e?.config ?? []) as { icon: string, label: string, detail: string }[],
|
||||
espaces: (e?.espaces ?? []) as { icon: string, label: string, desc: string }[],
|
||||
/** "Autonomie d'un bassin de vie — accessible et reproductible" */
|
||||
reproduction: (politique?.items?.[0] as string) ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
home,
|
||||
hero,
|
||||
axes,
|
||||
lpk,
|
||||
bouquet,
|
||||
evenement,
|
||||
book: computed(() => (home.value as any)?.book),
|
||||
format: computed(() => (home.value as any)?.format as { title: string, text: string } | undefined),
|
||||
toolboxTitle: computed(() => (home.value as any)?.toolbox?.title as string | undefined),
|
||||
consoleLabels: computed(() => (home.value as any)?.console as { prefix: string, top: string, bottom: string } | undefined),
|
||||
gratewizard: computed(() => (home.value as any)?.gratewizard),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Tracks scroll depth milestones (25 / 50 / 75 / 100 %) on each page.
|
||||
* Also intercepts external link clicks and tracks them.
|
||||
*
|
||||
* Usage: call once in default.vue layout.
|
||||
*/
|
||||
export function useScrollTracking() {
|
||||
const route = useRoute()
|
||||
const { trackScrollDepth, trackExternalLink } = useTracking()
|
||||
|
||||
// ── Scroll depth ────────────────────────────────────────────────────────
|
||||
const fired = new Set<number>()
|
||||
|
||||
function onScroll() {
|
||||
const el = document.documentElement
|
||||
const scrolled = el.scrollTop + el.clientHeight
|
||||
const total = el.scrollHeight
|
||||
if (total <= el.clientHeight) return
|
||||
const pct = (scrolled / total) * 100
|
||||
|
||||
for (const milestone of [25, 50, 75, 100] as const) {
|
||||
if (pct >= milestone && !fired.has(milestone)) {
|
||||
fired.add(milestone)
|
||||
trackScrollDepth(route.path, milestone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset fired milestones on route change
|
||||
watch(() => route.path, () => fired.clear())
|
||||
|
||||
// ── External link tracking ───────────────────────────────────────────────
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
const target = (e.target as HTMLElement).closest('a')
|
||||
if (!target) return
|
||||
const href = target.getAttribute('href') || ''
|
||||
if (!href.startsWith('http') && !href.startsWith('//')) return
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.hostname === 'librodrome.org') return
|
||||
trackExternalLink(href, target.textContent?.trim() || '')
|
||||
}
|
||||
catch { /* invalid URL, ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.addEventListener('click', onLinkClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.removeEventListener('click', onLinkClick)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Applique toutes les balises SEO (og:*, Twitter Cards, canonical, description)
|
||||
* à partir du contenu YAML d'une page.
|
||||
*
|
||||
* Usage dans les pages :
|
||||
* useSeoPage({ title: content.value?.meta?.title, description: content.value?.description })
|
||||
*
|
||||
* L'og:image par défaut est /og-default.png (logo §).
|
||||
* Chaque section peut surcharger avec son propre image via le champ seo.image du YAML.
|
||||
*/
|
||||
export function useSeoPage(opts: {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
image?: string | null
|
||||
type?: 'website' | 'article' | 'book'
|
||||
}) {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org'
|
||||
|
||||
const title = opts.title || 'Le Librodrome'
|
||||
const description = opts.description
|
||||
|| 'Autonomie numérique, économique et citoyenne. Un livre et des chansons sur l\'économie du don.'
|
||||
const rawImage = opts.image || '/og-default.png?v=3'
|
||||
const image = rawImage.startsWith('http') ? rawImage : `${siteUrl}${rawImage}`
|
||||
const canonical = `${siteUrl}${route.path}`
|
||||
const type = opts.type || 'website'
|
||||
|
||||
useSeoMeta({
|
||||
// Open Graph
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
ogImage: image,
|
||||
ogImageWidth: 1200,
|
||||
ogImageHeight: 630,
|
||||
ogUrl: canonical,
|
||||
ogType: type,
|
||||
ogSiteName: 'Le Librodrome',
|
||||
ogLocale: 'fr_FR',
|
||||
// Twitter Cards
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: title,
|
||||
twitterDescription: description,
|
||||
twitterImage: image,
|
||||
// Standard
|
||||
description,
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: canonical }],
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,12 @@ interface SiteContent {
|
||||
credits: string
|
||||
links: NavItem[]
|
||||
}
|
||||
lpk?: {
|
||||
name: string
|
||||
text: string
|
||||
role: string
|
||||
to: string
|
||||
}
|
||||
gratewizard: {
|
||||
url: string
|
||||
popup: {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Umami analytics wrapper — safe server-side, no-op when not configured.
|
||||
*
|
||||
* Events convention:
|
||||
* audio:play | audio:pause | audio:complete | audio:progress
|
||||
* pdf:open | pdf:close
|
||||
* player:open | player:chapter | player:mode
|
||||
* scroll:depth
|
||||
* link:external
|
||||
* cta:click
|
||||
*/
|
||||
export function useTracking() {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const enabled = !!runtimeConfig.public.umamiWebsiteId
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────────
|
||||
function track(event: string, data?: Record<string, unknown>) {
|
||||
if (!import.meta.client || !enabled) return
|
||||
const umami = (window as Record<string, unknown>).umami as
|
||||
| { track: (event: string, data?: unknown) => void }
|
||||
| undefined
|
||||
umami?.track(event, data)
|
||||
}
|
||||
|
||||
// ── Audio ────────────────────────────────────────────────────────────────
|
||||
function trackAudioPlay(songId: string, songTitle: string, context?: string) {
|
||||
track('audio:play', { song_id: songId, song_title: songTitle, context })
|
||||
}
|
||||
|
||||
function trackAudioPause(songId: string, progressPct: number) {
|
||||
track('audio:pause', { song_id: songId, progress_pct: progressPct })
|
||||
}
|
||||
|
||||
function trackAudioComplete(songId: string, songTitle: string) {
|
||||
track('audio:complete', { song_id: songId, song_title: songTitle })
|
||||
}
|
||||
|
||||
/** Fired at 25 / 50 / 75 % milestones */
|
||||
function trackAudioProgress(songId: string, milestone: 25 | 50 | 75) {
|
||||
track('audio:progress', { song_id: songId, milestone })
|
||||
}
|
||||
|
||||
// ── PDF ──────────────────────────────────────────────────────────────────
|
||||
function trackPdfOpen(trigger?: string) {
|
||||
track('pdf:open', { trigger })
|
||||
}
|
||||
|
||||
function trackPdfClose(pagesVisited: number, durationMs: number) {
|
||||
track('pdf:close', { pages_visited: pagesVisited, duration_ms: durationMs })
|
||||
}
|
||||
|
||||
// ── BookPlayer ───────────────────────────────────────────────────────────
|
||||
function trackPlayerOpen(trigger?: string) {
|
||||
track('player:open', { trigger })
|
||||
}
|
||||
|
||||
function trackPlayerChapter(chapterSlug: string) {
|
||||
track('player:chapter', { chapter_slug: chapterSlug })
|
||||
}
|
||||
|
||||
function trackPlayerMode(mode: 'guided' | 'scroll') {
|
||||
track('player:mode', { mode })
|
||||
}
|
||||
|
||||
// ── Navigation & UX ──────────────────────────────────────────────────────
|
||||
function trackScrollDepth(page: string, depth: 25 | 50 | 75 | 100) {
|
||||
track('scroll:depth', { page, depth })
|
||||
}
|
||||
|
||||
function trackExternalLink(url: string, label?: string) {
|
||||
const route = useRoute()
|
||||
track('link:external', { url, label, from_page: route.path })
|
||||
}
|
||||
|
||||
function trackCta(label: string, target?: string) {
|
||||
const route = useRoute()
|
||||
track('cta:click', { label, target, from_page: route.path })
|
||||
}
|
||||
|
||||
return {
|
||||
track,
|
||||
enabled,
|
||||
trackAudioPlay,
|
||||
trackAudioPause,
|
||||
trackAudioComplete,
|
||||
trackAudioProgress,
|
||||
trackPdfOpen,
|
||||
trackPdfClose,
|
||||
trackPlayerOpen,
|
||||
trackPlayerChapter,
|
||||
trackPlayerMode,
|
||||
trackScrollDepth,
|
||||
trackExternalLink,
|
||||
trackCta,
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -1,15 +1,59 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<main>
|
||||
<main class="app-main">
|
||||
<slot />
|
||||
|
||||
<!-- 益 Yì (Increase, #42) — sceau hexagramme (traits épais, coins nets, ~carré) -->
|
||||
<svg class="app-seal" viewBox="0 0 112 105" fill="currentColor" aria-hidden="true">
|
||||
<!-- Line 6 (top) — yang -->
|
||||
<rect x="6" y="6" width="100" height="8"/>
|
||||
<!-- Line 5 — yang -->
|
||||
<rect x="6" y="23" width="100" height="8"/>
|
||||
<!-- Line 4 — yin -->
|
||||
<rect x="6" y="40" width="42" height="8"/>
|
||||
<rect x="64" y="40" width="42" height="8"/>
|
||||
<!-- Line 3 — yin -->
|
||||
<rect x="6" y="57" width="42" height="8"/>
|
||||
<rect x="64" y="57" width="42" height="8"/>
|
||||
<!-- Line 2 — yin -->
|
||||
<rect x="6" y="74" width="42" height="8"/>
|
||||
<rect x="64" y="74" width="42" height="8"/>
|
||||
<!-- Line 1 (bottom) — yang -->
|
||||
<rect x="6" y="91" width="100" height="8"/>
|
||||
</svg>
|
||||
</main>
|
||||
<LayoutTheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useScrollTracking()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* The seal's block, between the page and the footer: a bird of a page that stands on the footer's
|
||||
top rule takes its offset from here (evenement, the puppeteer) */
|
||||
.app-main {
|
||||
--seal-w: 44px;
|
||||
--seal-mt: 2rem;
|
||||
--seal-mb: 1rem;
|
||||
--seal-block: calc(var(--seal-mt) + var(--seal-w) * 105 / 112 + var(--seal-mb));
|
||||
}
|
||||
|
||||
/* === Seal — 益 Yì (Increase) === */
|
||||
.app-seal {
|
||||
display: block;
|
||||
width: var(--seal-w);
|
||||
/* its height follows the viewBox: width × 105 / 112 (in --seal-block) */
|
||||
margin: var(--seal-mt) 1.5rem var(--seal-mb) auto;
|
||||
color: hsl(var(--color-accent));
|
||||
opacity: 0.28;
|
||||
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
|
||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
|
||||
}
|
||||
</style>
|
||||
|
||||
+16
-49
@@ -1,36 +1,14 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden section-padding">
|
||||
<!-- Shadok philosopher: character sitting cross-legged, floating -->
|
||||
<svg class="shadok-philosopher" viewBox="0 0 220 280" fill="none" aria-hidden="true">
|
||||
<!-- Body (round, serene) -->
|
||||
<ellipse cx="110" cy="150" rx="48" ry="55" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<ellipse cx="110" cy="82" rx="26" ry="25" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Closed eyes (meditating) -->
|
||||
<path d="M96 80 Q100 84 105 80" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M115 80 Q119 84 124 80" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<!-- Serene smile -->
|
||||
<path d="M102 93 Q110 98 118 93" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arms resting on knees -->
|
||||
<path d="M64 155 Q55 180 70 200" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<path d="M156 155 Q165 180 150 200" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<!-- Hands on knees -->
|
||||
<circle cx="70" cy="200" r="5" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="150" cy="200" r="5" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Crossed legs -->
|
||||
<path d="M80 205 Q90 230 120 235" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<path d="M140 205 Q130 230 100 235" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<!-- Floating aura lines -->
|
||||
<path d="M50 245 Q110 260 170 245" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" fill="none" opacity="0.25"/>
|
||||
<path d="M60 255 Q110 268 160 255" stroke="currentColor" stroke-width="1" stroke-dasharray="3 5" fill="none" opacity="0.18"/>
|
||||
<!-- Small sparkles around -->
|
||||
<circle cx="55" cy="110" r="2.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="170" cy="95" r="2" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="160" cy="130" r="3" fill="currentColor" opacity="0.15"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<div class="container-content relative mx-auto max-w-3xl">
|
||||
<!-- In the margins, at the height of « La vision » and of the economic proposal -->
|
||||
<UiOiseauPose nom="philosophe" pose="libre" left="calc(100% + 50px)" top="470px" :w="120" retourne />
|
||||
<UiOiseauPose nom="penseur" pose="libre" right="calc(100% + 60px)" top="720px" :w="100" />
|
||||
<ContentRenderer v-if="page" :value="page" class="prose" />
|
||||
<!-- Credit of the mascot (Yvv, word for word, 27/10) -->
|
||||
<p class="oiseaux-credit">
|
||||
N'ayant pas les moyens d'acquérir des droits, le personnage mascotte est une création qui fait un clin d'œil aux shadoks, avec respect et une grande affection, petit hommage en passant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,8 +18,9 @@ definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'À propos',
|
||||
useSeoPage({
|
||||
title: 'À propos — Le Librodrome',
|
||||
description: 'Le Librodrome : l\'histoire du projet, la démarche et les personnes derrière le livre et les outils d\'autonomie.',
|
||||
})
|
||||
|
||||
const { data: page } = await useAsyncData('about', () =>
|
||||
@@ -50,23 +29,11 @@ const { data: page } = await useAsyncData('about', () =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shadok-philosopher {
|
||||
position: absolute;
|
||||
right: 3%;
|
||||
bottom: 6%;
|
||||
width: clamp(120px, 15vw, 220px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float-philosopher 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-philosopher {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-philosopher { display: none; }
|
||||
.oiseaux-credit {
|
||||
margin: 3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -209,7 +209,7 @@ async function save() {
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -217,15 +217,15 @@ async function save() {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
/* ── Song tags ── */
|
||||
@@ -233,45 +233,45 @@ async function save() {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(20 8% 22%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
transition: all 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.song-tag:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.song-tag--active {
|
||||
border-color: hsl(12 76% 48% / 0.6);
|
||||
background: hsl(12 76% 48% / 0.08);
|
||||
border-color: hsl(var(--color-primary) / 0.6);
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
|
||||
.song-tag--primary {
|
||||
border-color: hsl(45 90% 55%);
|
||||
background: hsl(45 90% 55% / 0.08);
|
||||
border-color: hsl(var(--color-accent));
|
||||
background: hsl(var(--color-accent) / 0.08);
|
||||
}
|
||||
|
||||
.song-tag-label {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.song-tag--active .song-tag-label {
|
||||
color: hsl(12 76% 68%);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.song-tag--primary .song-tag-label {
|
||||
color: hsl(45 90% 65%);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.song-tag-label:hover {
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.song-star {
|
||||
@@ -281,16 +281,16 @@ async function save() {
|
||||
padding: 0.375rem 0 0.375rem 0.625rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(20 8% 30%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.song-star:hover {
|
||||
color: hsl(45 90% 55%);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.song-star--active {
|
||||
color: hsl(45 90% 55%);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
</style>
|
||||
|
||||
+160
-20
@@ -1,10 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Chapitres</h1>
|
||||
<h1 class="font-display text-2xl font-bold text-white">Livre & chapitres</h1>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
|
||||
</div>
|
||||
|
||||
<!-- PDF config -->
|
||||
<div class="pdf-section">
|
||||
<h2 class="font-display text-sm font-semibold text-white/60 mb-3">PDF du livre</h2>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-white/40 mb-1">Chemin du fichier PDF</label>
|
||||
<input
|
||||
v-model="pdfPath"
|
||||
class="admin-input w-full font-mono text-xs"
|
||||
placeholder="/pdf/une-economie-du-don.pdf"
|
||||
/>
|
||||
</div>
|
||||
<button class="save-pdf-btn" @click="savePdfConfig" :disabled="savingPdf">
|
||||
<div v-if="savingPdf" class="i-lucide-loader-2 h-3.5 w-3.5 animate-spin" />
|
||||
<div v-else-if="savedPdf" class="i-lucide-check h-3.5 w-3.5" />
|
||||
<div v-else class="i-lucide-save h-3.5 w-3.5" />
|
||||
{{ savedPdf ? 'Enregistré' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-2">
|
||||
Uploadez le PDF via Médias, puis renseignez le chemin ici.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(chapter, i) in chapters"
|
||||
@@ -35,6 +59,20 @@
|
||||
>{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-if="pdfOutline.length"
|
||||
class="page-select"
|
||||
:value="getChapterPage(chapter.slug) ?? ''"
|
||||
@change="setChapterPage(chapter.slug, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">— Page PDF —</option>
|
||||
<option
|
||||
v-for="entry in pdfOutline"
|
||||
:key="`${entry.page}-${entry.title}`"
|
||||
:value="entry.page"
|
||||
>{{ '\u00A0\u00A0'.repeat(entry.level) }}{{ entry.title }} (p.{{ entry.page }})</option>
|
||||
</select>
|
||||
<span v-else class="text-xs text-white/30">…</span>
|
||||
<button
|
||||
class="delete-btn"
|
||||
@click="removeChapter(chapter.slug)"
|
||||
@@ -91,6 +129,58 @@ const saved = ref(false)
|
||||
const newTitle = ref('')
|
||||
const newSlug = ref('')
|
||||
|
||||
// PDF path + outline
|
||||
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
|
||||
const savingPdf = ref(false)
|
||||
const savedPdf = ref(false)
|
||||
|
||||
const pdfOutline = ref<Array<{ title: string; page: number; level: number }>>([])
|
||||
|
||||
|
||||
const chapterPageMap = ref<Record<string, number | undefined>>({})
|
||||
if (bookConfig.value?.chapterPages) {
|
||||
for (const cp of bookConfig.value.chapterPages) {
|
||||
chapterPageMap.value[cp.chapterSlug] = cp.page
|
||||
}
|
||||
}
|
||||
|
||||
function getChapterPage(slug: string): number | undefined {
|
||||
return chapterPageMap.value[slug]
|
||||
}
|
||||
|
||||
function setChapterPage(slug: string, val: string) {
|
||||
const num = parseInt(val, 10)
|
||||
if (num > 0) chapterPageMap.value[slug] = num
|
||||
else delete chapterPageMap.value[slug]
|
||||
}
|
||||
|
||||
// Charger le sommaire PDF côté client via l'API serveur
|
||||
onMounted(() => {
|
||||
$fetch<Array<{ title: string; page: number; level: number }>>('/api/admin/pdf-outline')
|
||||
.then((data) => { pdfOutline.value = data })
|
||||
.catch((err) => { console.warn('PDF outline load failed:', err) })
|
||||
})
|
||||
|
||||
async function savePdfConfig() {
|
||||
if (!bookConfig.value) return
|
||||
savingPdf.value = true
|
||||
savedPdf.value = false
|
||||
try {
|
||||
bookConfig.value.book.pdfFile = pdfPath.value
|
||||
bookConfig.value.chapterPages = Object.entries(chapterPageMap.value)
|
||||
.filter(([, page]) => page != null)
|
||||
.map(([chapterSlug, page]) => ({ chapterSlug, page }))
|
||||
await $fetch('/api/admin/content/config', {
|
||||
method: 'PUT',
|
||||
body: bookConfig.value,
|
||||
})
|
||||
savedPdf.value = true
|
||||
setTimeout(() => { savedPdf.value = false }, 2000)
|
||||
} finally {
|
||||
savingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & drop state
|
||||
const dragIdx = ref<number | null>(null)
|
||||
const dropIdx = ref<number | null>(null)
|
||||
@@ -156,19 +246,52 @@ async function removeChapter(slug: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.save-pdf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-pdf-btn:hover:not(:disabled) {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.save-pdf-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chapter-item:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 6%);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.chapter-item--dragging {
|
||||
@@ -176,13 +299,13 @@ async function removeChapter(slug: string) {
|
||||
}
|
||||
|
||||
.chapter-item--over {
|
||||
border-top: 2px solid hsl(12 76% 48%);
|
||||
border-top: 2px solid hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
padding: 0.25rem;
|
||||
color: hsl(20 8% 35%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -193,7 +316,7 @@ async function removeChapter(slug: string) {
|
||||
.chapter-order {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(var(--color-primary) / 0.5);
|
||||
font-weight: 600;
|
||||
width: 1.75rem;
|
||||
}
|
||||
@@ -205,13 +328,13 @@ async function removeChapter(slug: string) {
|
||||
|
||||
.chapter-title {
|
||||
display: block;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chapter-title:hover {
|
||||
color: hsl(12 76% 68%);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.chapter-songs {
|
||||
@@ -225,9 +348,26 @@ async function removeChapter(slug: string) {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(12 76% 48% / 0.1);
|
||||
color: hsl(12 76% 60%);
|
||||
border: 1px solid hsl(12 76% 48% / 0.2);
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.page-select {
|
||||
flex-shrink: 0;
|
||||
max-width: 14rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
@@ -248,15 +388,15 @@ async function removeChapter(slug: string) {
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
@@ -265,9 +405,9 @@ async function removeChapter(slug: string) {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 25%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
@@ -275,8 +415,8 @@ async function removeChapter(slug: string) {
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(12 76% 68%);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.add-btn:disabled {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<p class="text-sm text-white/50">Identité, navigation, footer</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/pages/home" class="dash-card">
|
||||
<div class="i-lucide-home h-8 w-8 text-primary mb-2" />
|
||||
<NuxtLink to="/admin/pages" class="dash-card">
|
||||
<div class="i-lucide-file-text h-8 w-8 text-primary mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Pages</h2>
|
||||
<p class="text-sm text-white/50">Contenus des pages publiques</p>
|
||||
</NuxtLink>
|
||||
@@ -27,6 +27,12 @@
|
||||
<p class="text-sm text-white/50">Métadonnées des pistes</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/messages" class="dash-card">
|
||||
<div class="i-lucide-message-square h-8 w-8 text-accent mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Messages</h2>
|
||||
<p class="text-sm text-white/50">Modération des messages visiteurs</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/media" class="dash-card">
|
||||
<div class="i-lucide-image h-8 w-8 text-accent mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Médias</h2>
|
||||
@@ -47,14 +53,14 @@ definePageMeta({
|
||||
.dash-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dash-card:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 8%);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
|
||||
+13
-13
@@ -72,16 +72,16 @@ async function login() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 3.5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 1rem;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.login-error {
|
||||
@@ -97,7 +97,7 @@ async function login() {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -105,16 +105,16 @@ async function login() {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
@@ -126,7 +126,7 @@ async function login() {
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
@@ -135,7 +135,7 @@ async function login() {
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
@@ -147,12 +147,12 @@ async function login() {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.dev-hint code {
|
||||
color: hsl(12 76% 60%);
|
||||
background: hsl(20 8% 10%);
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-surface));
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
+161
-33
@@ -5,7 +5,7 @@
|
||||
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-3">
|
||||
<div v-if="messages?.length" class="space-y-4">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
@@ -17,6 +17,9 @@
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span>
|
||||
<span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span>
|
||||
<span class="type-badge" :class="`type-badge--${msg.type || 'reaction'}`">
|
||||
{{ typeLabel(msg.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
|
||||
@@ -29,23 +32,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texte éditable -->
|
||||
<div v-if="editing === msg.id" class="mb-3">
|
||||
<input
|
||||
v-model="editForm.author"
|
||||
class="admin-input mb-2 w-full"
|
||||
placeholder="Auteur"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editForm.text"
|
||||
class="admin-input w-full"
|
||||
rows="3"
|
||||
/>
|
||||
<!-- Texte du message -->
|
||||
<div v-if="editing === msg.id" class="mb-3 space-y-2">
|
||||
<input v-model="editForm.author" class="admin-input w-full" placeholder="Auteur" />
|
||||
<select v-model="editForm.type" class="admin-input w-full">
|
||||
<option value="question">Question</option>
|
||||
<option value="suggestion">Suggestion</option>
|
||||
<option value="retour">Retour d'expérience</option>
|
||||
</select>
|
||||
<textarea v-model="editForm.text" class="admin-input w-full" rows="3" />
|
||||
</div>
|
||||
<p v-else class="msg-text text-sm leading-relaxed mb-3">{{ msg.text }}</p>
|
||||
|
||||
<!-- Réponse existante -->
|
||||
<div v-if="msg.reply && replyEditing !== msg.id" class="reply-block mb-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-primary/80">Réponse publiée</span>
|
||||
<span class="text-xs text-white/30">{{ formatDate(msg.reply.publishedAt) }}</span>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm leading-relaxed">{{ msg.reply.text }}</p>
|
||||
<button class="action-btn mt-2 text-xs" @click="startReply(msg)">
|
||||
<div class="i-lucide-pencil h-3 w-3" />
|
||||
Modifier la réponse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire réponse -->
|
||||
<div v-if="replyEditing === msg.id" class="reply-form mb-3">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
class="admin-input w-full text-sm"
|
||||
rows="3"
|
||||
placeholder="Votre réponse..."
|
||||
/>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="action-btn action-btn--save" @click="saveReply(msg)">
|
||||
<div class="i-lucide-check h-3.5 w-3.5" />
|
||||
Publier la réponse
|
||||
</button>
|
||||
<button v-if="msg.reply" class="action-btn action-btn--danger" @click="removeReply(msg)">
|
||||
<div class="i-lucide-x h-3.5 w-3.5" />
|
||||
Supprimer la réponse
|
||||
</button>
|
||||
<button class="action-btn" @click="replyEditing = null">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="action-btn" @click="togglePublished(msg)">
|
||||
<div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
|
||||
{{ msg.published ? 'Dépublier' : 'Publier' }}
|
||||
@@ -56,15 +90,18 @@
|
||||
<div class="i-lucide-check h-3.5 w-3.5" />
|
||||
Valider
|
||||
</button>
|
||||
<button class="action-btn" @click="editing = null">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="action-btn" @click="editing = null">Annuler</button>
|
||||
</template>
|
||||
<button v-else class="action-btn" @click="startEdit(msg)">
|
||||
<div class="i-lucide-pencil h-3.5 w-3.5" />
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
<button v-if="replyEditing !== msg.id && !msg.reply" class="action-btn action-btn--reply" @click="startReply(msg)">
|
||||
<div class="i-lucide-reply h-3.5 w-3.5" />
|
||||
Répondre
|
||||
</button>
|
||||
|
||||
<button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
|
||||
<div class="i-lucide-trash-2 h-3.5 w-3.5" />
|
||||
Supprimer
|
||||
@@ -86,18 +123,33 @@ definePageMeta({
|
||||
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
|
||||
|
||||
const editing = ref<number | null>(null)
|
||||
const editForm = reactive({ author: '', text: '' })
|
||||
const editForm = reactive({ author: '', text: '', type: 'question' })
|
||||
|
||||
const replyEditing = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
function startEdit(msg: any) {
|
||||
editing.value = msg.id
|
||||
editForm.author = msg.author
|
||||
editForm.text = msg.text
|
||||
editForm.type = TYPE_LABELS[msg.type] ? msg.type : 'question'
|
||||
}
|
||||
|
||||
async function saveEdit(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { author: editForm.author, text: editForm.text },
|
||||
body: { author: editForm.author, text: editForm.text, type: editForm.type },
|
||||
})
|
||||
editing.value = null
|
||||
await refresh()
|
||||
@@ -111,6 +163,29 @@ async function togglePublished(msg: any) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function startReply(msg: any) {
|
||||
replyEditing.value = msg.id
|
||||
replyText.value = msg.reply?.text ?? ''
|
||||
}
|
||||
|
||||
async function saveReply(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { reply: replyText.value.trim() || null },
|
||||
})
|
||||
replyEditing.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function removeReply(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { reply: null },
|
||||
})
|
||||
replyEditing.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function remove(msg: any) {
|
||||
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
|
||||
@@ -130,14 +205,14 @@ function formatDate(iso: string) {
|
||||
|
||||
<style scoped>
|
||||
.msg-row {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-bg));
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.msg-row--draft {
|
||||
border-left: 3px solid hsl(36 80% 52%);
|
||||
border-left: 3px solid hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -155,22 +230,61 @@ function formatDate(iso: string) {
|
||||
}
|
||||
|
||||
.status-badge--draft {
|
||||
background: hsl(36 80% 52% / 0.15);
|
||||
color: hsl(36 80% 66%);
|
||||
background: hsl(var(--color-accent) / 0.15);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
.type-badge--question {
|
||||
background: hsl(220 70% 50% / 0.12);
|
||||
color: hsl(220 70% 65%);
|
||||
}
|
||||
|
||||
.type-badge--suggestion {
|
||||
background: hsl(280 60% 55% / 0.12);
|
||||
color: hsl(280 60% 70%);
|
||||
}
|
||||
|
||||
.type-badge--retour {
|
||||
background: hsl(35 70% 50% / 0.12);
|
||||
color: hsl(35 70% 65%);
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.3);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.reply-form {
|
||||
background: hsl(var(--color-surface) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -180,16 +294,16 @@ function formatDate(iso: string) {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: none;
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.action-btn--save {
|
||||
@@ -201,6 +315,20 @@ function formatDate(iso: string) {
|
||||
background: hsl(142 70% 40% / 0.1);
|
||||
}
|
||||
|
||||
.action-btn--reply {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary) / 0.8);
|
||||
}
|
||||
|
||||
.action-btn--reply:hover {
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
color: hsl(var(--color-text) / 0.72);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: hsl(0 70% 40% / 0.1);
|
||||
border-color: hsl(0 70% 40% / 0.3);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Dashboard
|
||||
<NuxtLink to="/admin/pages" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Pages
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">
|
||||
Page : {{ pageName }}
|
||||
Page : {{ pagePath }}
|
||||
</h1>
|
||||
</div>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
@@ -40,9 +40,12 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = computed(() => route.params.name as string)
|
||||
const pagePath = computed(() => {
|
||||
const p = route.params.path
|
||||
return Array.isArray(p) ? p.join('/') : p
|
||||
})
|
||||
|
||||
const { data, error } = await useFetch(() => `/api/content/pages/${pageName.value}`)
|
||||
const { data, error } = await useFetch(() => `/api/content/pages/${pagePath.value}`)
|
||||
|
||||
const yamlContent = ref('')
|
||||
|
||||
@@ -60,7 +63,7 @@ async function save() {
|
||||
saved.value = false
|
||||
try {
|
||||
const parsed = yaml.parse(yamlContent.value)
|
||||
await $fetch(`/api/admin/content/pages/${pageName.value}`, {
|
||||
await $fetch(`/api/admin/content/pages/${pagePath.value}`, {
|
||||
method: 'PUT',
|
||||
body: parsed,
|
||||
})
|
||||
@@ -80,10 +83,10 @@ async function save() {
|
||||
.yaml-textarea {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 4%);
|
||||
color: hsl(36 80% 76%);
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-accent));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
@@ -93,6 +96,6 @@ async function save() {
|
||||
|
||||
.yaml-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Dashboard
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">Pages</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pages" class="flex flex-col gap-6">
|
||||
<!-- Root pages -->
|
||||
<div v-if="rootPages.length">
|
||||
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">Pages principales</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="page in rootPages"
|
||||
:key="page.path"
|
||||
:to="`/admin/pages/${page.path}`"
|
||||
class="page-card"
|
||||
>
|
||||
<div class="i-lucide-file-text h-5 w-5 text-primary mb-1" />
|
||||
<span class="font-mono text-sm text-white">{{ page.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section pages -->
|
||||
<div v-for="[section, sectionPages] in sections" :key="section">
|
||||
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">
|
||||
<span class="i-lucide-folder h-4 w-4 inline-block mr-1 text-accent" />
|
||||
{{ section }}
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="page in sectionPages"
|
||||
:key="page.path"
|
||||
:to="`/admin/pages/${page.path}`"
|
||||
class="page-card"
|
||||
>
|
||||
<div class="i-lucide-file-text h-5 w-5 text-primary/60 mb-1" />
|
||||
<span class="font-mono text-sm text-white">{{ page.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
interface PageEntry {
|
||||
path: string
|
||||
label: string
|
||||
section?: string
|
||||
}
|
||||
|
||||
const { data: pages } = await useFetch<PageEntry[]>('/api/admin/content/pages')
|
||||
|
||||
const rootPages = computed(() =>
|
||||
(pages.value ?? []).filter(p => !p.section),
|
||||
)
|
||||
|
||||
const sections = computed(() => {
|
||||
const map = new Map<string, PageEntry[]>()
|
||||
for (const p of pages.value ?? []) {
|
||||
if (p.section) {
|
||||
if (!map.has(p.section)) map.set(p.section, [])
|
||||
map.get(p.section)!.push(p)
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
@@ -103,14 +103,14 @@ async function save() {
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
+12
-12
@@ -163,7 +163,7 @@ async function save() {
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid hsl(20 8% 10%);
|
||||
border-bottom: 1px solid hsl(var(--color-surface));
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
@@ -172,13 +172,13 @@ async function save() {
|
||||
}
|
||||
|
||||
.song-row--over {
|
||||
border-top: 2px solid hsl(12 76% 48%);
|
||||
border-top: 2px solid hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
padding: 0.25rem;
|
||||
color: hsl(20 8% 35%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -190,7 +190,7 @@ async function save() {
|
||||
.song-num {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
@@ -200,16 +200,16 @@ async function save() {
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.lyrics-textarea {
|
||||
@@ -240,16 +240,16 @@ async function save() {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(20 8% 25%);
|
||||
border: 1px dashed hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(12 76% 68%);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden section-padding">
|
||||
<!-- Shadok jardinier: character with watering can and plant -->
|
||||
<svg class="shadok-jardinier" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="110" cy="160" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="110" cy="96" r="25" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Straw hat -->
|
||||
<ellipse cx="110" cy="78" rx="35" ry="8" fill="currentColor" opacity="0.4"/>
|
||||
<path d="M85 78 Q110 60 135 78" fill="currentColor" opacity="0.35"/>
|
||||
<!-- Eyes (focused, looking down at plant) -->
|
||||
<circle cx="102" cy="94" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="120" cy="94" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="103" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="121" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Smile -->
|
||||
<path d="M103 106 Q110 111 118 106" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arm holding watering can -->
|
||||
<line x1="70" y1="150" x2="40" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Watering can -->
|
||||
<rect x="20" y="165" width="30" height="20" rx="3" fill="currentColor" opacity="0.4"/>
|
||||
<line x1="20" y1="168" x2="10" y2="160" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
|
||||
<!-- Water drops -->
|
||||
<circle cx="12" cy="165" r="1.5" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="8" cy="170" r="1.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="15" cy="172" r="1.5" fill="currentColor" opacity="0.2"/>
|
||||
<!-- Other arm -->
|
||||
<line x1="150" y1="150" x2="170" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Legs -->
|
||||
<line x1="95" y1="205" x2="85" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="125" y1="205" x2="135" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Plant -->
|
||||
<line x1="180" y1="220" x2="180" y2="180" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<path d="M180 195 Q195 185 190 175" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<path d="M180 205 Q165 195 168 185" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<path d="M180 185 Q192 172 188 165" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Pot -->
|
||||
<path d="M170 220 L175 240 L185 240 L190 220 Z" fill="currentColor" opacity="0.35"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok bâtisseur: character with trowel building a wall -->
|
||||
<svg class="shadok-batisseur" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="130" cy="150" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="130" cy="86" r="25" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Hard hat -->
|
||||
<ellipse cx="130" cy="68" rx="28" ry="6" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="108" y="60" width="44" height="10" rx="3" fill="currentColor" opacity="0.35"/>
|
||||
<!-- Eyes (determined) -->
|
||||
<circle cx="122" cy="84" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="140" cy="84" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="123" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="141" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Grin -->
|
||||
<path d="M123 96 Q130 101 138 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arm with trowel -->
|
||||
<line x1="170" y1="140" x2="210" y2="120" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Trowel -->
|
||||
<polygon points="210,115 230,110 225,120 210,122" fill="currentColor" opacity="0.45"/>
|
||||
<line x1="210" y1="118" x2="200" y2="125" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
|
||||
<!-- Other arm -->
|
||||
<line x1="90" y1="145" x2="65" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Legs -->
|
||||
<line x1="115" y1="195" x2="105" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="145" y1="195" x2="155" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Wall (bricks) -->
|
||||
<rect x="40" y="200" width="50" height="16" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="45" y="183" width="40" height="16" rx="1" fill="currentColor" opacity="0.28"/>
|
||||
<rect x="50" y="166" width="30" height="16" rx="1" fill="currentColor" opacity="0.25"/>
|
||||
<!-- Brick lines -->
|
||||
<line x1="65" y1="200" x2="65" y2="216" stroke="currentColor" stroke-width="1" opacity="0.15"/>
|
||||
<line x1="55" y1="183" x2="55" y2="199" stroke="currentColor" stroke-width="1" opacity="0.15"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
|
||||
<h1 class="page-title font-display font-bold tracking-tight text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 mx-auto max-w-2xl text-white/60">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex flex-col gap-6">
|
||||
<div
|
||||
v-for="(extract, i) in content?.extracts"
|
||||
:key="i"
|
||||
class="card-surface"
|
||||
>
|
||||
<p class="mb-2 font-mono text-xs tracking-widest text-accent uppercase">
|
||||
{{ extract.chapter }}
|
||||
</p>
|
||||
<blockquote class="border-l-2 border-primary/30 pl-4 text-white/70 italic leading-relaxed whitespace-pre-line">
|
||||
{{ extract.text }}
|
||||
</blockquote>
|
||||
<div class="mt-4">
|
||||
<NuxtLink
|
||||
:to="`/modele-eco/${extract.chapterSlug}`"
|
||||
class="inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Lire le chapitre
|
||||
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('autonomie')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Autonomie',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.shadok-jardinier {
|
||||
position: absolute;
|
||||
left: 2%;
|
||||
top: 5%;
|
||||
width: clamp(100px, 14vw, 200px);
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float-jardinier 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shadok-batisseur {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
bottom: 5%;
|
||||
width: clamp(110px, 15vw, 210px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-batisseur 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-jardinier {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes shadok-float-batisseur {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(1deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-jardinier { display: none; }
|
||||
.shadok-batisseur { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="axis-scope axis-scope--cit section-padding">
|
||||
<div class="container-content">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mx-auto max-w-2xl text-center mb-8">
|
||||
<div class="section-icon mx-auto mb-6">
|
||||
<div :class="`i-lucide-${content?.icon ?? 'landmark'}`" class="h-12 w-12" />
|
||||
</div>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
|
||||
{{ content?.kicker }}
|
||||
</p>
|
||||
<h1 class="font-display text-3xl font-bold mb-4" style="color: hsl(var(--color-text))">
|
||||
{{ content?.title ?? slug }}
|
||||
</h1>
|
||||
<p class="text-lg leading-relaxed" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation prev / index / next -->
|
||||
<nav class="ctx-nav mx-auto max-w-2xl mb-10">
|
||||
<NuxtLink v-if="prevItem?.to" :to="prevItem.to" class="ctx-nav-btn ctx-nav-prev">
|
||||
<div class="i-lucide-arrow-left h-4 w-4 shrink-0" />
|
||||
<span>{{ prevItem.label }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else class="ctx-nav-spacer" />
|
||||
|
||||
<NuxtLink to="/citoyenne" class="ctx-nav-btn ctx-nav-index">
|
||||
<div class="i-lucide-layout-grid h-4 w-4" />
|
||||
<span>Citoyenne</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="nextItem?.to" :to="nextItem.to" class="ctx-nav-btn ctx-nav-next">
|
||||
<span>{{ nextItem.label }}</span>
|
||||
<div class="i-lucide-arrow-right h-4 w-4 shrink-0" />
|
||||
</NuxtLink>
|
||||
<div v-else class="ctx-nav-spacer" />
|
||||
</nav>
|
||||
|
||||
<!-- Zone sections — relative pour ancrer le sidebar -->
|
||||
<div class="sections-area">
|
||||
|
||||
<!-- Sidebar sommaire -->
|
||||
<aside v-if="sommaire.length > 1" class="page-sidebar">
|
||||
<nav class="sommaire-sidebar">
|
||||
<p class="sommaire-sidebar-title">Sur cette page</p>
|
||||
<ol class="sommaire-sidebar-list">
|
||||
<li v-for="(entry, ei) in sommaire" :key="ei">
|
||||
<a :href="`#${entry.id}`" class="sommaire-sidebar-link">
|
||||
<span class="sommaire-n">{{ ei + 1 }}</span>
|
||||
<span>{{ entry.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Features grid -->
|
||||
<div
|
||||
v-if="content?.features"
|
||||
id="section-features"
|
||||
class="relative grid gap-4 sm:grid-cols-2 mb-12 max-w-2xl mx-auto section-anchor"
|
||||
>
|
||||
<UiOiseauPose v-for="o in oiseaux.fonctions ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<div v-for="feature in content.features" :key="feature.title" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<div :class="`i-lucide-${feature.icon} h-5 w-5`" />
|
||||
</div>
|
||||
<h3 class="font-display font-semibold mb-1" style="color: hsl(var(--color-text))">{{ feature.title }}</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: hsl(var(--color-text-muted))">{{ feature.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project card -->
|
||||
<div
|
||||
v-if="content?.project"
|
||||
id="section-project"
|
||||
class="project-card mb-8 max-w-2xl mx-auto section-anchor"
|
||||
>
|
||||
<UiOiseauPose v-for="o in oiseaux.projet ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<div class="project-icon">
|
||||
<div class="i-lucide-rocket h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-semibold mb-2" style="color: hsl(var(--color-text))">
|
||||
{{ content.project.name }}
|
||||
</h2>
|
||||
<p style="color: hsl(var(--color-text-muted))" class="leading-relaxed">
|
||||
{{ content.project.text }}
|
||||
</p>
|
||||
<UiStatusBadge class="mt-3" :status="content?.status" :gestation="content?.gestation" />
|
||||
</div>
|
||||
|
||||
<!-- Extended content -->
|
||||
<div
|
||||
v-if="content?.content"
|
||||
id="section-content"
|
||||
class="prose-block relative mb-10 max-w-2xl mx-auto section-anchor"
|
||||
>
|
||||
<UiOiseauPose v-for="o in oiseaux.texte ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="text-center flex flex-col items-center gap-3 sm:flex-row sm:justify-center mb-4">
|
||||
<UiBaseButton v-if="slug === 'decision'" :href="decisionUrl" target="_blank">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
Ouvrir libreDecision
|
||||
</UiBaseButton>
|
||||
<UiBaseButton v-if="slug === 'tarifs-eau'" :href="sejeteral0Url" target="_blank">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
Lancer SejeteralO
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
|
||||
</div><!-- /sections-area -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
// Birds of each page: by the features grid, the project card and the text block
|
||||
interface OiseauxPage { fonctions?: OiseauPlace[], projet?: OiseauPlace[], texte?: OiseauPlace[] }
|
||||
const OISEAUX_PAGE: Record<string, OiseauxPage> = {
|
||||
'decision': {
|
||||
fonctions: [
|
||||
{ nom: 'mime', pose: 'marge-g', ecart: '48px', w: 104 },
|
||||
// on the line under the first row of cards (16 px gap between the rows)
|
||||
{ nom: 'avocate', pose: 'marge-d', ecart: '44px', bottom: 'calc(50% + 8px)', w: 96, retourne: true },
|
||||
// above the grid's left corner the page is empty (the links row starts at its middle)
|
||||
{ nom: 'tisserand', pose: 'perche', left: '24px', w: 84 },
|
||||
],
|
||||
},
|
||||
'tarifs-eau': {
|
||||
// the actor in the right margin, feet on the card's top edge: perched on the card, it stood
|
||||
// 11 px under the introduction (rule f, DA 03/11 after 17h35); the summary starts lower
|
||||
projet: [{ nom: 'comedien', pose: 'marge-d', ecart: '24px', bottom: '100%', w: 100, retourne: true }],
|
||||
texte: [{ nom: 'potier', pose: 'marge-g', ecart: '52px', w: 112 }],
|
||||
},
|
||||
}
|
||||
const oiseaux = computed<OiseauxPage>(() => OISEAUX_PAGE[slug] ?? {})
|
||||
|
||||
const [{ data: content }, { data: homeData }] = await Promise.all([
|
||||
usePageContent(`citoyenne/${slug}`),
|
||||
usePageContent('home'),
|
||||
])
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const decisionUrl = (appConfig.libredecision as { url: string })?.url ?? '#'
|
||||
const sejeteral0Url = (appConfig.sejeteral0 as { url: string })?.url ?? '#'
|
||||
|
||||
// Sommaire
|
||||
const sommaire = computed(() => {
|
||||
const entries: { title: string; id: string }[] = []
|
||||
if ((content.value?.features as unknown[])?.length) entries.push({ title: 'Fonctionnalités', id: 'section-features' })
|
||||
if (content.value?.project) entries.push({ title: (content.value.project as { name: string }).name, id: 'section-project' })
|
||||
if (content.value?.content) entries.push({ title: 'En savoir plus', id: 'section-content' })
|
||||
return entries
|
||||
})
|
||||
|
||||
// Prev / next dans la section citoyenne
|
||||
interface AxisItem { label: string; to?: string; icon?: string }
|
||||
const politiqueItems = computed<AxisItem[]>(
|
||||
() => (homeData.value as Record<string, unknown> | null)?.axes?.politique?.items as AxisItem[] ?? [],
|
||||
)
|
||||
const currentPath = `/citoyenne/${slug}`
|
||||
const currentIdx = computed(() => politiqueItems.value.findIndex(i => i.to === currentPath))
|
||||
const prevItem = computed(() => currentIdx.value > 0 ? politiqueItems.value[currentIdx.value - 1] : null)
|
||||
const nextItem = computed(() => currentIdx.value < politiqueItems.value.length - 1 ? politiqueItems.value[currentIdx.value + 1] : null)
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? slug,
|
||||
description: content.value?.description as string ?? undefined,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-primary) / 0.15);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.prose-block {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.section-anchor {
|
||||
scroll-margin-top: 5.5rem;
|
||||
}
|
||||
|
||||
/* ── Navigation prev/next ── */
|
||||
.ctx-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctx-nav-spacer { min-width: 0; }
|
||||
|
||||
.ctx-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
transition: all 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctx-nav-btn:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.ctx-nav-prev { justify-content: flex-start; }
|
||||
.ctx-nav-next { justify-content: flex-end; }
|
||||
|
||||
.ctx-nav-prev span,
|
||||
.ctx-nav-next span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctx-nav-index {
|
||||
justify-content: center;
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
border-color: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.75rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-nav-index:hover { background: hsl(var(--color-primary) / 0.15); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ctx-nav { grid-template-columns: auto 1fr auto; }
|
||||
.ctx-nav-prev span, .ctx-nav-next span { display: none; }
|
||||
.ctx-nav-prev, .ctx-nav-next { padding: 0.5rem; }
|
||||
}
|
||||
|
||||
/* ── Zone sections ── */
|
||||
.sections-area {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sidebar sommaire ── */
|
||||
.page-sidebar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 10.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
.page-sidebar { display: block; }
|
||||
}
|
||||
|
||||
.sommaire-sidebar {
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
padding: 0.875rem;
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.07);
|
||||
}
|
||||
|
||||
.sommaire-sidebar-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-link {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.28rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-text-muted));
|
||||
line-height: 1.4;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-link:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
}
|
||||
|
||||
.sommaire-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="axis-scope axis-scope--cit oiseaux-scene relative overflow-hidden section-padding">
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
|
||||
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
|
||||
header, and a perched bird keeps a void of its own height above it (rule f) -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="potier" pose="perche" bout :w="72" mobile-seul />
|
||||
</p>
|
||||
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
|
||||
<!-- At the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
|
||||
{{ content?.title }}<UiOiseauPose nom="vigie" pose="perche" bout left="16px" :w="96" retourne :seuil="1200" />
|
||||
</h1>
|
||||
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex flex-col gap-6">
|
||||
<!-- Decision collective — the card clips its corners, the birds stand on a wrapper -->
|
||||
<div class="relative">
|
||||
<UiOiseauPose nom="juge" pose="marge-d" ecart="40px" :w="84" retourne />
|
||||
<div class="item-card">
|
||||
<NuxtLink to="/citoyenne/decision" class="item-overlay" aria-label="Décision collective" tabindex="-1" />
|
||||
<div class="item-body group">
|
||||
<div class="item-header">
|
||||
<div class="item-icon">
|
||||
<div class="i-lucide-gavel h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
Décision collective
|
||||
</h2>
|
||||
<UiStatusBadge class="ml-auto" v-bind="statusOf('decision')" />
|
||||
</div>
|
||||
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
|
||||
Se donner les moyens de la décision collective.
|
||||
</p>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a :href="decisionUrl" target="_blank" rel="noopener" class="action-btn action-btn--primary">
|
||||
<div class="i-lucide-external-link h-3.5 w-3.5" />
|
||||
Ouvrir libreDecision
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tarifs de l'eau -->
|
||||
<div class="relative">
|
||||
<!-- 24 px between the two cards: no room above them for a perched bird (DA rule of 03/11) -->
|
||||
<!-- Out by the side of the card, not by its corners: the card is a link (rule h); its feet
|
||||
46 px above the card's bottom, where the captain stands on the other side (rule c) -->
|
||||
<UiOiseauPose nom="cartographe" pose="derriere" left="calc(100% - 100px)" bottom="46px" :w="210" />
|
||||
<UiOiseauPose nom="capitaine" pose="marge-g" ecart="46px" :w="136" />
|
||||
<div class="item-card">
|
||||
<NuxtLink to="/citoyenne/tarifs-eau" class="item-overlay" aria-label="Tarifs de l'eau" tabindex="-1" />
|
||||
<div class="item-body group">
|
||||
<div class="item-header">
|
||||
<div class="item-icon">
|
||||
<div class="i-lucide-droplets h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
Tarifs de l'eau
|
||||
</h2>
|
||||
<UiStatusBadge class="ml-auto" v-bind="statusOf('tarifs-eau')" />
|
||||
</div>
|
||||
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
|
||||
Application pour obtenir justice sociale et incitation dynamique à la réduction.
|
||||
Permet de confier la décision à la population des communes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a :href="sejeteral0Url" target="_blank" rel="noopener" class="action-btn action-btn--primary">
|
||||
<div class="i-lucide-external-link h-3.5 w-3.5" />
|
||||
Lancer SejeteralO
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('citoyenne')
|
||||
const statusOf = (id: string) => {
|
||||
const item = ((content.value as any)?.items ?? []).find((i: any) => i.id === id) ?? {}
|
||||
return { status: item.status, since: item.since, gestation: item.gestation }
|
||||
}
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const decisionUrl = (appConfig.libredecision as { url: string })?.url ?? '#'
|
||||
const sejeteral0Url = (appConfig.sejeteral0 as { url: string })?.url ?? '#'
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Autonomie citoyenne',
|
||||
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Décision collective, gouvernance locale, outils citoyens — l\'autonomie politique à l\'échelle des bassins de vie.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: hsl(var(--color-surface));
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
/* Lien overlay — couvre toute la carte, AU-DESSUS du contenu */
|
||||
.item-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.item-body {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: 1px solid hsl(var(--color-text) / 0.06);
|
||||
background: hsl(var(--color-bg) / 0.4);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn--primary {
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.action-btn--primary:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="oiseaux-scene relative overflow-hidden section-padding">
|
||||
<div class="container-content">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="section-icon relative mx-auto mb-6">
|
||||
<div class="i-lucide-shopping-bag h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
|
||||
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="lectrice" pose="perche" bout :w="84" retourne mobile-seul />
|
||||
</p>
|
||||
|
||||
<h1 class="relative font-display text-3xl font-bold mb-4 text-center" style="color: hsl(var(--color-text))">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
|
||||
<p class="text-lg leading-relaxed mb-10 text-center" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
|
||||
<!-- Bookelis CTA -->
|
||||
<div class="cta-block mb-8">
|
||||
<UiOiseauPose nom="cycliste-livreur" pose="marge-g" ecart="54px" :w="150" />
|
||||
<!-- The reader in the right margin, feet on the card's top edge: on the bag, under the
|
||||
sticky header, she had 22 px of void above her (rule f, DA 03/11 after 17h35) -->
|
||||
<UiOiseauPose nom="lectrice" pose="marge-d" ecart="24px" bottom="100%" :w="84" retourne />
|
||||
<div class="cta-icon">
|
||||
<div class="i-lucide-globe h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="font-display text-lg font-semibold mb-1" style="color: hsl(var(--color-text))">
|
||||
Commander en ligne
|
||||
</h2>
|
||||
<p class="text-sm mb-3" style="color: hsl(var(--color-text-muted))">
|
||||
Impression à la demande, livraison chez vous.
|
||||
</p>
|
||||
<a
|
||||
:href="content?.bookelis?.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="order-btn"
|
||||
>
|
||||
<div class="i-lucide-external-link h-4 w-4" />
|
||||
{{ content?.bookelis?.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Librairie -->
|
||||
<div class="cta-block mb-10">
|
||||
<UiOiseauPose nom="libraire" pose="derriere" left="calc(100% - 96px)" bottom="-36px" :w="214" retourne />
|
||||
<div class="cta-icon cta-icon--accent">
|
||||
<div class="i-lucide-store h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="font-display text-lg font-semibold mb-2" style="color: hsl(var(--color-text))">
|
||||
{{ content?.librairie?.title }}
|
||||
</h2>
|
||||
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.librairie?.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<UiBaseButton variant="ghost" to="/economique">
|
||||
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
|
||||
Autonomie économique
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('economique/commande')
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Commander le livre — Une économie du don',
|
||||
description: 'Commander l\'édition papier du livre « Une économie du don — enfin concevable » d\'Yvv.',
|
||||
image: '/images/Couv-Economie-du-don.jpg',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.cta-block {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.cta-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.cta-icon--accent {
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.order-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.order-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="axis-scope axis-scope--eco oiseaux-scene relative overflow-hidden section-padding">
|
||||
<div class="container-content">
|
||||
<!-- Header -->
|
||||
<header class="mb-12 text-center">
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
|
||||
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
|
||||
header, and a perched bird keeps a void of its own height above it (rule f) -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="maraichere" pose="perche" bout :w="72" retourne mobile-seul />
|
||||
</p>
|
||||
<h1 class="page-title font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex flex-col gap-8">
|
||||
<!-- Monnaie libre — the card clips its Ğ1 watermark, so the bird stands on a wrapper -->
|
||||
<div class="relative">
|
||||
<UiOiseauPose nom="pompeur-de-g1" pose="marge-d" ecart="34px" :w="126" retourne />
|
||||
<NuxtLink to="/economique/monnaie-libre" class="item-card g1-card group">
|
||||
<span class="g1-watermark" aria-hidden="true">Ğ1</span>
|
||||
<div class="item-header">
|
||||
<div class="item-icon">
|
||||
<span class="g1-icon">Ğ1</span>
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
Monnaie libre
|
||||
</h2>
|
||||
<UiStatusBadge class="ml-auto" v-bind="statusOf('monnaie-libre')" />
|
||||
</div>
|
||||
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
|
||||
La Ğ1 (June) : une monnaie co-créée par ses membres, sans dette ni intérêt. Le dividende universel comme base.
|
||||
</p>
|
||||
<div class="mt-3 inline-flex items-center gap-1 text-sm text-primary group-hover:text-primary/80 transition-colors">
|
||||
En savoir plus
|
||||
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Modèle économique — bloc livre -->
|
||||
<div class="book-block">
|
||||
<!-- The balance (the measure of the gift) peeks out from behind the book, on the left and
|
||||
halfway up: the market gardener stands low on the right, never at its height, and the
|
||||
four birds of the page do not alternate at an even step -->
|
||||
<UiOiseauPose nom="la-balance" pose="derriere" left="-150px" bottom="calc(100% - 240px)" :w="240" />
|
||||
<!-- Not by the Productions card: with the beekeeper, a pair at the same height -->
|
||||
<UiOiseauPose nom="maraichere" pose="marge-d" ecart="46px" :w="108" retourne />
|
||||
<HomeBookSection
|
||||
compact
|
||||
@open-player="showBookPlayer = true"
|
||||
@open-pdf="showPdfReader = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Productions collectives -->
|
||||
<NuxtLink to="/economique/productions-collectives" class="item-card group">
|
||||
<UiOiseauPose nom="apicultrice" pose="marge-g" ecart="52px" :w="124" />
|
||||
<div class="item-header">
|
||||
<div class="item-icon">
|
||||
<div class="i-lucide-users h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
Productions collectives
|
||||
</h2>
|
||||
<UiStatusBadge class="ml-auto" v-bind="statusOf('productions-collectives')" />
|
||||
</div>
|
||||
<p class="leading-relaxed mt-3" style="color: hsl(var(--color-text-muted))">
|
||||
Une plateforme pour faciliter la création d'équipes et la réalisation de productions à l'échelle des bassins de vie. Passer la seconde.
|
||||
</p>
|
||||
<div class="mt-3 inline-flex items-center gap-1 text-sm text-primary group-hover:text-primary/80 transition-colors">
|
||||
En savoir plus
|
||||
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('economique')
|
||||
const statusOf = (id: string) => {
|
||||
const item = ((content.value as any)?.items ?? []).find((i: any) => i.id === id) ?? {}
|
||||
return { status: item.status, since: item.since, gestation: item.gestation }
|
||||
}
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Autonomie économique',
|
||||
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Comprendre et expérimenter une économie fondée sur le don, la monnaie libre et la réciprocité.',
|
||||
})
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
const showPdfReader = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: hsl(var(--color-surface));
|
||||
transition: border-color 0.2s, transform 0.12s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.2);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.g1-icon {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.book-block {
|
||||
position: relative;
|
||||
padding: 1.25rem;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
/* Monnaie libre card */
|
||||
.g1-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-surface)) 60%,
|
||||
hsl(var(--color-primary) / 0.06) 100%
|
||||
);
|
||||
border-color: hsl(var(--color-primary) / 0.14);
|
||||
}
|
||||
|
||||
.g1-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.g1-watermark {
|
||||
position: absolute;
|
||||
right: -0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 900;
|
||||
font-size: 5.5rem;
|
||||
line-height: 1;
|
||||
color: hsl(var(--color-primary) / 0.07);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.g1-card:hover .g1-watermark {
|
||||
color: hsl(var(--color-primary) / 0.11);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div v-if="chapter">
|
||||
<div v-if="chapter" class="relative">
|
||||
<!-- The book's trades in the margins, each on a line of the chapter (rule e): the two who
|
||||
write keep their way, on the left, the calligrapher on the title's rule (in the chapter
|
||||
header), the typographer on the navigation's rule; the box maker in the right margin, by
|
||||
the PDF and songs buttons (in the header); the proofreader on the right, by the last
|
||||
paragraph -->
|
||||
<UiOiseauPose nom="correcteur" pose="libre" left="calc(100% + 40px)" bottom="140px" :w="108" retourne />
|
||||
<BookChapterHeader
|
||||
:title="chapter.title"
|
||||
:description="chapter.description"
|
||||
@@ -10,11 +16,14 @@
|
||||
|
||||
<BookChapterContent :content="chapter" />
|
||||
|
||||
<!-- Prev / Next navigation -->
|
||||
<nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8">
|
||||
<!-- Prev / Next navigation. The rule above it is drawn (border-t alone sets no style here):
|
||||
the typographer stands on it, in the left margin. Perched in the column, the box maker had
|
||||
20 to 27 px of void under the last paragraph (rule f) -->
|
||||
<nav class="relative mt-16 flex items-center justify-between border-t border-t-solid border-white/8 pt-8">
|
||||
<UiOiseauPose nom="typographe" pose="marge-g" ecart="64px" bottom="100%" :w="100" />
|
||||
<NuxtLink
|
||||
v-if="prevChapter"
|
||||
:to="`/modele-eco/${prevChapter.stem?.split('/').pop()}`"
|
||||
:to="`/economique/modele-eco/${prevChapter.stem?.split('/').pop()}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<div class="i-lucide-arrow-left h-4 w-4" />
|
||||
@@ -24,7 +33,7 @@
|
||||
|
||||
<NuxtLink
|
||||
v-if="nextChapter"
|
||||
:to="`/modele-eco/${nextChapter.stem?.split('/').pop()}`"
|
||||
:to="`/economique/modele-eco/${nextChapter.stem?.split('/').pop()}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<span class="text-sm">{{ nextChapter.title }}</span>
|
||||
@@ -43,6 +52,11 @@ definePageMeta({
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
// Scroll to top when navigating between chapters (component reused by Vue Router)
|
||||
watch(() => slug, () => {
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
// Initialize guided mode
|
||||
useGuidedMode()
|
||||
|
||||
@@ -54,8 +68,11 @@ if (!chapter.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: chapter.value?.title,
|
||||
useSeoPage({
|
||||
title: chapter.value?.title ?? slug,
|
||||
description: chapter.value?.description as string ?? `Chapitre « ${chapter.value?.title} » — Une économie du don, enfin concevable.`,
|
||||
image: '/images/Couv-Economie-du-don.jpg',
|
||||
type: 'article',
|
||||
})
|
||||
|
||||
// Get adjacent chapters for navigation
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="oiseaux-scene relative overflow-hidden section-padding">
|
||||
<div class="container-content">
|
||||
<!-- Page de couverture du livre -->
|
||||
<HomeBookSection
|
||||
class="mb-10 hero-compact"
|
||||
:show-chapters="false"
|
||||
@open-player="showBookPlayer = true"
|
||||
@open-pdf="showPdfReader = true"
|
||||
/>
|
||||
|
||||
<header class="mb-10 text-center">
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="relieur" pose="perche" bout :w="72" mobile-seul />
|
||||
</p>
|
||||
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
|
||||
<!-- Before the title, on its baseline (rule e, docs/da-oiseaux.md) -->
|
||||
<UiOiseauPose nom="relieur" pose="perche" bout right="16px" :w="108" :seuil="1200" />{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-3 mx-auto max-w-2xl text-sm" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="chapter in chapters"
|
||||
:key="chapter.path"
|
||||
class="relative"
|
||||
>
|
||||
<UiOiseauPose v-for="o in OISEAUX_CHAPITRE[chapter.stem?.split('/').pop() ?? ''] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<NuxtLink
|
||||
:to="`/economique/modele-eco/${chapter.stem?.split('/').pop()}`"
|
||||
class="card-surface flex items-start gap-4 group"
|
||||
>
|
||||
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
|
||||
{{ String(chapter.order).padStart(2, '0') }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
|
||||
{{ chapter.title }}
|
||||
</h2>
|
||||
<span v-if="chapter.page" class="text-xs font-mono text-white/30 flex-shrink-0">p. {{ chapter.page }}</span>
|
||||
</div>
|
||||
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
|
||||
{{ chapter.description }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<span v-if="chapter.readingTime" class="text-xs text-white/30">
|
||||
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
|
||||
{{ chapter.readingTime }}
|
||||
</span>
|
||||
<SongBadges :chapter-slug="chapter.stem?.split('/').pop() ?? ''" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
// Birds along the chapters, each by the one its trade tells (irregular intervals). The cards
|
||||
// stand 12 px apart: no room above them for a perched bird (DA rule of 03/11)
|
||||
const OISEAUX_CHAPITRE: Record<string, OiseauPlace[]> = {
|
||||
'01-introduction': [{ nom: 'conteuse', pose: 'marge-g', ecart: '50px', w: 122 }],
|
||||
'03-mesure': [{ nom: 'la-balance', pose: 'marge-d', ecart: '40px', w: 112, retourne: true }],
|
||||
'07-echanger': [{ nom: 'colporteur', pose: 'marge-g', ecart: '44px', w: 116 }],
|
||||
'08-institution': [{ nom: 'empileur', pose: 'marge-d', ecart: '90px', w: 104, retourne: true }],
|
||||
'11-annexes': [{ nom: 'illustratrice', pose: 'derriere', left: 'calc(100% - 110px)', bottom: '-40px', w: 220 }],
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('economique/modele-eco')
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Une économie du don — Table des matières',
|
||||
description: 'Onze chapitres pour explorer les fondements d\'une économie fondée sur le don, la mesure et la monnaie libre.',
|
||||
image: '/images/Couv-Economie-du-don.jpg',
|
||||
type: 'book',
|
||||
})
|
||||
|
||||
const { data: chapters } = await useAsyncData('book-toc', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
const showPdfReader = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(1.5rem, 3.5vw, 2rem);
|
||||
}
|
||||
|
||||
/* Compact the HomeBookSection hero */
|
||||
:deep(.hero-compact .book-hero),
|
||||
:deep(.hero-compact .section-hero) {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
:deep(.hero-compact img),
|
||||
:deep(.hero-compact .book-cover) {
|
||||
max-height: 260px;
|
||||
}
|
||||
:deep(.hero-compact h1),
|
||||
:deep(.hero-compact .hero-title) {
|
||||
font-size: clamp(1.4rem, 3vw, 2rem);
|
||||
}
|
||||
:deep(.hero-compact .hero-description) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="oiseaux-scene axis-scope axis-scope--eco section-padding">
|
||||
<div class="container-content">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mx-auto max-w-2xl text-center mb-8">
|
||||
<div class="section-icon mx-auto mb-6">
|
||||
<span v-if="content?.icon === 'g1'" class="g1-icon">Ğ1</span>
|
||||
<div v-else :class="`i-lucide-${content?.icon ?? 'coins'}`" class="h-12 w-12" />
|
||||
</div>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
{{ content?.kicker }}
|
||||
</p>
|
||||
<h1 class="font-display text-3xl font-bold mb-4" style="color: hsl(var(--color-text))">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="text-lg leading-relaxed" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
<UiStatusBadge class="mt-4" :status="content?.status" :since="content?.since" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation prev / index / next -->
|
||||
<nav class="ctx-nav mx-auto max-w-2xl mb-10">
|
||||
<NuxtLink v-if="prevItem?.to" :to="prevItem.to" class="ctx-nav-btn ctx-nav-prev">
|
||||
<div class="i-lucide-arrow-left h-4 w-4 shrink-0" />
|
||||
<span>{{ prevItem.label }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else class="ctx-nav-spacer" />
|
||||
|
||||
<NuxtLink to="/economique" class="ctx-nav-btn ctx-nav-index">
|
||||
<div class="i-lucide-layout-grid h-4 w-4" />
|
||||
<span>Économique</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="nextItem?.to" :to="nextItem.to" class="ctx-nav-btn ctx-nav-next">
|
||||
<span>{{ nextItem.label }}</span>
|
||||
<div class="i-lucide-arrow-right h-4 w-4 shrink-0" />
|
||||
</NuxtLink>
|
||||
<div v-else class="ctx-nav-spacer" />
|
||||
</nav>
|
||||
|
||||
<!-- Zone sections — relative pour ancrer le sidebar -->
|
||||
<div class="sections-area">
|
||||
|
||||
<!-- Sidebar sommaire -->
|
||||
<aside v-if="sommaire.length > 1" class="page-sidebar">
|
||||
<nav class="sommaire-sidebar">
|
||||
<p class="sommaire-sidebar-title">Sur cette page</p>
|
||||
<ol class="sommaire-sidebar-list">
|
||||
<li v-for="(entry, ei) in sommaire" :key="ei">
|
||||
<a :href="`#${entry.id}`" class="sommaire-sidebar-link">
|
||||
<span class="sommaire-n">{{ ei + 1 }}</span>
|
||||
<span>{{ entry.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
v-if="content?.content"
|
||||
id="section-content"
|
||||
class="prose-block relative mb-8 max-w-2xl mx-auto section-anchor"
|
||||
>
|
||||
<UiOiseauPose nom="boulangere" pose="marge-g" ecart="56px" :w="96" />
|
||||
<!-- Behind the top of the text block, which has a background (the links stand apart) -->
|
||||
<UiOiseauPose nom="vigneronne" pose="derriere" left="-140px" bottom="calc(100% - 220px)" :w="210" />
|
||||
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- External links -->
|
||||
<div
|
||||
v-if="content?.links"
|
||||
id="section-links"
|
||||
class="relative flex flex-col gap-3 mb-10 max-w-2xl mx-auto section-anchor"
|
||||
>
|
||||
<a
|
||||
v-for="link in content.links"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="link-card group"
|
||||
>
|
||||
<div class="link-icon">
|
||||
<div :class="`i-lucide-${link.icon ?? 'external-link'} h-4 w-4`" />
|
||||
</div>
|
||||
<span class="text-sm font-medium" style="color: hsl(var(--color-text))">{{ link.label }}</span>
|
||||
<div class="i-lucide-arrow-up-right h-3.5 w-3.5 ml-auto text-primary/40 group-hover:text-primary transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div><!-- /sections-area -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const [{ data: content }, { data: homeData }] = await Promise.all([
|
||||
usePageContent('economique/monnaie-libre'),
|
||||
usePageContent('home'),
|
||||
])
|
||||
|
||||
// Sommaire
|
||||
const sommaire = computed(() => {
|
||||
const entries: { title: string; id: string }[] = []
|
||||
if (content.value?.content) entries.push({ title: 'La théorie', id: 'section-content' })
|
||||
if ((content.value?.links as unknown[])?.length) entries.push({ title: 'Ressources', id: 'section-links' })
|
||||
return entries
|
||||
})
|
||||
|
||||
// Prev / next dans la section économique
|
||||
interface AxisItem { label: string; to?: string; icon?: string }
|
||||
const economieItems = computed<AxisItem[]>(
|
||||
() => (homeData.value as Record<string, unknown> | null)?.axes?.economie?.items as AxisItem[] ?? [],
|
||||
)
|
||||
const currentPath = '/economique/monnaie-libre'
|
||||
const currentIdx = computed(() => economieItems.value.findIndex(i => i.to === currentPath))
|
||||
const prevItem = computed(() => currentIdx.value > 0 ? economieItems.value[currentIdx.value - 1] : null)
|
||||
const nextItem = computed(() => currentIdx.value < economieItems.value.length - 1 ? economieItems.value[currentIdx.value + 1] : null)
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Monnaie libre — Autonomie économique',
|
||||
description: content.value?.description as string ?? 'La monnaie libre (June / G1) : comprendre la théorie relative de la monnaie et expérimenter une monnaie équitable.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.g1-icon {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.prose-block {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-primary) / 0.1);
|
||||
background: hsl(var(--color-surface));
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-anchor {
|
||||
scroll-margin-top: 5.5rem;
|
||||
}
|
||||
|
||||
/* ── Navigation prev/next ── */
|
||||
.ctx-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctx-nav-spacer { min-width: 0; }
|
||||
|
||||
.ctx-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
transition: all 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctx-nav-btn:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.ctx-nav-prev { justify-content: flex-start; }
|
||||
.ctx-nav-next { justify-content: flex-end; }
|
||||
|
||||
.ctx-nav-prev span,
|
||||
.ctx-nav-next span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctx-nav-index {
|
||||
justify-content: center;
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
border-color: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.75rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-nav-index:hover { background: hsl(var(--color-primary) / 0.15); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ctx-nav { grid-template-columns: auto 1fr auto; }
|
||||
.ctx-nav-prev span, .ctx-nav-next span { display: none; }
|
||||
.ctx-nav-prev, .ctx-nav-next { padding: 0.5rem; }
|
||||
}
|
||||
|
||||
/* ── Zone sections ── */
|
||||
.sections-area {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sidebar sommaire ── */
|
||||
.page-sidebar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 10.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
.page-sidebar { display: block; }
|
||||
}
|
||||
|
||||
.sommaire-sidebar {
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
padding: 0.875rem;
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.07);
|
||||
}
|
||||
|
||||
.sommaire-sidebar-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-link {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.28rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-text-muted));
|
||||
line-height: 1.4;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.sommaire-sidebar-link:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
}
|
||||
|
||||
.sommaire-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="axis-scope axis-scope--eco oiseaux-scene relative section-padding">
|
||||
<div class="container-content">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="section-icon mx-auto mb-6">
|
||||
<div :class="`i-lucide-${content?.icon ?? 'users'}`" class="h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase text-center">
|
||||
{{ content?.kicker }}
|
||||
</p>
|
||||
|
||||
<!-- The workshop: each trade of the collective productions around the page -->
|
||||
<h1 class="relative font-display text-3xl font-bold mb-4 text-center" style="color: hsl(var(--color-text))">
|
||||
{{ content?.title }}
|
||||
<!-- In the margin, feet on the title's baseline (rule e) -->
|
||||
<UiOiseauPose nom="soudeuse" pose="marge-d" ecart="30px" bottom="4px" :w="112" retourne />
|
||||
</h1>
|
||||
|
||||
<p class="relative text-lg leading-relaxed mb-8 text-center" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
|
||||
<div class="mb-8 text-center">
|
||||
<UiStatusBadge :status="content?.status" :gestation="content?.gestation" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="content?.content" class="prose-block mb-10">
|
||||
<UiOiseauPose nom="menuisier" pose="perche" left="20px" :w="84" />
|
||||
<!-- Phone: a flatter bird at the same place, the text above leaves 89 px (rule f) -->
|
||||
<UiOiseauPose nom="cycliste-livreur" pose="perche" left="20px" :w="72" mobile-seul />
|
||||
<UiOiseauPose nom="forgeron" pose="marge-g" ecart="70px" :w="140" />
|
||||
<UiOiseauPose nom="berger" pose="derriere" left="calc(100% - 110px)" bottom="-46px" :w="230" />
|
||||
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<UiBaseButton variant="ghost" to="/economique">
|
||||
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
|
||||
Autonomie économique
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('economique/productions-collectives')
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Productions collectives',
|
||||
description: content.value?.description as string ?? 'Initiatives et productions collectives dans le cadre de l\'économie du don.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.prose-block {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
+17
-110
@@ -1,75 +1,11 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden section-padding">
|
||||
<!-- Shadok danseur: character dancing with music notes -->
|
||||
<svg class="shadok-danseur" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<!-- Body (dynamic pose, leaning) -->
|
||||
<ellipse cx="120" cy="155" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head (tilted with joy) -->
|
||||
<ellipse cx="125" cy="92" rx="24" ry="23" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Eyes (happy, squinted) -->
|
||||
<path d="M114 88 Q118 84 122 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M130 88 Q134 84 138 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<!-- Big smile -->
|
||||
<path d="M116 100 Q125 108 134 100" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- Arms thrown up (dancing) -->
|
||||
<line x1="85" y1="140" x2="50" y2="100" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="155" y1="140" x2="190" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="50" cy="98" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="190" cy="103" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Legs (one kicked up) -->
|
||||
<line x1="105" y1="198" x2="80" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="135" y1="198" x2="170" y2="240" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Feet -->
|
||||
<path d="M80 255 L68 258 M80 255 L75 261" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M170 240 L180 238 M170 240 L175 246" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<!-- Music notes floating around -->
|
||||
<text x="42" y="82" fill="currentColor" opacity="0.3" font-size="18">♪</text>
|
||||
<text x="195" y="88" fill="currentColor" opacity="0.25" font-size="16">♫</text>
|
||||
<text x="60" y="65" fill="currentColor" opacity="0.2" font-size="14">♩</text>
|
||||
<text x="180" y="72" fill="currentColor" opacity="0.2" font-size="20">♪</text>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok DJ: character with headphones behind a turntable -->
|
||||
<svg class="shadok-dj" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="130" cy="155" rx="42" ry="50" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="130" cy="88" r="26" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Headphones band -->
|
||||
<path d="M104 78 Q130 55 156 78" stroke="currentColor" stroke-width="4" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<!-- Headphone ear pads -->
|
||||
<ellipse cx="102" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
|
||||
<ellipse cx="158" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Eyes (cool, half-lidded) -->
|
||||
<ellipse cx="120" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
|
||||
<ellipse cx="140" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="121" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="141" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Mouth (grin) -->
|
||||
<path d="M122 98 Q130 104 138 98" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- Arms reaching to turntable -->
|
||||
<line x1="90" y1="150" x2="55" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="170" y1="150" x2="205" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Turntable body -->
|
||||
<rect x="30" y="200" width="200" height="18" rx="4" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Turntable platter -->
|
||||
<ellipse cx="130" cy="200" rx="55" ry="15" fill="currentColor" opacity="0.25"/>
|
||||
<ellipse cx="130" cy="200" rx="55" ry="15" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.35"/>
|
||||
<!-- Record center -->
|
||||
<circle cx="130" cy="200" r="5" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Tone arm -->
|
||||
<line x1="195" y1="188" x2="150" y2="195" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="195" cy="188" r="3" fill="currentColor" opacity="0.35"/>
|
||||
<!-- Legs -->
|
||||
<line x1="115" y1="202" x2="105" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="145" y1="202" x2="155" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content">
|
||||
<!-- Hero section with book cover -->
|
||||
<div class="mb-12 grid items-center gap-8 md:grid-cols-2">
|
||||
<div class="book-cover-wrapper">
|
||||
<div class="book-cover-wrapper relative">
|
||||
<!-- On the cover's base line, in the empty space between the book and the text -->
|
||||
<UiOiseauPose nom="trompettiste" pose="libre" left="calc(100% - 112px)" bottom="0px" :w="104" />
|
||||
<div class="book-cover-3d">
|
||||
<img
|
||||
:src="homeContent?.book.coverImage"
|
||||
@@ -80,8 +16,12 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
|
||||
<h1 class="page-title font-display font-bold tracking-tight text-white">
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-accent uppercase">
|
||||
<!-- The DJ stands at the end of this line, on its baseline: the title's box has no visible
|
||||
top edge (rule e) -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="dj" pose="perche" bout left="14px" :w="84" retourne ton="accent" :seuil="1200" />
|
||||
</p>
|
||||
<h1 class="page-title relative font-display font-bold tracking-tight text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 text-white/60">
|
||||
@@ -131,7 +71,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Song list -->
|
||||
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
|
||||
<div v-if="viewMode === 'list'" class="relative flex flex-col gap-2">
|
||||
<!-- S, not M: above it the buttons leave 150 px, and a perched bird keeps a void of its own
|
||||
height above its head (rule f, DA 03/11 after 17h35) -->
|
||||
<UiOiseauPose nom="danseur" pose="perche" left="calc(64% - 60px)" :w="80" :seuil="1200" />
|
||||
<SongItem
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
@@ -166,8 +109,10 @@ definePageMeta({
|
||||
const { data: content } = await usePageContent('en-musique')
|
||||
const { data: homeContent } = await usePageContent('home')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'En musique',
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'En musique — Le Librodrome',
|
||||
description: 'Neuf chansons qui racontent le livre « Une économie du don ». Écoute libre, paroles et présentation musicale guidée.',
|
||||
image: '/images/Couv-Economie-du-don.jpg',
|
||||
})
|
||||
|
||||
const store = usePlayerStore()
|
||||
@@ -235,42 +180,4 @@ const filteredSongs = computed(() => {
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.shadok-danseur {
|
||||
position: absolute;
|
||||
left: 2%;
|
||||
top: 3%;
|
||||
width: clamp(100px, 14vw, 200px);
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float-danseur 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shadok-dj {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 3%;
|
||||
width: clamp(120px, 16vw, 230px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-dj 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-danseur {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-8px) rotate(2deg); }
|
||||
75% { transform: translateY(-4px) rotate(-2deg); }
|
||||
}
|
||||
|
||||
@keyframes shadok-float-dj {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-danseur { display: none; }
|
||||
.shadok-dj { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+310
-226
@@ -1,267 +1,351 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden section-padding min-h-[70vh] flex items-center justify-center">
|
||||
<!-- Shadok jongleur: juggling coins (top-left) -->
|
||||
<svg class="shadok-juggler" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="120" cy="160" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="120" cy="98" r="24" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Eyes (looking up at coins) -->
|
||||
<circle cx="112" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="130" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="113" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="131" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Smile -->
|
||||
<path d="M112 108 Q120 114 128 108" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- Arms up (juggling) -->
|
||||
<line x1="85" y1="145" x2="55" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="155" y1="145" x2="185" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="55" cy="103" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="185" cy="103" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Juggling coins -->
|
||||
<circle cx="90" cy="55" r="8" fill="currentColor" opacity="0.35"/>
|
||||
<text x="86" y="59" fill="currentColor" opacity="0.5" font-size="10" font-weight="bold">$</text>
|
||||
<circle cx="120" cy="40" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<text x="116" y="44" fill="currentColor" opacity="0.45" font-size="10" font-weight="bold">$</text>
|
||||
<circle cx="150" cy="50" r="8" fill="currentColor" opacity="0.32"/>
|
||||
<text x="146" y="54" fill="currentColor" opacity="0.48" font-size="10" font-weight="bold">$</text>
|
||||
<!-- Legs -->
|
||||
<line x1="105" y1="203" x2="95" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="135" y1="203" x2="145" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok échelle: on a wobbly ladder (top-right) -->
|
||||
<svg class="shadok-ladder" viewBox="0 0 220 320" fill="none" aria-hidden="true">
|
||||
<!-- Ladder (tilting) -->
|
||||
<line x1="80" y1="50" x2="70" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
|
||||
<line x1="150" y1="50" x2="140" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
|
||||
<!-- Rungs -->
|
||||
<line x1="82" y1="80" x2="148" y2="80" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
|
||||
<line x1="83" y1="120" x2="147" y2="120" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
|
||||
<line x1="84" y1="160" x2="146" y2="160" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
|
||||
<line x1="85" y1="200" x2="145" y2="200" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
|
||||
<line x1="86" y1="240" x2="144" y2="240" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
|
||||
<!-- Shadok on top (arms out for balance) -->
|
||||
<ellipse cx="115" cy="68" rx="18" ry="14" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="115" cy="46" r="14" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Eyes (worried) -->
|
||||
<circle cx="110" cy="43" r="3" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="122" cy="43" r="3" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="110" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="122" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Worried mouth -->
|
||||
<path d="M108 52 Q115 49 122 52" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arms out (balancing) -->
|
||||
<line x1="97" y1="62" x2="60" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="133" y1="62" x2="170" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok acrobate: doing a cartwheel (center) -->
|
||||
<svg class="shadok-acrobat" viewBox="0 0 260 240" fill="none" aria-hidden="true">
|
||||
<!-- Body (sideways, mid-cartwheel) -->
|
||||
<ellipse cx="130" cy="120" rx="30" ry="38" fill="currentColor" opacity="0.85" transform="rotate(45 130 120)"/>
|
||||
<!-- Head -->
|
||||
<circle cx="155" cy="82" r="20" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Eyes (dizzy/happy) -->
|
||||
<path d="M148 78 Q152 74 156 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M160 78 Q164 74 168 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<!-- Smile -->
|
||||
<path d="M150 90 Q158 95 165 90" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arms (one touching ground, one up) -->
|
||||
<line x1="110" y1="100" x2="80" y2="130" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="150" y1="105" x2="185" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Hand on ground -->
|
||||
<circle cx="78" cy="132" r="4" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Legs (splayed in cartwheel) -->
|
||||
<line x1="125" y1="155" x2="100" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="140" y1="150" x2="175" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Motion lines -->
|
||||
<path d="M70 110 Q60 105 55 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||
<path d="M190 60 Q200 55 205 65" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok dormeur: sleeping on a cloud (bottom-left) -->
|
||||
<svg class="shadok-sleeper" viewBox="0 0 260 220" fill="none" aria-hidden="true">
|
||||
<!-- Cloud -->
|
||||
<ellipse cx="130" cy="150" rx="80" ry="25" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="80" cy="140" r="25" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="120" cy="130" r="30" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="165" cy="135" r="22" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="190" cy="142" r="18" fill="currentColor" opacity="0.15"/>
|
||||
<!-- Shadok body (lying down) -->
|
||||
<ellipse cx="130" cy="125" rx="35" ry="18" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head (on cloud, sideways) -->
|
||||
<ellipse cx="85" cy="118" rx="18" ry="16" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Closed eyes (sleeping) -->
|
||||
<path d="M76 115 Q80 112 84 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<path d="M88 115 Q92 112 96 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
|
||||
<!-- Snooze bubbles -->
|
||||
<text x="70" y="100" fill="currentColor" opacity="0.3" font-size="12" font-weight="bold">z</text>
|
||||
<text x="60" y="85" fill="currentColor" opacity="0.25" font-size="16" font-weight="bold">z</text>
|
||||
<text x="48" y="68" fill="currentColor" opacity="0.2" font-size="20" font-weight="bold">z</text>
|
||||
<!-- Legs (curled) -->
|
||||
<path d="M165 125 Q180 130 175 140" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
<path d="M160 130 Q172 138 168 148" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok cuisinier: cooking in a cauldron (bottom-right) -->
|
||||
<svg class="shadok-cook" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="120" cy="145" rx="38" ry="45" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="120" cy="85" r="24" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Chef hat -->
|
||||
<ellipse cx="120" cy="62" rx="22" ry="18" fill="currentColor" opacity="0.35"/>
|
||||
<rect x="105" y="68" width="30" height="6" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Eyes (focused on cooking) -->
|
||||
<circle cx="112" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="130" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="113" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="131" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Tongue out (concentrating) -->
|
||||
<path d="M115 96 Q120 100 125 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
<!-- Arm with ladle -->
|
||||
<line x1="155" y1="135" x2="185" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Ladle -->
|
||||
<line x1="185" y1="175" x2="175" y2="200" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<ellipse cx="175" cy="205" rx="8" ry="5" fill="currentColor" opacity="0.35"/>
|
||||
<!-- Other arm -->
|
||||
<line x1="85" y1="140" x2="60" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Legs -->
|
||||
<line x1="105" y1="188" x2="95" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="135" y1="188" x2="145" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Cauldron -->
|
||||
<path d="M55 220 Q55 260 120 260 Q185 260 185 220" fill="currentColor" opacity="0.3"/>
|
||||
<ellipse cx="120" cy="220" rx="65" ry="12" fill="currentColor" opacity="0.25"/>
|
||||
<ellipse cx="120" cy="220" rx="65" ry="12" stroke="currentColor" stroke-width="2" fill="none" opacity="0.35"/>
|
||||
<!-- Steam -->
|
||||
<path d="M95 210 Q90 195 95 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||
<path d="M120 208 Q118 190 122 180" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||
<path d="M145 210 Q148 195 143 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content relative z-10 text-center">
|
||||
<!-- The stage of the hero: a tightrope walker and a drummer, at different heights -->
|
||||
<UiOiseauPose nom="funambule" pose="libre" left="5%" bottom="6%" :w="176" :op="0.2" />
|
||||
<UiOiseauPose nom="batteur" pose="libre" right="6%" bottom="34%" :w="116" retourne ton="accent" />
|
||||
<!-- Logo SVG vectoriel — inline pour currentColor -->
|
||||
<div class="evt-logo-wrap mx-auto mb-6" aria-hidden="true">
|
||||
<UiSectionSign class="evt-logo" />
|
||||
</div>
|
||||
|
||||
<p class="mb-3 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
|
||||
<h1 class="page-title font-display font-extrabold tracking-tight text-white">
|
||||
{{ content?.title }}
|
||||
<h1 class="page-title relative font-display font-extrabold tracking-tight" style="color: hsl(var(--color-text))">
|
||||
<!-- The trapeze artist at the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
|
||||
{{ content?.title }}<UiOiseauPose nom="trapeziste" pose="perche" bout left="16px" :w="96" ton="accent" :seuil="1200" />
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-white/50">
|
||||
<p v-if="evtContent?.format" class="evt-format font-display">
|
||||
{{ evtContent.format }}
|
||||
</p>
|
||||
<p v-if="evtContent?.subtitle" class="mt-2 text-base italic" style="color: hsl(var(--color-text-muted))">
|
||||
{{ evtContent.subtitle }}
|
||||
</p>
|
||||
<p class="mt-4 text-lg leading-relaxed max-w-xl mx-auto" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
|
||||
<!-- Leitmotiv -->
|
||||
<div v-if="evtContent?.leitmotiv" class="mt-8 flex flex-col items-center gap-3">
|
||||
<p class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
« {{ evtContent.leitmotiv }} »
|
||||
</p>
|
||||
<!-- The acrobat before the tagline, on its line: the tagline is narrower than the leitmotiv,
|
||||
28 px keep it out from under the leitmotiv, and above it the description ends higher
|
||||
(rule f, DA 03/11 after 17h35). By « Format & lieu », the stage manager stands alone -->
|
||||
<p v-if="evtContent?.tagline" class="relative font-mono text-sm tracking-widest uppercase" style="color: hsl(var(--color-primary))">
|
||||
{{ evtContent.tagline }}
|
||||
<UiOiseauPose nom="acrobate" pose="perche" right="calc(100% + 28px)" bottom="0px" :w="72" />
|
||||
</p>
|
||||
<span v-if="evtContent?.gestation" class="evt-gestation-badge">
|
||||
<div class="i-lucide-flask-conical h-3.5 w-3.5" />
|
||||
En gestation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Contenu événement ══ -->
|
||||
<section v-if="evtContent?.axes || evtContent?.espaces || evtContent?.config" class="evt-content relative section-padding">
|
||||
<!-- The puppeteer stands on the footer's top rule, from 1200 px: under « Espaces & programme »
|
||||
the axes cards leave 100 px, half what it needs (rule f), and each row of cards holds one
|
||||
bird (rule c). The only bird of the page out of its block (orch, 03/11, option 1). Its
|
||||
offset is the seal's block of the layout (--seal-block), between this section and the
|
||||
footer; the bird check verifies it stands on the rule -->
|
||||
<UiOiseauPose nom="marionnettiste" pose="perche" left="18%" bottom="calc(-1 * var(--seal-block))" :w="112" retourne :seuil="1200" />
|
||||
<div class="container-content flex flex-col gap-14">
|
||||
|
||||
<!-- 3 axes -->
|
||||
<div v-if="evtContent?.axes">
|
||||
<h2 class="evt-section-title relative">
|
||||
<!-- The clown at the end of its title, on its baseline (rule e). M: between 768 and
|
||||
1199 px the four birds shown would all be S otherwise (rule a) -->
|
||||
Trois axes d'émancipation<UiOiseauPose nom="clown" pose="perche" bout left="14px" :w="108" retourne ton="accent" />
|
||||
</h2>
|
||||
<p class="evt-section-sub">« je subis — ou je m'affranchis »</p>
|
||||
<div class="axes-grid">
|
||||
<div v-for="(axe, i) in evtContent.axes" :key="i" class="axe-card">
|
||||
<UiOiseauPose v-for="o in OISEAUX_CARTE[axe.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<div class="axe-icon">
|
||||
<div :class="`i-lucide-${axe.icon} h-5 w-5`" />
|
||||
</div>
|
||||
<h3 class="font-display text-lg font-bold mb-3" style="color: hsl(var(--color-text))">
|
||||
{{ axe.label }}
|
||||
</h3>
|
||||
<ul class="axe-list">
|
||||
<li v-for="(item, j) in axe.items" :key="j">
|
||||
<div class="i-lucide-arrow-right h-3.5 w-3.5 shrink-0 mt-0.5" style="color: hsl(var(--color-primary))" />
|
||||
<span>{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Espaces permanents -->
|
||||
<div v-if="evtContent?.espaces">
|
||||
<h2 class="evt-section-title relative">
|
||||
Espaces & programme
|
||||
</h2>
|
||||
<p class="evt-section-sub">Chorégraphie séquencée de plénières, ateliers et espaces permanents</p>
|
||||
<div class="espaces-grid">
|
||||
<div v-for="(esp, i) in evtContent.espaces" :key="i" class="espace-card">
|
||||
<UiOiseauPose v-for="o in OISEAUX_CARTE[esp.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<div class="espace-icon">
|
||||
<div :class="`i-lucide-${esp.icon} h-4 w-4`" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-sm" style="color: hsl(var(--color-text))">{{ esp.label }}</p>
|
||||
<p class="text-xs mt-0.5 leading-relaxed" style="color: hsl(var(--color-text-muted))">{{ esp.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config -->
|
||||
<div v-if="evtContent?.config">
|
||||
<h2 class="evt-section-title relative">
|
||||
Format & lieu
|
||||
<!-- S at 72 px, feet on the cards' top edge (the title's 0.25rem margin under it): the
|
||||
espaces cards above leave 106 px at 768 px, and a perched bird keeps a void of its own
|
||||
height above it (rule f) -->
|
||||
<UiOiseauPose nom="regisseur" pose="perche" right="24%" bottom="-0.25rem" :w="72" retourne />
|
||||
</h2>
|
||||
<div class="config-grid">
|
||||
<div v-for="(cfg, i) in evtContent.config" :key="i" class="config-card">
|
||||
<UiOiseauPose v-for="o in OISEAUX_CARTE[cfg.label] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
<div class="config-icon">
|
||||
<div :class="`i-lucide-${cfg.icon} h-4 w-4`" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-sm" style="color: hsl(var(--color-text))">{{ cfg.label }}</p>
|
||||
<p class="text-xs mt-0.5" style="color: hsl(var(--color-text-muted))">{{ cfg.detail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('evenement')
|
||||
const evtContent = computed(() => content.value as Record<string, any> | null)
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Évènement',
|
||||
// Birds standing on the cards, by card label: never two on the same row
|
||||
const OISEAUX_CARTE: Record<string, OiseauPlace[]> = {
|
||||
'Économique': [{ nom: 'jongleur', pose: 'perche', right: '22px', w: 76 }],
|
||||
'Ğ(marché)': [{ nom: 'cracheur-de-feu', pose: 'perche', right: '18px', w: 76, ton: 'accent', seuil: 1200 }],
|
||||
}
|
||||
|
||||
useSeoPage({
|
||||
title: evtContent.value?.meta?.title ?? 'Évènement — Le Librodrome',
|
||||
description: evtContent.value?.description as string ?? 'Prochains événements du Librodrome : rencontres, lectures et ateliers autour de l\'économie du don.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.evt-format {
|
||||
margin-top: 0.35rem;
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(2.5rem, 6vw, 3.5rem);
|
||||
}
|
||||
|
||||
.shadok-juggler {
|
||||
position: absolute;
|
||||
left: 4%;
|
||||
top: 5%;
|
||||
width: clamp(100px, 14vw, 190px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
/* ── Logo SVG ── */
|
||||
.evt-logo-wrap {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-bounce-juggler 4s ease-in-out infinite;
|
||||
filter: drop-shadow(0 6px 14px hsl(var(--color-primary) / 0.25));
|
||||
}
|
||||
|
||||
.shadok-ladder {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
top: 3%;
|
||||
width: clamp(90px, 12vw, 170px);
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
.evt-logo {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ── Badge gestation hero ── */
|
||||
.evt-gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
border: 1px solid hsl(var(--color-accent) / 0.25);
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-wobble-ladder 5s ease-in-out infinite;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.shadok-acrobat {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 55%;
|
||||
transform: translateX(-50%);
|
||||
width: clamp(100px, 13vw, 180px);
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
/* ── Section contenus événement ── */
|
||||
.evt-content {
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.evt-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
font-weight: 800;
|
||||
color: hsl(var(--color-text));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.evt-section-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-spin-acrobat 6s ease-in-out infinite;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.shadok-sleeper {
|
||||
position: absolute;
|
||||
left: 3%;
|
||||
bottom: 5%;
|
||||
width: clamp(110px, 15vw, 210px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
/* ── Axes ── */
|
||||
.axes-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.axes-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.axe-card {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.07);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.axe-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
box-shadow: 0 4px 24px hsl(var(--color-primary) / 0.06);
|
||||
}
|
||||
|
||||
.axe-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.axe-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.axe-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
/* ── Espaces ── */
|
||||
.espaces-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.espaces-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.espaces-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.espace-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.06);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.espace-card:hover {
|
||||
border-color: hsl(var(--color-accent) / 0.25);
|
||||
}
|
||||
|
||||
.espace-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-accent) / 0.1);
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-sleeper 8s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shadok-cook {
|
||||
position: absolute;
|
||||
right: 3%;
|
||||
bottom: 4%;
|
||||
width: clamp(100px, 14vw, 200px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
/* ── Config ── */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.config-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
.config-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.06);
|
||||
}
|
||||
|
||||
.config-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-bounce-cook 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-bounce-juggler {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-12px); }
|
||||
60% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes shadok-wobble-ladder {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(3deg); }
|
||||
75% { transform: rotate(-3deg); }
|
||||
}
|
||||
|
||||
@keyframes shadok-spin-acrobat {
|
||||
0% { transform: translateX(-50%) rotate(0deg); }
|
||||
25% { transform: translateX(-50%) rotate(15deg); }
|
||||
50% { transform: translateX(-50%) rotate(0deg); }
|
||||
75% { transform: translateX(-50%) rotate(-15deg); }
|
||||
100% { transform: translateX(-50%) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes shadok-float-sleeper {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes shadok-bounce-cook {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-10px); }
|
||||
70% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-juggler { display: none; }
|
||||
.shadok-ladder { display: none; }
|
||||
.shadok-acrobat { display: none; }
|
||||
.shadok-sleeper { display: none; }
|
||||
.shadok-cook { display: none; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,6 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<div class="relative overflow-hidden section-padding">
|
||||
<!-- Shadok alchemist: character stirring a cauldron with sparkles -->
|
||||
<svg class="shadok-alchemist" viewBox="0 0 240 320" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="120" cy="145" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="120" cy="82" r="24" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Wizard hat -->
|
||||
<polygon points="120,30 100,80 140,80" fill="currentColor" opacity="0.5"/>
|
||||
<line x1="100" y1="80" x2="140" y2="80" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.45"/>
|
||||
<!-- Hat star -->
|
||||
<circle cx="118" cy="55" r="3" fill="currentColor" opacity="0.25"/>
|
||||
<!-- Eyes (mischievous) -->
|
||||
<circle cx="111" cy="78" r="4.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="129" cy="78" r="4.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="112" cy="77" r="2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="130" cy="77" r="2" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Grin -->
|
||||
<path d="M112 92 Q120 98 128 92" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- Arm holding spoon/stick -->
|
||||
<line x1="158" y1="140" x2="175" y2="210" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Other arm -->
|
||||
<path d="M82 145 Q65 165 70 185" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<!-- Cauldron -->
|
||||
<path d="M60 220 Q60 260 120 260 Q180 260 180 220" fill="currentColor" opacity="0.4"/>
|
||||
<ellipse cx="120" cy="220" rx="60" ry="15" fill="currentColor" opacity="0.3"/>
|
||||
<ellipse cx="120" cy="220" rx="60" ry="15" stroke="currentColor" stroke-width="2" fill="none" opacity="0.4"/>
|
||||
<!-- Cauldron legs -->
|
||||
<line x1="80" y1="258" x2="75" y2="280" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="160" y1="258" x2="165" y2="280" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||
<!-- Bubbles from cauldron -->
|
||||
<circle cx="100" cy="210" r="5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="135" cy="205" r="4" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="115" cy="198" r="3" fill="currentColor" opacity="0.15"/>
|
||||
<!-- Sparkles -->
|
||||
<circle cx="90" cy="190" r="2" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="150" cy="195" r="2.5" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="105" cy="185" r="1.5" fill="currentColor" opacity="0.2"/>
|
||||
<!-- Character legs -->
|
||||
<line x1="105" y1="190" x2="95" y2="225" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.5"/>
|
||||
<line x1="135" y1="190" x2="145" y2="225" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content max-w-3xl mx-auto">
|
||||
<!-- Back link -->
|
||||
@@ -58,8 +17,9 @@
|
||||
<span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.kicker }}
|
||||
</span>
|
||||
<h1 class="page-title font-display font-bold text-white">
|
||||
{{ content?.title }}
|
||||
<h1 class="page-title relative font-display font-bold text-white">
|
||||
<!-- The alchemist at the end of the title, on its baseline (rule e) -->
|
||||
{{ content?.title }}<UiOiseauPose nom="alchimiste" pose="perche" bout left="16px" :w="98" retourne />
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto">
|
||||
{{ content?.description }}
|
||||
@@ -104,8 +64,9 @@
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('gratewizard')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'grateWizard \u2014 Coefficients relatifs',
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'grateWizard — Coefficients relatifs',
|
||||
description: 'Calculateur de coefficients relatifs pour l\'économie du don et la monnaie libre (DU/June).',
|
||||
})
|
||||
|
||||
const { url, launch } = useGrateWizard()
|
||||
@@ -136,24 +97,4 @@ code {
|
||||
border-radius: 0.25em;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
|
||||
.shadok-alchemist {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 10%;
|
||||
width: clamp(120px, 16vw, 230px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-alchemist 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-alchemist {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-10px) rotate(1deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-alchemist { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<HomeHeroSection />
|
||||
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeGrateWizardTeaser />
|
||||
<HomeLanding @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeMessages />
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Accueil',
|
||||
useSeoPage({
|
||||
title: 'Le Librodrome — autonomie numérique, économique, citoyenne',
|
||||
description: 'Construire une autonomie collective à l\'échelle des bassins de vie. Un livre, des chansons et des outils pour l\'émancipation.',
|
||||
})
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
|
||||
+83
-9
@@ -2,20 +2,33 @@
|
||||
<div class="section-padding">
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1>
|
||||
<p class="text-white/50 mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
|
||||
<p class="page-subtitle mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-4">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
<!-- En-tête auteur -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
|
||||
<span class="type-pill">{{ typeLabel(msg.type) }}</span>
|
||||
<span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
<!-- Texte du message -->
|
||||
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<!-- Réponse -->
|
||||
<div v-if="msg.reply?.text" class="reply-thread">
|
||||
<div class="reply-connector" aria-hidden="true" />
|
||||
<div class="reply-block">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
|
||||
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
|
||||
</div>
|
||||
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-white/40 py-12">Aucun message pour l'instant.</p>
|
||||
<p v-else class="text-center page-subtitle py-12">Aucun message pour l'instant.</p>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<NuxtLink to="/" class="btn-ghost text-sm">
|
||||
@@ -28,12 +41,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Messages',
|
||||
useSeoPage({
|
||||
title: 'Messages — Le Librodrome',
|
||||
description: 'Questions, retours et réactions des lecteurs et visiteurs du Librodrome.',
|
||||
})
|
||||
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
@@ -41,10 +66,59 @@ function formatDate(iso: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-subtitle { color: hsl(var(--color-text) / 0.5); }
|
||||
.msg-author { color: hsl(var(--color-text) / 0.75); }
|
||||
.msg-date { color: hsl(var(--color-text) / 0.38); }
|
||||
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
|
||||
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
|
||||
.reply-author { color: hsl(var(--color-primary) / 0.8); }
|
||||
.reply-text { color: hsl(var(--color-text) / 0.62); }
|
||||
|
||||
.message-card {
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
.reply-thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.reply-connector {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: -0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--color-primary) / 0.35),
|
||||
hsl(var(--color-primary) / 0.15)
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.25);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.6rem 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden section-padding">
|
||||
<!-- Shadok reader: character with big glasses reading a book -->
|
||||
<svg class="shadok-reader" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="120" cy="170" rx="45" ry="55" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="120" cy="100" r="28" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Big round glasses -->
|
||||
<circle cx="107" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
|
||||
<circle cx="133" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
|
||||
<line x1="118" y1="94" x2="122" y2="94" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
<!-- Eyes behind glasses -->
|
||||
<circle cx="108" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="134" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Arms holding book -->
|
||||
<line x1="78" y1="155" x2="60" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="162" y1="155" x2="180" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Book (open) -->
|
||||
<rect x="55" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.35"/>
|
||||
<rect x="110" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="110" y1="175" x2="110" y2="215" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
<!-- Book lines (text) -->
|
||||
<line x1="65" y1="188" x2="100" y2="188" stroke="currentColor" stroke-width="1" opacity="0.2"/>
|
||||
<line x1="65" y1="195" x2="95" y2="195" stroke="currentColor" stroke-width="1" opacity="0.2"/>
|
||||
<line x1="65" y1="202" x2="98" y2="202" stroke="currentColor" stroke-width="1" opacity="0.2"/>
|
||||
<!-- Legs -->
|
||||
<line x1="105" y1="222" x2="95" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="135" y1="222" x2="145" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok stack: pile of books tilting -->
|
||||
<svg class="shadok-stack" viewBox="0 0 160 220" fill="none" aria-hidden="true">
|
||||
<!-- Bottom book -->
|
||||
<rect x="20" y="170" width="120" height="22" rx="3" fill="currentColor" opacity="0.5" transform="rotate(-2 80 181)"/>
|
||||
<!-- Second book -->
|
||||
<rect x="30" y="145" width="100" height="20" rx="3" fill="currentColor" opacity="0.45" transform="rotate(3 80 155)"/>
|
||||
<!-- Third book -->
|
||||
<rect x="25" y="120" width="110" height="18" rx="3" fill="currentColor" opacity="0.4" transform="rotate(-4 80 129)"/>
|
||||
<!-- Fourth book -->
|
||||
<rect x="35" y="97" width="90" height="18" rx="3" fill="currentColor" opacity="0.35" transform="rotate(5 80 106)"/>
|
||||
<!-- Fifth book (tilting more) -->
|
||||
<rect x="40" y="74" width="80" height="17" rx="3" fill="currentColor" opacity="0.3" transform="rotate(-7 80 82)"/>
|
||||
<!-- Top book (really tilting) -->
|
||||
<rect x="45" y="52" width="70" height="16" rx="3" fill="currentColor" opacity="0.25" transform="rotate(10 80 60)"/>
|
||||
<!-- Tiny Shadok sitting on top -->
|
||||
<ellipse cx="85" cy="42" rx="10" ry="8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="85" cy="30" r="6" fill="currentColor" opacity="0.45"/>
|
||||
<circle cx="87" cy="29" r="1.5" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok scribe: character with quill and inkwell -->
|
||||
<svg class="shadok-scribe" viewBox="0 0 240 280" fill="none" aria-hidden="true">
|
||||
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
|
||||
<ellipse cx="120" cy="72" rx="20" ry="8" fill="currentColor" opacity="0.35"/>
|
||||
<circle cx="112" cy="90" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="130" cy="90" r="4" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="113" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="131" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<path d="M118 104 Q120 108 122 104" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="160" y1="140" x2="190" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="190" y1="170" x2="210" y2="145" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.45"/>
|
||||
<path d="M210 145 Q215 140 212 135 Q208 138 210 145" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="80" y1="148" x2="55" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<path d="M100 200 Q90 230 95 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<path d="M140 200 Q150 230 145 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<rect x="45" y="185" width="20" height="18" rx="3" fill="currentColor" opacity="0.35"/>
|
||||
<ellipse cx="55" cy="185" rx="12" ry="4" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="170" y="175" width="40" height="50" rx="2" fill="currentColor" opacity="0.2"/>
|
||||
<line x1="178" y1="188" x2="202" y2="188" stroke="currentColor" stroke-width="1" opacity="0.15"/>
|
||||
<line x1="178" y1="195" x2="200" y2="195" stroke="currentColor" stroke-width="1" opacity="0.15"/>
|
||||
<line x1="178" y1="202" x2="198" y2="202" stroke="currentColor" stroke-width="1" opacity="0.15"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
|
||||
<h1 class="page-title font-display font-bold tracking-tight text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 mx-auto max-w-2xl text-white/60">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="chapter in chapters"
|
||||
:key="chapter.path"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="`/modele-eco/${chapter.stem?.split('/').pop()}`"
|
||||
class="card-surface flex items-start gap-4 group"
|
||||
>
|
||||
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
|
||||
{{ String(chapter.order).padStart(2, '0') }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
|
||||
{{ chapter.title }}
|
||||
</h2>
|
||||
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
|
||||
{{ chapter.description }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<span v-if="chapter.readingTime" class="text-xs text-white/30">
|
||||
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
|
||||
{{ chapter.readingTime }}
|
||||
</span>
|
||||
<SongBadges :chapter-slug="chapter.stem?.split('/').pop() ?? ''" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('modele-eco')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Table des matières',
|
||||
})
|
||||
|
||||
const { data: chapters } = await useAsyncData('book-toc', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.shadok-reader {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 3%;
|
||||
width: clamp(110px, 15vw, 220px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float-reader 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shadok-stack {
|
||||
position: absolute;
|
||||
left: 2%;
|
||||
bottom: 5%;
|
||||
width: clamp(100px, 13vw, 180px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-stack 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-reader {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes shadok-float-stack {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(-2deg); }
|
||||
}
|
||||
|
||||
.shadok-scribe {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 3%;
|
||||
transform: translateX(-50%);
|
||||
width: clamp(100px, 13vw, 180px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-primary));
|
||||
animation: shadok-float-scribe 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-scribe {
|
||||
0%, 100% { transform: translateX(-50%) translateY(0); }
|
||||
50% { transform: translateX(-50%) translateY(-8px); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-reader { display: none; }
|
||||
.shadok-stack { display: none; }
|
||||
.shadok-scribe { display: none; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="axis-scope axis-scope--num oiseaux-scene relative overflow-hidden section-padding">
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="relative mb-2 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
<!-- Phone: the title fills the width; the bird stands at the end of this line, on its baseline.
|
||||
A flat bird (37-44 px tall at 72 px wide): 93 px separate this baseline from the sticky
|
||||
header, and a perched bird keeps a void of its own height above it (rule f) -->
|
||||
{{ content?.kicker }}<UiOiseauPose nom="codeuse" pose="perche" bout :w="72" mobile-seul />
|
||||
</p>
|
||||
<h1 class="page-title relative font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
|
||||
<!-- At the end of the title, on its baseline (rule e, docs/da-oiseaux.md) -->
|
||||
{{ content?.title }}<UiOiseauPose nom="droneuse" pose="perche" bout left="16px" :w="76" :seuil="1200" />
|
||||
</h1>
|
||||
<p class="mt-4 mx-auto max-w-2xl leading-relaxed" style="color: hsl(var(--color-text-muted))">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex flex-col gap-6">
|
||||
<!-- LPK sert l'axe numérique -->
|
||||
<NuxtLink v-if="lpk" :to="lpk.to" class="lpk-card">
|
||||
<UiOiseauPose nom="electricien" pose="marge-g" ecart="40px" :w="150" />
|
||||
<span class="lpk-name">{{ lpk.name }}</span>
|
||||
<span class="lpk-body">
|
||||
<span class="lpk-full">{{ lpk.text }}</span>
|
||||
<span class="lpk-role">{{ lpk.role }}</span>
|
||||
</span>
|
||||
<span class="i-lucide-arrow-right h-5 w-5 lpk-arrow" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
|
||||
<div
|
||||
v-for="pillar in content?.pillars"
|
||||
:key="pillar.id"
|
||||
class="pillar-card"
|
||||
>
|
||||
<UiOiseauPose v-for="o in OISEAUX_PILIER[pillar.id] ?? []" :key="`${o.nom}-${o.pose}`" v-bind="o" />
|
||||
|
||||
<!-- Overlay lien — toute la carte cliquable -->
|
||||
<NuxtLink v-if="pillar.to" :to="pillar.to" class="pillar-overlay" :aria-label="pillar.label" tabindex="-1" />
|
||||
|
||||
<!-- Corps de la carte -->
|
||||
<div class="pillar-body">
|
||||
<div class="pillar-header">
|
||||
<div class="pillar-icon">
|
||||
<div :class="`i-lucide-${pillar.icon}`" class="h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-bold" style="color: hsl(var(--color-text))">
|
||||
{{ pillar.label }}
|
||||
</h2>
|
||||
<UiStatusBadge class="ml-auto" :status="pillar.status" :gestation="pillar.gestation" :since="pillar.since" />
|
||||
</div>
|
||||
|
||||
<p class="leading-relaxed whitespace-pre-line mt-3" style="color: hsl(var(--color-text-muted))">{{ pillar.text }}</p>
|
||||
|
||||
<!-- Project card -->
|
||||
<div v-if="pillar.project" class="project-card mt-4">
|
||||
<div class="project-icon">
|
||||
<div class="i-lucide-rocket h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-display font-semibold text-sm" style="color: hsl(var(--color-text))">{{ pillar.project.name }}</span>
|
||||
<span class="text-sm ml-2" style="color: hsl(var(--color-text-muted))">{{ pillar.project.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone actions — z-index au-dessus de l'overlay -->
|
||||
<div v-if="(pillar as any).actions?.length" class="pillar-actions">
|
||||
<a
|
||||
v-for="action in (pillar as any).actions"
|
||||
:key="action.label"
|
||||
:href="action.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="pillar-action-btn"
|
||||
:class="{ 'pillar-action-btn--highlight': action.highlight }"
|
||||
>
|
||||
<div :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" />
|
||||
{{ action.label }}
|
||||
</a>
|
||||
<span v-for="action in (pillar as any).actions.filter((a: any) => a.note)" :key="`n-${action.label}`" class="pillar-action-note">{{ action.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OiseauPlace } from '~/utils/oiseauPose'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('numerique')
|
||||
|
||||
// Birds by pillar, each next to the trade it is about (composition of 03/11)
|
||||
const OISEAUX_PILIER: Record<string, OiseauPlace[]> = {
|
||||
// The cards stand 16 px apart: no room above them for a perched bird (DA rule of 03/11).
|
||||
// In the margins, no strict zigzag and several sizes (DA, 03/11 07h56): two stand at the
|
||||
// bottom of their card on the right, the network admin is large behind Cloud libre
|
||||
'logiciel-libre': [
|
||||
{ nom: 'codeuse', pose: 'marge-d', ecart: '28px', w: 84, retourne: true },
|
||||
],
|
||||
'authentification-wot': [
|
||||
{ nom: 'cryptographe', pose: 'marge-d', ecart: '64px', w: 112, retourne: true },
|
||||
],
|
||||
'cloud-libre': [
|
||||
// out by the side of the card, not by its corner: the card is a link, and its rounded corner
|
||||
// let 5 px of the drawing show on it (rule h)
|
||||
{ nom: 'admin-reseau', pose: 'derriere', left: '-150px', bottom: '20px', w: 236 },
|
||||
],
|
||||
}
|
||||
const { data: site } = await useSiteContent()
|
||||
const lpk = computed(() => site.value?.lpk)
|
||||
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'Autonomie numérique',
|
||||
description: (content.value as Record<string, unknown> | null)?.description as string ?? 'Logiciel libre, authentification Web of Trust, cloud souverain — reprendre le contrôle de ses outils numériques.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lpk-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-text));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.lpk-card:hover { transform: translateY(-3px); }
|
||||
.lpk-card:active { transform: translateY(0); }
|
||||
|
||||
.lpk-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.lpk-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.lpk-full {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.lpk-role {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pillar-action-note {
|
||||
align-self: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
.lpk-arrow { color: hsl(var(--color-primary)); flex-shrink: 0; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lpk-card { transition: none; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.pillar-card {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: hsl(var(--color-surface));
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pillar-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.pillar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.pillar-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.pillar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: 1px solid hsl(var(--color-text) / 0.06);
|
||||
background: hsl(var(--color-bg) / 0.4);
|
||||
border-bottom-left-radius: 0.75rem;
|
||||
border-bottom-right-radius: 0.75rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pillar-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
background: hsl(var(--color-text) / 0.05);
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pillar-action-btn:hover {
|
||||
color: hsl(var(--color-text));
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.pillar-action-btn--highlight {
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.pillar-action-btn--highlight:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.pillar-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pillar-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-bg) / 0.5);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RouterConfig } from '@nuxt/schema'
|
||||
|
||||
export default <RouterConfig>{
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
if (to.hash) return { el: to.hash, behavior: 'smooth' }
|
||||
return { top: 0, behavior: 'instant' }
|
||||
},
|
||||
}
|
||||
+50
-23
@@ -8,6 +8,10 @@ interface PaletteColors {
|
||||
surfaceLight: string
|
||||
text: string
|
||||
textMuted: string
|
||||
// One hue per axis: numérique (cool) · économique (warm) · citoyenne (green)
|
||||
axisNum: string
|
||||
axisEco: string
|
||||
axisCit: string
|
||||
isLight: boolean
|
||||
label: string
|
||||
icon: string
|
||||
@@ -25,6 +29,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
|
||||
surfaceLight: '20 8% 17%', // bois fumé
|
||||
text: '0 0% 100%',
|
||||
textMuted: '0 0% 65%',
|
||||
axisNum: '195 55% 60%',
|
||||
axisEco: '32 85% 55%',
|
||||
axisCit: '95 35% 55%',
|
||||
isLight: false,
|
||||
label: 'Automne',
|
||||
icon: 'i-lucide-leaf',
|
||||
@@ -39,6 +46,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
|
||||
surfaceLight: '220 12% 18%', // brume nocturne
|
||||
text: '0 0% 100%',
|
||||
textMuted: '210 10% 65%',
|
||||
axisNum: '205 70% 66%',
|
||||
axisEco: '36 80% 62%',
|
||||
axisCit: '160 40% 58%',
|
||||
isLight: false,
|
||||
label: 'Hiver',
|
||||
icon: 'i-lucide-snowflake',
|
||||
@@ -55,6 +65,9 @@ const palettes: Record<PaletteName, PaletteColors> = {
|
||||
surfaceLight: '148 22% 77%', // feuillage vif
|
||||
text: '155 50% 6%', // encre noire-verte
|
||||
textMuted: '150 22% 28%', // sous-bois dense
|
||||
axisNum: '212 65% 34%',
|
||||
axisEco: '338 80% 42%',
|
||||
axisCit: '152 80% 22%',
|
||||
isLight: true,
|
||||
label: 'Printemps',
|
||||
icon: 'i-lucide-flower-2',
|
||||
@@ -69,42 +82,56 @@ const palettes: Record<PaletteName, PaletteColors> = {
|
||||
surfaceLight: '30 32% 78%', // argile chaude
|
||||
text: '20 45% 8%', // brun profond
|
||||
textMuted: '22 22% 30%', // ombre terracotta
|
||||
axisNum: '198 70% 30%',
|
||||
axisEco: '24 88% 42%',
|
||||
axisCit: '150 45% 28%',
|
||||
isLight: true,
|
||||
label: 'Été',
|
||||
icon: 'i-lucide-sun',
|
||||
},
|
||||
}
|
||||
|
||||
export const usePaletteStore = defineStore('palette', () => {
|
||||
const currentPalette = ref<PaletteName>(
|
||||
(import.meta.client && localStorage.getItem('palette') as PaletteName) || 'automne',
|
||||
// Chaque palette devient un "mood" du layer @yvv/nuxt-base : les couleurs
|
||||
// (identité du projet) sont injectées en variables CSS --color-*, et les classes
|
||||
// .palette-light / .palette-dark + color-scheme sont gérées par le mécanisme partagé.
|
||||
const paletteMoods = (Object.entries(palettes) as [PaletteName, PaletteColors][]).map(
|
||||
([id, c]) => ({
|
||||
id,
|
||||
isDark: !c.isLight,
|
||||
vars: {
|
||||
'--color-primary': c.primary,
|
||||
'--color-accent': c.accent,
|
||||
'--color-surface': c.surface,
|
||||
'--color-bg': c.bg,
|
||||
'--color-surface-light': c.surfaceLight,
|
||||
'--color-text': c.text,
|
||||
'--color-text-muted': c.textMuted,
|
||||
'--axis-num': c.axisNum,
|
||||
'--axis-eco': c.axisEco,
|
||||
'--axis-cit': c.axisCit,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const usePaletteStore = defineStore('palette', () => {
|
||||
// Application DOM + persistance + light/dark déléguées au layer partagé.
|
||||
// storageKey 'palette' conservé pour ne pas perdre les préférences existantes.
|
||||
const { currentMood, setMood, initMood } = useMood(paletteMoods, {
|
||||
storageKey: 'palette',
|
||||
defaultId: 'ete',
|
||||
syncColorMode: false, // librodrome n'utilise pas @nuxtjs/color-mode
|
||||
lightDarkClasses: true,
|
||||
})
|
||||
|
||||
const currentPalette = computed<PaletteName>(() => (currentMood.value as PaletteName) || 'ete')
|
||||
const colors = computed(() => palettes[currentPalette.value])
|
||||
const isLight = computed(() => colors.value.isLight)
|
||||
|
||||
function applyToDOM() {
|
||||
if (!import.meta.client) return
|
||||
const c = colors.value
|
||||
const root = document.documentElement
|
||||
const s = root.style
|
||||
s.setProperty('--color-primary', c.primary)
|
||||
s.setProperty('--color-accent', c.accent)
|
||||
s.setProperty('--color-surface', c.surface)
|
||||
s.setProperty('--color-bg', c.bg)
|
||||
s.setProperty('--color-surface-light', c.surfaceLight)
|
||||
s.setProperty('--color-text', c.text)
|
||||
s.setProperty('--color-text-muted', c.textMuted)
|
||||
// Toggle light/dark class for CSS overrides
|
||||
root.classList.toggle('palette-light', c.isLight)
|
||||
root.classList.toggle('palette-dark', !c.isLight)
|
||||
s.setProperty('color-scheme', c.isLight ? 'light' : 'dark')
|
||||
}
|
||||
// Init depuis localStorage (SSR-safe : no-op côté serveur).
|
||||
const applyToDOM = () => initMood()
|
||||
|
||||
function setPalette(name: PaletteName) {
|
||||
currentPalette.value = name
|
||||
if (import.meta.client) localStorage.setItem('palette', name)
|
||||
applyToDOM()
|
||||
setMood(name)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export interface ChapterPageLink {
|
||||
chapterSlug: string
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
title: string
|
||||
author: string
|
||||
description: string
|
||||
coverImage?: string
|
||||
pdfFile?: string
|
||||
chapters: ChapterMeta[]
|
||||
songs: import('./song').Song[]
|
||||
chapterSongs: ChapterSongLink[]
|
||||
chapterPages: ChapterPageLink[]
|
||||
defaultPlaylistOrder: string[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Placement of a bird on its block (props of UiOiseauPose), for the per-page tables.
|
||||
export interface OiseauPlace {
|
||||
nom: string
|
||||
pose?: 'perche' | 'marge-g' | 'marge-d' | 'derriere' | 'libre'
|
||||
w?: number
|
||||
left?: string
|
||||
right?: string
|
||||
top?: string
|
||||
bottom?: string
|
||||
ecart?: string
|
||||
retourne?: boolean
|
||||
mobile?: boolean
|
||||
mobileSeul?: boolean
|
||||
seuil?: 768 | 1024 | 1200
|
||||
ton?: 'primary' | 'accent'
|
||||
op?: number
|
||||
bout?: boolean
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// The 61 birds of the librodrome (studio DA, 2026-10-28) and the footer strip.
|
||||
// ratio : aspect ratio of the drawing's box ; sol : where its ground line is, in % of the box height
|
||||
// from the bottom (to stand a bird on an edge of the page : translateY(sol%)).
|
||||
// Files : /images/oiseaux/<name>.svg
|
||||
export const OISEAUX: Record<string, { ratio: string, sol: number, univers: string }> = {
|
||||
"calligraphe": {
|
||||
"ratio": "152.0 / 116.7",
|
||||
"sol": 5.0,
|
||||
"univers": "livre"
|
||||
},
|
||||
"cartonnier": {
|
||||
"ratio": "170.0 / 113.5",
|
||||
"sol": 5.2,
|
||||
"univers": "livre"
|
||||
},
|
||||
"colporteur": {
|
||||
"ratio": "152.0 / 119.6",
|
||||
"sol": 5.1,
|
||||
"univers": "livre"
|
||||
},
|
||||
"conteuse": {
|
||||
"ratio": "175.3 / 155.6",
|
||||
"sol": 3.9,
|
||||
"univers": "livre"
|
||||
},
|
||||
"correcteur": {
|
||||
"ratio": "152.0 / 112.8",
|
||||
"sol": 5.4,
|
||||
"univers": "livre"
|
||||
},
|
||||
"cycliste-livreur": {
|
||||
"ratio": "170.0 / 97.6",
|
||||
"sol": 6.0,
|
||||
"univers": "livre"
|
||||
},
|
||||
"empileur": {
|
||||
"ratio": "157.0 / 147.4",
|
||||
"sol": 4.0,
|
||||
"univers": "livre"
|
||||
},
|
||||
"factrice": {
|
||||
"ratio": "171.8 / 135.8",
|
||||
"sol": 4.5,
|
||||
"univers": "livre"
|
||||
},
|
||||
"illustratrice": {
|
||||
"ratio": "164.0 / 129.6",
|
||||
"sol": 4.6,
|
||||
"univers": "livre"
|
||||
},
|
||||
"lectrice": {
|
||||
"ratio": "137.0 / 133.5",
|
||||
"sol": 4.5,
|
||||
"univers": "livre"
|
||||
},
|
||||
"libraire": {
|
||||
"ratio": "170.0 / 133.6",
|
||||
"sol": 4.6,
|
||||
"univers": "livre"
|
||||
},
|
||||
"relieur": {
|
||||
"ratio": "152.0 / 129.6",
|
||||
"sol": 4.7,
|
||||
"univers": "livre"
|
||||
},
|
||||
"typographe": {
|
||||
"ratio": "157.0 / 131.6",
|
||||
"sol": 4.6,
|
||||
"univers": "livre"
|
||||
},
|
||||
"apicultrice": {
|
||||
"ratio": "167.0 / 155.6",
|
||||
"sol": 3.9,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"berger": {
|
||||
"ratio": "172.0 / 133.0",
|
||||
"sol": 4.4,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"boulangere": {
|
||||
"ratio": "157.0 / 147.4",
|
||||
"sol": 4.1,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"forgeron": {
|
||||
"ratio": "158.0 / 160.7",
|
||||
"sol": 3.7,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"maraichere": {
|
||||
"ratio": "164.0 / 112.1",
|
||||
"sol": 5.4,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"menuisier": {
|
||||
"ratio": "164.0 / 115.1",
|
||||
"sol": 5.2,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"pompeur-de-g1": {
|
||||
"ratio": "159.0 / 115.9",
|
||||
"sol": 5.1,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"potier": {
|
||||
"ratio": "157.0 / 90.6",
|
||||
"sol": 6.5,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"tisserand": {
|
||||
"ratio": "162.0 / 113.6",
|
||||
"sol": 5.3,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"vigneronne": {
|
||||
"ratio": "157.0 / 138.6",
|
||||
"sol": 4.4,
|
||||
"univers": "bassin"
|
||||
},
|
||||
"admin-reseau": {
|
||||
"ratio": "137.0 / 138.6",
|
||||
"sol": 4.3,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"cafeine": {
|
||||
"ratio": "166.0 / 137.4",
|
||||
"sol": 4.3,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"canard-en-plastique": {
|
||||
"ratio": "166.0 / 126.2",
|
||||
"sol": 4.7,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"codeuse": {
|
||||
"ratio": "164.0 / 95.6",
|
||||
"sol": 6.3,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"cryptographe": {
|
||||
"ratio": "172.9 / 147.5",
|
||||
"sol": 4.0,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"debogueur": {
|
||||
"ratio": "166.0 / 111.3",
|
||||
"sol": 5.3,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"droneuse": {
|
||||
"ratio": "169.0 / 152.2",
|
||||
"sol": 3.9,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"electricien": {
|
||||
"ratio": "174.0 / 152.8",
|
||||
"sol": 4.0,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"imprimeuse-3d": {
|
||||
"ratio": "166.0 / 146.0",
|
||||
"sol": 4.1,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"le-manchot": {
|
||||
"ratio": "166.0 / 109.6",
|
||||
"sol": 5.4,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"porteur-de-serveur": {
|
||||
"ratio": "137.0 / 140.5",
|
||||
"sol": 4.3,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"soudeuse": {
|
||||
"ratio": "166.0 / 114.6",
|
||||
"sol": 5.1,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"toile-de-confiance": {
|
||||
"ratio": "137.3 / 159.8",
|
||||
"sol": 3.8,
|
||||
"univers": "numerique"
|
||||
},
|
||||
"avocate": {
|
||||
"ratio": "137.0 / 141.6",
|
||||
"sol": 4.3,
|
||||
"univers": "cite"
|
||||
},
|
||||
"capitaine": {
|
||||
"ratio": "157.0 / 116.5",
|
||||
"sol": 5.1,
|
||||
"univers": "cite"
|
||||
},
|
||||
"cartographe": {
|
||||
"ratio": "151.0 / 123.6",
|
||||
"sol": 4.9,
|
||||
"univers": "cite"
|
||||
},
|
||||
"comedien": {
|
||||
"ratio": "137.0 / 150.0",
|
||||
"sol": 3.9,
|
||||
"univers": "cite"
|
||||
},
|
||||
"juge": {
|
||||
"ratio": "137.0 / 154.9",
|
||||
"sol": 3.9,
|
||||
"univers": "cite"
|
||||
},
|
||||
"matelot": {
|
||||
"ratio": "172.0 / 115.7",
|
||||
"sol": 5.1,
|
||||
"univers": "cite"
|
||||
},
|
||||
"mime": {
|
||||
"ratio": "137.0 / 112.8",
|
||||
"sol": 5.3,
|
||||
"univers": "cite"
|
||||
},
|
||||
"vigie": {
|
||||
"ratio": "184.0 / 154.0",
|
||||
"sol": 3.9,
|
||||
"univers": "cite"
|
||||
},
|
||||
"accordeoniste": {
|
||||
"ratio": "146.3 / 135.6",
|
||||
"sol": 4.5,
|
||||
"univers": "scene"
|
||||
},
|
||||
"acrobate": {
|
||||
"ratio": "137.0 / 153.4",
|
||||
"sol": 3.9,
|
||||
"univers": "scene"
|
||||
},
|
||||
"batteur": {
|
||||
"ratio": "164.0 / 153.6",
|
||||
"sol": 3.9,
|
||||
"univers": "scene"
|
||||
},
|
||||
"clown": {
|
||||
"ratio": "159.4 / 127.9",
|
||||
"sol": 4.7,
|
||||
"univers": "scene"
|
||||
},
|
||||
"cracheur-de-feu": {
|
||||
"ratio": "137.0 / 141.0",
|
||||
"sol": 4.2,
|
||||
"univers": "scene"
|
||||
},
|
||||
"funambule": {
|
||||
"ratio": "170.0 / 162.9",
|
||||
"sol": 3.7,
|
||||
"univers": "scene"
|
||||
},
|
||||
"jongleur": {
|
||||
"ratio": "137.0 / 148.3",
|
||||
"sol": 4.0,
|
||||
"univers": "scene"
|
||||
},
|
||||
"marionnettiste": {
|
||||
"ratio": "164.0 / 133.6",
|
||||
"sol": 4.5,
|
||||
"univers": "scene"
|
||||
},
|
||||
"regisseur": {
|
||||
"ratio": "168.0 / 127.9",
|
||||
"sol": 4.7,
|
||||
"univers": "scene"
|
||||
},
|
||||
"trapeziste": {
|
||||
"ratio": "137.0 / 163.6",
|
||||
"sol": 3.7,
|
||||
"univers": "scene"
|
||||
},
|
||||
"alchimiste": {
|
||||
"ratio": "164.0 / 133.2",
|
||||
"sol": 4.6,
|
||||
"univers": "musique"
|
||||
},
|
||||
"danseur": {
|
||||
"ratio": "162.0 / 159.6",
|
||||
"sol": 3.8,
|
||||
"univers": "musique"
|
||||
},
|
||||
"dj": {
|
||||
"ratio": "164.0 / 155.6",
|
||||
"sol": 3.8,
|
||||
"univers": "musique"
|
||||
},
|
||||
"la-balance": {
|
||||
"ratio": "170.3 / 150.4",
|
||||
"sol": 3.9,
|
||||
"univers": "musique"
|
||||
},
|
||||
"penseur": {
|
||||
"ratio": "162.0 / 150.9",
|
||||
"sol": 3.9,
|
||||
"univers": "musique"
|
||||
},
|
||||
"philosophe": {
|
||||
"ratio": "162.0 / 155.2",
|
||||
"sol": 3.8,
|
||||
"univers": "musique"
|
||||
},
|
||||
"trompettiste": {
|
||||
"ratio": "164.0 / 158.1",
|
||||
"sol": 3.7,
|
||||
"univers": "musique"
|
||||
},
|
||||
"frise": {
|
||||
"ratio": "400 / 80",
|
||||
"sol": 6.3,
|
||||
"univers": "pied de page"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default defineContentConfig({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
order: z.number(),
|
||||
page: z.number().optional(),
|
||||
readingTime: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
---
|
||||
title: "Introduction"
|
||||
description: "Un livre et des chansons pour réaliser une économie du don, portée par les monnaie-libristes."
|
||||
description: "La proposition d'un modèle économique, permis par la monnaie libre — une expérimentation concrète, économique et civile."
|
||||
order: 1
|
||||
page: 9
|
||||
readingTime: "15 min"
|
||||
---
|
||||
|
||||
## Ce livre est une façon
|
||||
## Introduction
|
||||
|
||||
Ce livre est un essai. Une proposition. Une intention. Une invitation. Une façon.
|
||||
[ *masc.* ]
|
||||
|
||||
Il ne s'agit pas d'une théorie universelle, mais d'une **expérimentation concrète, économique, civile et artistique**. Pas un guide, pas un kit : plutôt un *git* — un dépôt de réflexions ouvertes, une cartographie et quelques boussoles dans une jungle à défricher.
|
||||
Ce livre est un essai : la proposition d'un modèle économique, permis par la monnaie libre. Je désigne par là une seule et unique devise, dénommée June, qui applique la Théorie Relative de la Monnaie – TRM.
|
||||
|
||||
## À qui s'adresse ce livre ?
|
||||
Ce modèle n'est pas une théorie universelle. Je vous fais la proposition d'une expérimentation concrète, économique et civile. Une expérimentation qui peut démarrer sans condition, même à l'échelle d'un petit nombre. Mais c'est en passant à l'échelle d'un bassin de vie qu'elle change de portée et devient… économique.
|
||||
|
||||
Ce livre s'adresse aux quelque **8 500 monnaie-libristes** (au moment de l'écriture) qui utilisent la June (Ğ1), monnaie libre fondée sur la Théorie Relative de la Monnaie. L'ambition : proposer un **modèle économique** concret pour passer de l'expérimentation monétaire à une véritable économie du don.
|
||||
Ce livre n'est donc pas un guide, encore moins un kit. Il ne vous dira pas quoi faire lundi. Il propose des repères concrets et conceptuels pour nourrir une réflexion collective. Le passage à l'action vous appartient.
|
||||
|
||||
## Pourquoi une économie du don ?
|
||||
L'expérience est destinée en premier lieu aux « monnaie-libristes » — quelque 8.500 créateurs monétaires de DUĞ1 à l'heure où j'écris ces lignes. Mais je m'adresse également à toute personne intriguée par le don avec un regard anthropologique ou économique ; aux personnes affranchies qui peuvent consacrer du temps et des savoir-faire ; à celles qui cherchent à créer un monde alternatif ; aux jeunes adultes qui ont la possibilité de faire des choix de vie.
|
||||
|
||||
Nous avons déjà une économie. Elle couvre nos besoins vitaux, de facto. Alors pourquoi en créer une autre ?
|
||||
> *Or le pouvoir de la création monétaire est avant tout celui de créer une économie. Une monnaie n'a aucun lieu d'être sans une économie à servir.*
|
||||
|
||||
Pour **l'autonomie**. Et c'est tout sauf un repli. Réfuter l'autonomie, c'est nous bannir en tant qu'adultes bien vivants. L'autonomie, ici, c'est la capacité d'un groupe humain à couvrir ses besoins essentiels par ses propres moyens — sans pour autant se couper du reste du monde.
|
||||
> *Il n'est ici question que d'une économie du don. Rien d'autre.*
|
||||
|
||||
## Ce que ce livre n'est pas
|
||||
---
|
||||
|
||||
- Ce n'est pas une baguette magique
|
||||
- Ce n'est pas un programme politique
|
||||
- Ça ne dit pas quoi faire lundi
|
||||
- Ce n'est pas du prêt-à-porter : à nous de tailler
|
||||
## Monnaie-libristes
|
||||
|
||||
## Ce que ce livre est
|
||||
[ *fém.* ]
|
||||
|
||||
Un **os à ronger**. Une cartographie avec quelques boussoles. Un livre *et* des chansons, parce que la musique porte les idées autrement.
|
||||
Une partie des junistes ont la volonté d'engendrer une économie monnaie-libriste ; une autre y voit un moyen de rectifier notre économie euro ; d'autres encore y voient un jeu. Les portes d'entrée sont multiples : le logiciel libre, la blockchain et la Toile de Confiance, la sémantique du « dividende universel », les monnaies locales.
|
||||
|
||||
Chaque chapitre est accompagné d'un morceau qui en prolonge le propos. Le livre se lit, s'écoute, se vit.
|
||||
Pour témoigner de mon propre cheminement, je suis arrivé sur la june depuis l'étude de l'économie, menant à la création monétaire, en passant par la case Bernard Lietaer. Donc par la porte de la TRM, moins fréquentée.
|
||||
|
||||
> *On tourne une page pour voir ?*
|
||||
Beaucoup d'énergie est mise sur la tentative de « convertir » des commerçantes dans une démarche très prosélyte, vivant la monnaie libre comme une monnaie locale. C'est naturel — on reproduit des schémas familiers. Mais rester là-dessus ne crée pas une économie.
|
||||
|
||||
> *Car l'économie – avant l'échange – c'est la production.*
|
||||
|
||||
J'utilise l'image de la première vitesse et du geste de « passer la seconde ». Rester en première, c'est énormément d'énergie pour avancer au pas.
|
||||
|
||||
Il manque à beaucoup d'endroits la notion de finalité. Changer de devise n'est pas suffisant. Chaque groupe peut définir sa propre finalité — il en faut une. La monnaie libre n'est pas une baguette magique.
|
||||
|
||||
> *Le leitmotiv de l'autonomie collective à échelle humaine est un excellent candidat, en tant que finalité.*
|
||||
|
||||
L'autonomie n'est pas un repli sur soi, c'est le premier leitmotiv des parents envers leurs enfants. L'année 2026, où je reprends cet essai, s'annonce comme une année de défis. J'appelle de mes vœux la démarche collective de moins subir les agendas.
|
||||
|
||||
L'intention ici est de proposer un modèle, comme un os à ronger — quelques repères économiques, administratifs et symboliques. Progresser sur un terrain vierge nécessite de tâtonner et, dans notre cas, de la créativité.
|
||||
|
||||
> *Ce n'est pas encore l'heure du « prêt-à-porter ».*
|
||||
|
||||
+66
-21
@@ -1,42 +1,87 @@
|
||||
---
|
||||
title: "De quel don parlons-nous ?"
|
||||
description: "Du don de Marcel Mauss aux asymétries du quotidien : comprendre le triple geste donner-recevoir-rendre."
|
||||
description: "Le don, en tant que vecteur clé d'une économie, est le geste de transmettre quelque chose en premier lieu, sans réciprocité symétrique, mais assuré d'un contre-don."
|
||||
order: 2
|
||||
readingTime: "20 min"
|
||||
page: 23
|
||||
readingTime: "18 min"
|
||||
---
|
||||
|
||||
## L'oxymore apparent
|
||||
Dans le titre, c'est évidemment le terme « don » qui marque le plus, car il a tendance à s'opposer au terme économie — une sorte d'oxymore. Le don flirte parfois avec la spiritualité, ce qui prend le dessus sur une notion que l'on se représente froide et intellectuelle. Mais l'est-elle vraiment ?
|
||||
|
||||
« Économie du don » — le mariage fait peur. Un oxymore pour l'esprit. Pour éviter tout quiproquo : il ne s'agit ni de spirituel, ni de karma, ni du « centuple divin ». Ici, le don est un **geste opératoire**, une base, une fondation pour une autre forme de construction.
|
||||
## Trois mots de philo, et de socio
|
||||
|
||||
## Marcel Mauss et le triple geste
|
||||
*[ fém. ]*
|
||||
|
||||
Ce n'est pas l'image d'Épinal du don gratuit. **Marcel Mauss**, dans son *Essai sur le don* (1925), a montré que le don est un cycle vital en trois temps :
|
||||
Je commence par le don, en évacuant le registre spirituel : je ne parle pas du don religieux, du centuple divin, ni de celui que « l'univers me rendra ». Je parle seulement du geste.
|
||||
|
||||
1. **Donner** — l'initiative, le geste premier
|
||||
2. **Recevoir** — accepter crée un lien, une obligation
|
||||
3. **Rendre** — boucler le cycle, entretenir la relation
|
||||
Marcel Mauss est souvent cité dès que l'on parle du don. À l'occasion d'une boucle bretonne, j'ai pu observer que les présentes personnes avaient une interprétation un peu fantasmée des pratiques du don dans les temps anciens — y compris les lectrices de Mauss, alors qu'il présente une tension souvent redoutable : une dimension prépondérante des rapports de domination ; une décorrélation marquée avec les échanges de subsistance.
|
||||
|
||||
C'est un pacte, une tension, parfois même un combat. Si tu enfreins le protocole... ça ne pardonne pas.
|
||||
Notre héritage judéo-chrétien associe culturellement le don à quelque chose qui ne peut – ne doit – pas être rendu. Mais avant les évangiles, le don était un geste triple indissociable : donner — recevoir — rendre (contre-don).
|
||||
|
||||
## La monnaie-dette n'est pas la monnaie
|
||||
J'ai entendu aussi l'idée qu'il faudrait pratiquer le troc, ou supprimer la monnaie comme finalité ultime. Ce propos repose sur l'idée que la monnaie est en elle-même porteuse de problèmes. Pourtant, c'est seulement la monnaie-dette qui génère ces déséquilibres. L'existence d'une création monétaire décentralisée change la donne. La promesse d'une économie du don, c'est que cette monnaie (produite par un DU selon la TRM) apparaît alors davantage comme un outil qui permet de créer des équilibres.
|
||||
|
||||
On entend souvent : « Brûlons la monnaie ! Le troc, la gratuité, le grand projet... » La monnaie serait le vice, la source de tout le mal. **Erreur de cible.** Ce n'est pas la monnaie le problème, c'est la *dette*. La monnaie-dette, celle qui nous tient, celle qui nous guette. Mais si la monnaie est libre ? Elle permet les équilibres.
|
||||
Pour finir, il y a l'idée que le don ne peut pas être mesuré. Je conclus ce chapitre en positionnant clairement le don dont je parle — non pas une définition universelle, mais une définition dans le cadre d'un modèle éco monnaie-libriste :
|
||||
|
||||
## Les asymétries
|
||||
> Le don, en tant que vecteur clé d'une économie, est le **geste de transmettre quelque chose**
|
||||
>
|
||||
> en premier lieu ;
|
||||
> sans réciprocité symétrique ;
|
||||
> mais assuré d'un contre-don ;
|
||||
> de toute forme, de toute provenance.
|
||||
>
|
||||
> **« Mon économie me le rendra »**
|
||||
>
|
||||
> → J'y trouve ma pleine mesure.
|
||||
> → J'y réalise mes propres équilibres.
|
||||
>
|
||||
> Le don que nous manipulons est donc : **un don qui se mesure**.
|
||||
>
|
||||
> Une mesure qui se fait par l'acte d'affecter un DU (*fractions et multiples de DU*), au geste vécu du don ; **selon ses échelles du moment, individuelles et collectives**.
|
||||
>
|
||||
> Une mesure décentralisée et relativiste, pour des milliards d'estimations, toutes légitimes.
|
||||
|
||||
Rien n'est symétrique. Ce n'est pas magique. Une pomme aujourd'hui n'est pas la même demain. Une cagette, ce n'est pas cinq palettes. Six heures assis à parler bien à l'aise... six heures à genoux sur un toit — est-ce le même geste ?
|
||||
« Faire du DU la mesure du don », c'est davantage que « faire de la Ğ1 la monnaie du don ». Le DU a une valeur d'invariant qui permet de positionner toutes les valeurs relativement à lui. C'est la raison pour laquelle j'invite à rebaptiser les choses. Parmi ces propositions, la « monnaie » devient la « mesure ».
|
||||
|
||||
Les asymétries sont partout. On ne peut pas les supprimer, mais on peut les **mesurer**. Et c'est précisément ce que fait une monnaie bien conçue.
|
||||
## Asymétries et Communautés
|
||||
|
||||
## Communautés et protocoles
|
||||
*[ masc. ]*
|
||||
|
||||
Les collectifs qui prônent le don sans outil de mesure finissent souvent par l'usure, le ressentiment, l'abandon. Légiférer, prescrire un comportement ? C'est tentant, mais bancal.
|
||||
Les mouvements alternatifs ont tendance à réunir les « déjà convaincus » et génèrent naturellement une forme d'entre-soi. Les collectifs deviennent souvent des communautés « pseudo-isolées ».
|
||||
|
||||
Mieux vaut un **protocole** — une façon de traiter les asymétries, d'éviter de juger, de préfigurer. Sans trancher les sorts à l'insu des participants.
|
||||
Pour cadrer mon raisonnement : sur un collectif récent qui porte le don dans son code génétique, j'y ai recueilli beaucoup de témoignages d'usure, de sentiments d'abus, d'abandons, de ressentiments. Pourquoi ? Parce que dans la totalité de nos échanges, il y a des asymétries.
|
||||
|
||||
## Eco si nuestra
|
||||
Nos échanges sont asymétriques dans la totalité des cas. Donner une journée par mois pendant un an, est-ce le même geste que donner deux semaines complètes ? Entre une journée assis à étiqueter des pots et une journée plié en deux sur une toiture, y a-t-il équilibre ? Rien n'est symétrique. Pour éviter les abus ou les déséquilibres, il suffit de se mettre d'accord sur une mesure qui fasse sens pour tous.
|
||||
|
||||
L'expérience « Made in Ğ1 » et le jeu **321DU** montrent qu'il est possible de simuler et de vivre une micro-économie du don. Ces prototypages en communauté pseudo-isolée révèlent les mécanismes, les biais, les opportunités.
|
||||
Une population ouverte peut difficilement légiférer sur ces asymétries permanentes ; elle peut en revanche utiliser une monnaie. C'est en cela un outil précieux, probablement le seul.
|
||||
|
||||
> *Pour se rétablir, retomber sur nos pieds, il existe un outil qui s'appelle... « la monnaie ».*
|
||||
> *Résoudre le problème des asymétries est le rôle pivot, structurel, que joue la monnaie dans l'échange.*
|
||||
|
||||
Si je raisonne à l'échelle de mon bassin de vie, que je donne une valeur aux dons des autres et que les autres donnent une valeur à mes dons, peu importe si quelqu'un me déçoit — je m'y retrouverai dans l'économie de mon bassin de vie. Plus besoin que tout le monde se comporte selon mes critères propres.
|
||||
|
||||
En commençant par retrouver dans économie le sens de « *éco - oikos* », son lieu, foyer et terre élue. « Nomos », c'est originellement la façon de distribuer, puis désigne ce que l'être humain institue. Actualisé, on peut lire dans « créer une économie » le fait de définir notre façon de :
|
||||
|
||||
> couvrir nos besoins pour vivre
|
||||
> et
|
||||
> nourrir nos plaisirs de vivre.
|
||||
|
||||
Rien n'empêche de pratiquer un don non mesuré, par exemple pour la beauté de l'élan. Chacun définit son périmètre et ses moments. Mais dès que le don sera mesuré, il deviendra structurant pour l'économie qui émerge.
|
||||
|
||||
## Le cas emblématique « eco si nuestra »
|
||||
|
||||
*[ fém. ]*
|
||||
|
||||
Eco si nuestra est bien connue des junistes. On peut entendre certaines participantes déclarer vivre avec la monnaie-libre pour un tiers de tous leurs besoins. Le premier point est d'associer le DU créé à un devoir et à une durée de travail : le DU que tu crées te rend redevable d'une heure de boulot à la communauté. Elles ont par ailleurs une concertation collective sur les « prix » pour les maintenir au plus bas, et font une double corrélation avec l'heure de travail et les calories.
|
||||
|
||||
Une communauté ouverte sur le monde, dont le caractère pseudo-isolé s'est néanmoins révélé lors de rencontres internationales : un gros décalage de pratiques sur les « prix » a généré ce que je baptiserais pudiquement « *les larmes du pot de confiture* ».
|
||||
|
||||
Cela invite chaque groupe qui voudra utiliser le DU à une réflexion sur l'interopérabilité de ses règles. Quels sont nos indicateurs relatifs locaux ?
|
||||
|
||||
## L'expérience « made in zion »
|
||||
|
||||
*[ masc. ]*
|
||||
|
||||
Cette expérience de vie communautaire en forêt utilisait les DUs pour structurer le fonctionnement quotidien. Pour vivre : 3 DUs pour les visiteuses, 2 pour les contributrices à l'entretien et 1 seul DU pour les amélioratrices (projets). Une rétribution des tâches par tirage dans une « boîte de gratitudes » évite les débats sur la valeur de chacune. L'effet recherché est de « gamifier » — transformer en jeu.
|
||||
|
||||
[ Règles complètes du jeu « 321…DU », @Qoop : https://pad.p2p.legal/s/321DU.LeJeu# ]
|
||||
|
||||
Ce genre de règles ne peut être que circonscrit à un lieu partagé, mais cela peut donner des idées pour un grand nombre de contextes.
|
||||
|
||||
+34
-16
@@ -1,34 +1,52 @@
|
||||
---
|
||||
title: "La mesure du don"
|
||||
description: "Le DU comme unité de mesure du don : un retournement sémantique qui change tout."
|
||||
description: "Considérer le DU de Ğ1 comme unité de mesure du don — une façon très factuelle de prendre la mesure du don."
|
||||
order: 3
|
||||
page: 43
|
||||
readingTime: "8 min"
|
||||
---
|
||||
|
||||
## Une mesure, pas un prix
|
||||
## La mesure du don
|
||||
|
||||
Le mot « mesure » a une portée symbolique décisive. Mesurer le don, ce n'est pas le dénaturer ni le marchandiser. C'est lui donner un **référentiel commun** pour que chacun puisse s'y retrouver.
|
||||
*[ fém. ]*
|
||||
|
||||
Le **Dividende Universel (DU)** — la quantité de monnaie libre créée chaque jour pour chaque membre — devient l'unité de mesure naturelle. Un DU, c'est la part quotidienne de création monétaire d'un être humain. Ni plus, ni moins.
|
||||
Ce terme « mesure » a un caractère très fonctionnel, mais également une portée symbolique importante. Il s'agit de ne plus considérer la june (Ğ1) comme une monnaie au sens courant du terme, mais de considérer le DU de Ğ1 comme unité de mesure du don.
|
||||
|
||||
## Le retournement sémantique
|
||||
Une façon très factuelle de *prendre la mesure* du don ;-)
|
||||
|
||||
Voici le basculement fondamental que propose l'économie du don en monnaie libre :
|
||||
La mesure du don, de même que la notion de contre-don, n'est pas une négation du don — c'est une solution très élégante pour gérer les asymétries, éviter les ressentiments qui naissent tôt ou tard, car ces asymétries sont inévitables et permanentes.
|
||||
|
||||
- **« Je ne vends plus, je donne »** — mon geste premier est un don, pas une transaction marchande
|
||||
- **« Je n'achète plus, je reçois »** — recevoir engage, crée un lien
|
||||
- **« Je ne paye plus, je mesure »** — la monnaie libre mesure la gratitude, pas le prix
|
||||
La posture « *je ne veux rien en retour* », de facto, *oblige* l'autre. Elle peut même mettre dans l'embarras : celle qui reçoit ne sait pas quel est le protocole convenable. Ici, le don n'oblige pas. Le protocole est clair — il suffit de mesurer la valeur que j'attribue à ce don, mon degré de gratitude, avec le DU comme unité.
|
||||
|
||||
Ce retournement n'est pas qu'un jeu de mots. Il transforme la posture intérieure de chaque participant à l'échange.
|
||||
Pour bien saisir la portée de ce geste devenant structurant : je donne avant tout à mon économie, à mon bassin de vie. Si j'apprécie la personne en face, j'y mets une dimension plus affective. Mais si je ne l'apprécie pas, non seulement je peux quand même donner — mais c'est surtout désirable, car c'est mon économie, tout mon bassin de vie, que je sers.
|
||||
|
||||
## Trans·action
|
||||
## Retournement sémantique
|
||||
|
||||
Le mot « transaction » retrouve son sens premier : **trans** (à travers) + **action**. C'est une action qui traverse, qui relie. Pas un échange froid entre deux intérêts, mais un geste qui crée du lien.
|
||||
*[ masc. ]*
|
||||
|
||||
En monnaie libre, chaque transaction est une **mesure de gratitude**. Combien de DU vaut ce que tu m'as donné ? C'est toi qui le dis. C'est moi qui accepte. Et la monnaie, symétrique dans sa création, garantit que personne n'est structurellement avantagé.
|
||||
À compter du moment où l'on ne considère plus le DU comme une monnaie, et que l'on considère tout geste dans le sens du don, on peut naturellement changer de vocabulaire.
|
||||
|
||||
## Mesurer sans juger
|
||||
*Je ne vends plus, je donne.*
|
||||
|
||||
Le protocole de mesure remplace le besoin de légiférer. Pas besoin d'écrire des règles pour chaque situation : la mesure en DU est un **langage commun** qui permet aux asymétries de s'exprimer et de se résorber naturellement.
|
||||
Ma production, mon temps. Je redonne aussitôt à « l'offre » un sens plein. C'est *mon offre*.
|
||||
|
||||
> *Il suffit d'une mesure. Sans morsure. Qui fait sens pour toi et moi, pour tout le monde.*
|
||||
*Je n'achète plus, je reçois.*
|
||||
|
||||
Des biens, des services. Je ne crains plus une dépossession monétaire, je valorise la « joie de recevoir ».
|
||||
|
||||
*Je ne paye plus, je mesure.*
|
||||
|
||||
Je donne du poids, de la masse, des degrés — à la gratitude que je souhaite exprimer, que je souhaite maintenant imprimer dans un registre partagé distribué. En transférant un DU, *j'investis sur un don*, un geste que je souhaite voir se développer dans mon économie.
|
||||
|
||||
*On ne négocie plus un prix, on ajuste une balance.*
|
||||
*On construit nos échelles de valeurs individuelles.*
|
||||
|
||||
Et on les frotte à celles des autres. On construit par le même temps des échelles de valeurs collectives.
|
||||
|
||||
*On cherche des équilibres, ensemble.*
|
||||
|
||||
---
|
||||
|
||||
Bien sûr il y a des contextes où l'on n'a pas le temps de jouer à ce jeu des équilibres — par exemple lorsqu'il y a une notion de débit ou une queue. C'est simple : le don se situe en amont. Une buvette a fait ses estimations internes d'équilibre, elle trouve une valeur de référence pour le verre servi. Le don consiste à offrir toute la buvette elle-même. Rien ne m'empêche de gratifier davantage.
|
||||
|
||||
On relativise, autour de notre invariant, cadeau de la TRM, le DU.
|
||||
|
||||
+107
-16
@@ -1,36 +1,127 @@
|
||||
---
|
||||
title: "Raison d'être d'une monnaie"
|
||||
description: "Pourquoi la monnaie est la clé de voûte de toute économie — et pourquoi les monnaies-dette sont génétiquement viciées."
|
||||
description: "Voyons à quel point les modalités de création de chaque unité de monnaie sont déterminantes, comme un code génétique, pour l'économie que cette monnaie servira."
|
||||
order: 4
|
||||
readingTime: "25 min"
|
||||
page: 49
|
||||
readingTime: "22 min"
|
||||
---
|
||||
|
||||
## La clé de voûte
|
||||
[ *fém.* ]
|
||||
|
||||
La monnaie est l'outil le plus puissant et le plus sous-estimé de toute économie. C'est elle qui, en permanence, résout les asymétries. Même sans qu'on y pense. Chaque monnaie **programme sa propre engeance** — son pouvoir est immense.
|
||||
Je ne vais pas faire un cours sur la monnaie, ni sur son histoire, mais l'évocation d'une absence de monnaie pose tout de même la question de sa nécessité, de son rôle structurant. Elle pose également la question du besoin d'une monnaie radicalement différente. Différente de l'euro, et plus généralement de ce qu'on appelle les « fiat ». Fiat n'est pas un acronyme, c'est le mot latin qui fait référence à quelque chose qui n'existe que par le fait d'avoir été déclaré comme tel. Fiat lux : que la lumière soit. Fiat euro : que l'euro soit.
|
||||
|
||||
## L'équation de Fischer
|
||||
Autrement dit, avec une question simple :
|
||||
|
||||
**E = Q × V** — l'équation de Fischer pose les bases : la masse monétaire en circulation (E) est le produit de la quantité de monnaie (Q) par sa vitesse de circulation (V). Cette relation simple révèle pourquoi le contrôle de la création monétaire est un enjeu de pouvoir fondamental.
|
||||
- Pourquoi se compliquer la vie avec une autre monnaie ?
|
||||
|
||||
## Trois leçons sur les monnaies-dette
|
||||
## Au-delà de ses 3 fonctions
|
||||
|
||||
### 1. La cavalerie
|
||||
[ *masc.* ]
|
||||
|
||||
La monnaie-dette fonctionne comme une cavalerie : pour rembourser la dette d'hier, il faut créer de la dette aujourd'hui. Le système ne tient que par la croissance perpétuelle de l'endettement. Quand la musique s'arrête, tout s'effondre.
|
||||
Dans une image architecturale dont les fondations seraient les institutions bancaires, la monnaie et plus précisément la création monétaire, devient la clé de voûte de l'édifice. Tout ce que l'on construit au sein de cette structure et tout ce que l'on vit dans l'édifice, notre économie, repose sur ces institutions bancaires et tient sous l'égide de la création monétaire.
|
||||
|
||||
### 2. La machine à faillites
|
||||
La création monétaire est programmatique, comme une ligne de code. Elle détermine certains comportements de toutes les entités économiques chaque jour. Dès que j'achète ou que je vends, dès que j'estime mentalement une valeur, aussitôt je m'inscris dans cette programmatique, dans son référentiel.
|
||||
|
||||
Dans un système où la monnaie est créée par le crédit, les intérêts ne sont jamais créés. Il y a donc **structurellement moins de monnaie en circulation que de dettes à rembourser**. Le système fabrique mécaniquement des perdants.
|
||||
Les fameuses trois fonctions formulées par Aristote :
|
||||
|
||||
### 3. La pyramide arbitraire
|
||||
- unité de compte : c'est la mesure.
|
||||
- valeur d'échange : c'est la transaction (apporte la symétrie).
|
||||
- réserve de valeur : c'est l'épargne.
|
||||
|
||||
Qui décide de la création monétaire ? Les banques. Et pour qui ? Pour ceux qui offrent des garanties. Le système monétaire conventionnel est une **pyramide d'accès arbitraire** à la monnaie : ceux qui sont proches de la source en bénéficient, les autres subissent la dilution.
|
||||
La fonction d'unité de compte est plus importante qu'il n'y paraît. C'est parce qu'elle est unité de compte qu'elle peut être une valeur d'échange, et c'est en tant que valeur d'échange qu'il devient indispensable d'en posséder. Si je pense à une cafetière économiquement, je pense un chiffre en euros. Je ne me demande pas si je trouve juste cette équivalence entre un plein d'essence et une cafetière ; … l'est-elle ?
|
||||
|
||||
Dans notre économie monnaie-dette, l'usage de réserve de valeur a pris l'ascendant sur les échanges. L'équation de Fischer : E = Q x V. La quantité monétaire multipliée par sa vitesse de circulation mesure l'économie. Si la monnaie est davantage stockée pour faire des réserves, l'économie ralentit. Mais cette formule ne définit pas l'économie elle-même ; elle dévoile en creux à quel point l'économie est d'abord une affaire de production.
|
||||
|
||||
Petit message aux monnaie-libristes : il ne suffit pas de faire circuler la monnaie pour se réjouir de son économie. L'économie commence avec une production distribuée.
|
||||
|
||||
Je finirai en rappelant que la création monétaire est un pouvoir majeur, ceux qui le détiennent déterminent les échelles de valeur. Donner le même accès à la création monétaire pour tout le monde sans aucune distinction, est bien sûr un cadeau du DU. Mais ce n'est pas la monnaie, toute libre soit-elle, qui crée de l'égalité dans le monde réel.
|
||||
|
||||
> Voyons à quel point les modalités de création de chaque unité de monnaie sont déterminantes, comme un code génétique, pour l'économie que cette monnaie servira. Ou asservira, ce qui est devenu le cas de la « nôtre ».
|
||||
|
||||
## Bassin économique
|
||||
|
||||
[ *fém.* ]
|
||||
|
||||
> « à chaque monnaie son économie ».
|
||||
|
||||
Si on se mobilise sur le développement d'une monnaie aux modalités de création propres, c'est justement parce qu'elle permet de créer une autre économie. Et si on se mobilise c'est parce que le code monétaire que nous vivons actuellement, celui de notre euro, pose problème. Gros problème. Or rien ne peut y remédier, c'est un code… génétique.
|
||||
|
||||
Les monnaies locales ou complémentaires reposant sur une indexation euro, ne permettent pas vraiment de créer une économie différente. Les études économiques sur ces monnaies sont souvent biaisées car endogènes. Ces monnaies créent une sorte de « club », comparable aux « cartes réseaux ». Tous ces collectifs sont précieux car ils s'intéressent à la question monétaire, mais si l'on cherche un impact structurant, il est nécessaire de rompre à la racine avec le code monétaire des fiat.
|
||||
|
||||
## Problèmes génétiques des fiat
|
||||
|
||||
[ *masc.* ]
|
||||
|
||||
Voici trois problèmes majeurs non solvables, de nature strictement monétaire, programmés par le code de création de notre cher euro.
|
||||
|
||||
La quasi-totalité (>90%) de la monnaie qui circule est produite par ladite dette. Vous souscrivez un crédit de 100 k€, la banque crée 100.000 unités dont vous pouvez disposer. Les 100.000 unités n'existaient pas, elles sont créées ; puis détruites quand vous les rendez. Nous devons rendre cette quantité de monnaie, mais nous-mêmes ne la créons pas (les banques ont le droit, pas nous) ; nous devons donc capter des unités qui circulent, en produisant une valeur non encore produite.
|
||||
|
||||
## Leçon #1 — La monnaie-dette est une cavalerie
|
||||
|
||||
Dans ce référentiel monétaire, la croissance n'est pas une option.
|
||||
|
||||
[ *fém.* ]
|
||||
|
||||
Produire pour rendre la même quantité de monnaie ?
|
||||
|
||||
Non. Il faut ajouter lesdits intérêts aux 100 k. 3% sur 10 ans, ça fait près de 30 k, 30 %. Or ces 30.000 unités supplémentaires requises n'ont pas été créées au moment du crédit. Vous devez capter des unités à un autre, qui doit aussi le faire, qui lui aussi doit capter 30 % qui n'ont jamais été créés. Pensez-vous que tout le monde puisse gagner sa partie avec une telle règle du jeu ? Une véritable chaise musicale, où il manquerait non pas une mais le quart des chaises ?
|
||||
|
||||
## Leçon #2 — C'est une machine à faillites
|
||||
|
||||
Dans ce référentiel monétaire, la compétition, pour ne pas dire une féroce prédation, n'est pas une option.
|
||||
|
||||
[ *masc.* ]
|
||||
|
||||
Imaginez une population totale de 50 « personnes ». La monnaie créée M est de 5 millions d'unités circulantes. Mais elle doit reverser collectivement 6,5 M€. Où trouve-t-elle le 1,5 million d'unités qui n'existent pas ?
|
||||
|
||||
38 « personnes » sur 50 peuvent rembourser leurs 130 k, soit 3,8 millions détruits et 1,14 million captés par la banque. Il reste 60 k€ dans l'économie des 50 personnes, mais 95% dans les caisses de la banque ! 12 personnes en faillite monétaire collective doivent encore verser 1,56 million avec 60 k disponibles. Vous voyez le tableau ?
|
||||
|
||||
Comment un petit 3% d'intérêt devient un drainage massif proche de 95% ? Le phénomène est noyé dans la longue durée et dans un volume qui dépasse l'entendement de tout individu. Le seul moyen de créer les 1,5 M€ manquants, c'est de souscrire un crédit de 1,5 M€. La dette passe alors immédiatement à 3 M€. Cela ne résout jamais le problème, ça l'aggrave.
|
||||
|
||||
> C'est ce qu'on appelle une cavalerie : lorsque vous souscrivez un second crédit pour rembourser le premier, puis un troisième pour rembourser le second, ... un jeu qui finit rarement bien.
|
||||
|
||||
Il y a deux formes de pénurie structurellement monétaire : d'une part cette pénurie permanente roulante du fait de toujours devoir rendre plus de monnaie qu'il n'en a été créée ; d'autre part une pénurie itérative, erratique, lorsque les banques ferment le robinet. C'est la raison pour laquelle la peur de manquer invite tout le monde à mettre de côté, raison pour laquelle la fonction de valeur de réserve prend le pas sur sa fonction de valeur d'échange.
|
||||
|
||||
## Leçon #3 — Ce système monétaire est une pyramide arbitraire qui impose ses valeurs
|
||||
|
||||
Dans ce référentiel monétaire, subir ladite Loi du marché n'est pas une option.
|
||||
|
||||
[ *fém.* ]
|
||||
|
||||
Concrètement, vous allez chez le banquier présenter votre projet selon les critères des banques. Ce qu'ils signifient et les critères qui les définissent appartiennent aux banques, soit une poignée de personnes qui en déclarent les règles de fonctionnement.
|
||||
|
||||
En résumé, un très petit nombre de personnes décide ce qui vaut, ce qui est valeur, et ce qui ne l'est pas. Les déchets n'ont pas de valeur, de même les activités dites sociales ou solidaires. En dessous des déchets il y a par exemple le « geste infirmier à domicile ». Tout en haut il y a le traitement massif de la numérisation de tous nos faits et gestes.
|
||||
|
||||
Si les personnes ayant besoin d'un soin à domicile déterminaient de façon proportionnelle, selon leurs échelles de valeurs, la répartition des milliards de devises créées chaque jour, ... ces devises seraient-elles réparties de la même façon ?
|
||||
|
||||
Tous les projets cherchent la conformité, les requérants jouent la scène qui leur est demandée. Fatalement les projets de petite envergure, ceux dont les modèles éco sortent des sentiers battus, passent rarement les mailles du filet.
|
||||
|
||||
> La monnaie-libre et le code génétique qu'elle embarque sont radicalement différents de la monnaie telle que nous croyons la connaître ; elle propose un référentiel relativiste qu'il nous appartient de mettre à notre service. Nous avons maintenant une liberté de choisir, à nous de la saisir.
|
||||
>
|
||||
> Mais pouvoir ne signifie pas savoir, encore nous faut-il collectivement apprendre à le faire.
|
||||
|
||||
## Une économie mal codée
|
||||
|
||||
L'analogie informatique est éclairante : notre économie est un programme dont le **code source est vicié**. Les monnaies-dette contiennent des bugs génétiques — cavalerie, faillites programmées, pyramide — qui ne sont pas des accidents mais des propriétés structurelles.
|
||||
[ *masc.* ]
|
||||
|
||||
La monnaie libre propose de **recoder** la base : une monnaie dont la création est symétrique entre tous les membres, dans l'espace et dans le temps.
|
||||
> On entend de nos jours de belles expressions qui parlent de « dette roulante », ou de « dette qui paie la dette ». Elles sont un voile pudique sur cette cavalerie insoutenable. Le résultat de cette cavalerie, de cette croissance requise effrénée, c'est une extraction de toutes les ressources, planétaires et humaines, qui prend des allures de pillage. Or si vous suivez le raisonnement, ce pillage est inéluctable.
|
||||
>
|
||||
> Le besoin monétaire est exponentiel, donc ladite croissance doit l'être, ainsi la consommation d'énergie et l'extraction de toutes les ressources.
|
||||
|
||||
> *Chaque monnaie programme sa propre engeance. Ne t'y méprends pas, son pouvoir est immense.*
|
||||
Donc tous ceux qui affirment que la croissance est obligatoire ont raison. Tant que la source monétaire qui abreuve notre économie sera une monnaie-dette.
|
||||
|
||||
C'est pour cela qu'en dépit de toutes les COPs, GIEC, les Conventions citoyennes, les intentions et les mobilisations, les chiffres parlent. Il n'y a pas le moindre début d'inflexion dans l'extraction, dans la consommation d'énergie et de matières, dans aucun domaine structurant de fait.
|
||||
|
||||
Heureusement localement, il y a d'innombrables expériences et initiatives magnifiques. Dans une vue satellitaire elles sont marginales, mais dans le paysage elles sont vitales.
|
||||
|
||||
Pour conclure, si l'on veut mener une réflexion sur la décroissance, sur la sobriété, la contraction, la robustesse non performante, il faut changer de création monétaire. La bonne nouvelle, c'est que la TRM ou la monnaie-libre, propose une, on ne peut plus compatible avec ces réflexions.
|
||||
|
||||
> Il s'agit d'embarquer dans une aventure,
|
||||
> peut-être encore marginale
|
||||
> mais stratégiquement valable,
|
||||
> peut-être laborieuse
|
||||
> mais éventuellement ludique,
|
||||
> peut-être utopique
|
||||
> mais, *sans doute*, significative.
|
||||
|
||||
Quelle part de nos besoins, quelle part de notre économie pourrait couvrir une production sans plus vraiment d'extraction, sans plus vraiment de « laissés pour compte » ? Jusqu'où peut-on pousser les curseurs ?
|
||||
|
||||
+151
-25
@@ -1,51 +1,177 @@
|
||||
---
|
||||
title: "La TRM"
|
||||
description: "Invitation à découvrir la Théorie Relative de la Monnaie de Stéphane Laborde : symétrie, DU et relativité."
|
||||
description: "Corréler directement le flux monétaire au flux de la vie humaine — les principes et raisonnements de la Théorie Relative de la Monnaie."
|
||||
order: 5
|
||||
readingTime: "30 min"
|
||||
page: 83
|
||||
readingTime: "22 min"
|
||||
---
|
||||
|
||||
## Invitation à la TRM
|
||||
## La TRM – Théorie Relative de la monnaie
|
||||
|
||||
Ce chapitre est une **invitation à lire la Théorie Relative de la Monnaie** (TRM) de Stéphane Laborde. Pas un résumé — la TRM se mérite, se travaille, se digère. Mais quelques clés pour en saisir la portée.
|
||||
*[ fém. ]*
|
||||
|
||||
## Le flux monétaire
|
||||
Si l'on veut comprendre la monnaie libre, il est utile de comprendre les principes et raisonnements de la TRM.
|
||||
|
||||
En monnaie libre, le flux monétaire est **corrélé à l'existence physique** de chaque être humain. Tant que tu vis, tu co-crées de la monnaie. Le Dividende Universel (DU) est cette part de création, identique pour chaque membre, chaque jour.
|
||||
[ Page de Stéphane Laborde : https://trm.creationmonetaire.info/ ]
|
||||
|
||||
## La symétrie spatio-temporelle
|
||||
Je ne parle pas des démonstrations mathématiques, heureusement pour le grand nombre. Mais les postulats, leur positionnement, les questionnements d'origine et la nature des réponses qu'ils apportent.
|
||||
|
||||
Le principe fondamental de la TRM tient en deux symétries :
|
||||
Cet essai est directement lié à ma lecture de cette théorie, elle y est présente partout. Je vous en restitue donc une partie pour que vous puissiez suivre ce livre sans avoir lu la TRM.
|
||||
|
||||
- **Symétrie spatiale** : aucun individu vivant n'est privilégié par rapport à un autre dans la création monétaire
|
||||
- **Symétrie temporelle** : les individus présents ne sont pas privilégiés par rapport aux individus futurs (ni l'inverse)
|
||||
---
|
||||
|
||||
Ces deux symétries éliminent les biais génétiques des monnaies-dette : plus de pyramide, plus de privilège d'ancienneté.
|
||||
## Flux monétaire et vie humaine
|
||||
|
||||
## La formule du DU
|
||||
*[ masc. ]*
|
||||
|
||||
Le DU se calcule relativement à la masse monétaire existante et au nombre de membres. La formule garantit que, quels que soient le moment d'entrée et la durée de participation, chaque membre converge vers la **même part relative** de la masse monétaire.
|
||||
L'un des fondements de la TRM :
|
||||
|
||||
## Relativité : penser en M/N
|
||||
> Corréler directement le flux monétaire au flux de la vie humaine.
|
||||
|
||||
En monnaie libre, les montants absolus n'ont pas de sens. Ce qui compte, c'est la **part relative** : combien de DU ? Quel pourcentage de la masse monétaire moyenne par membre (M/N) ?
|
||||
L'image est belle et ce n'est pas qu'une image.
|
||||
|
||||
Cette relativité est contre-intuitive au début, mais libératrice : elle élimine le biais de genèse (les premiers arrivés ne sont pas structurellement plus riches) et le biais d'ancienneté.
|
||||
La création monétaire repose ici sur l'existence physique d'un individu. La monnaie que l'individu crée apparaît avec sa naissance dans son bassin éco. De la même façon, cette monnaie créée meurt avec lui, ou lorsqu'il décide de quitter la monnaie et son économie.
|
||||
|
||||
## GrateWizard
|
||||
Une propriété mathématique conséquente de la TRM est la « convergence à la moyenne ». En ajoutant une quantité d'unités chaque jour (le DU quotidien), la même pour tout le monde, tous les créateurs monétaires tendent vers le même nombre de DUs. Si vous êtes en-dessous de la moyenne, le nombre de vos DUs augmente ; au-dessus, il diminue. De façon asymptotique, magique mathématique.
|
||||
|
||||
L'outil **GrateWizard** permet de simuler et visualiser les flux monétaires en monnaie libre. Il donne à voir la convergence, les échanges, les équilibres — et aide à développer l'intuition de la relativité.
|
||||
La réflexion immédiate qui vient à l'esprit concerne « L'inflation ». Il n'y a pas une mais deux familles d'inflation bien distinctes : une inflation économique (pénurie d'un produit ou de ce qui permet de le produire) et une inflation monétaire (surabondance de monnaie dans l'économie).
|
||||
|
||||
## Volume des offres et diversité
|
||||
Du point de vue relativiste dans la monnaie libre, le nombre de DUs finit par atteindre un plafond asymptotique dès que N se stabilise. Dans le référentiel relativiste, il n'y a pas d'inflation monétaire possible.
|
||||
|
||||
Une monnaie ne vaut que par ce qu'on peut obtenir avec. Le **volume des offres** et leur **diversité** sont les indicateurs clés de la santé d'une économie en monnaie libre. Plus il y a de producteurs, de services, de biens disponibles, plus la monnaie fait sens.
|
||||
> Or c'est justement ce que fait précisément la monnaie libre. Il y a une création monétaire continue, corrélée aux flux de toutes les vies en cours, répartie également sur chaque personne. Dans le référentiel relativiste, il n'y a pas d'inflation monétaire possible.
|
||||
|
||||
## Échelles de valeurs et convergence
|
||||
Et si vous faites l'impasse sur les démonstrations, retenez simplement qu'en comptant en DUs, vous voyez que l'inflation ne peut plus être une question monétaire ; vous voyez que la quantité monétaire créée par un individu naît et meurt avec lui, telle une petite vague qui passe.
|
||||
|
||||
Chacun a ses propres échelles de valeurs. La monnaie libre ne les uniformise pas : elle fournit un **référentiel commun** (le DU) qui permet à chacun d'exprimer ses valeurs et de les échanger. Avec le temps, les prix relatifs convergent vers une moyenne, signe que l'économie trouve ses équilibres.
|
||||
C'est – vraiment – beau.
|
||||
|
||||
## Le commun monétaire
|
||||
*Pointe une petite larme ? Loin d'être petite, l'arme.*
|
||||
|
||||
La monnaie libre est un **commun** : elle n'appartient à personne et à tout le monde. Comme l'air ou l'eau, elle est un bien partagé dont chacun est co-producteur. Ce statut de commun est garanti par la symétrie de sa création.
|
||||
---
|
||||
|
||||
> *Le DU est la part de chacun. Ni plus, ni moins.*
|
||||
## Symétrie dans l'espace-temps
|
||||
|
||||
*[ fém. ]*
|
||||
|
||||
Personne en aucun lieu ni aucune époque ne peut créer de la monnaie au détriment de l'accès monétaire d'une autre personne, de tout lieu et de toute époque. Une belle interprétation d'un leitmotiv de John Locke, celui de la non nuisance.
|
||||
|
||||
Dans la recherche originelle de Stéphane Laborde, en bon ingénieur, il y avait en premier lieu la recherche d'une mesure invariante. Il pose l'économie en tant que champ en expansion, or la mesure de ce champ ne cesse de fluctuer. Est-il possible de trouver un étalon, une unité de mesure qui ait la même valeur pour toutes, une mesure universelle ?
|
||||
|
||||
Cela ne peut pas être le cas de l'or. Dans le champ de l'économie, les référentiels ce sont les gens, nous toutes. Il s'agit donc de trouver la même unité de mesure pour moi ou pour une Papoue de Nouvelle-Guinée, pour une Inuit du 41e siècle. Cet étalon universel ne peut être aucune des valeurs produites ou extraites par l'économie elle-même. Il ne peut donc s'agir que d'un nombre.
|
||||
|
||||
> La seule valeur tangible, universelle, et seule valeur qu'on ne puisse pas totalement enlever d'une « zone économique » sans que cette économie s'éteigne immédiatement, c'est … la vie humaine.
|
||||
|
||||
Pour que le flux monétaire suive celui de notre vie, son taux de croissance doit être corrélé à la croissance de l'individu. D'où une formule logarithmique sur l'espérance de vie (ev=80), qui donne un taux de croissance proche de 10% annuel.
|
||||
|
||||
Bingo, c'est la naissance du Dividende Universel, le DU.
|
||||
|
||||
> - Créer soi-même l'unité de mesure de l'économie et toutes nos valeurs, pour manipuler et partager un invariant.
|
||||
> - Créer soi-même sa part de monnaie, qui suit le flux de sa propre vie, égale à toute autre sur un principe de non-nuisance.
|
||||
>
|
||||
> C'est la même chose, la même formule, la formule du DU !
|
||||
|
||||
Plutôt génial non ?
|
||||
|
||||
[ Formule du DUğ1 : DU*t+1* = DU*t* + c² × (M/N)*t* / 182.625 ]
|
||||
|
||||
[ 5, 48 % / *an* ≤ c ≤ 9, 22 % / *an*, selon que l'on prenne ev ou ev/2 pour symétrie ]
|
||||
|
||||
*Cela tombe bien : « ex perfecto nihil fit ».*
|
||||
*[ de la perfection rien ne naît – formule alchimiste ]*
|
||||
|
||||
---
|
||||
|
||||
## Relativité
|
||||
|
||||
*[ masc. ]*
|
||||
|
||||
Que peut m'inspirer une unité de mesure universelle relativiste pour estimer les valeurs ? Je peux mesurer toutes les valeurs de façon relative — relativement à la masse monétaire créée dans mon bassin éco, « M » ; et au nombre de personnes « N » qui créent cette monnaie. Estimer les valeurs par rapport à la moyenne « M/N » est un premier repère très intéressant.
|
||||
|
||||
Une observation terrain dévoile rapidement un biais de genèse : le gros décalage monétaire entre les personnes qui créent leur DU depuis plus de 8 ans et ceux qui viennent de commencer. C'est paradoxal, car la monnaie-libre fait la promesse d'une « égalité ». Mais c'est une égalité d'accès à la création monétaire, pas une égalité de richesse.
|
||||
|
||||
De plus, la richesse n'est pas monétaire ; on s'en rend compte dès que l'on quitte le référentiel de la monnaie-dette. Dans celui de la TRM, ce n'est plus la modalité de création monétaire elle-même qui crée les écarts. C'est dans les économies que nous créerons, par exemple une économie du don, que les écarts peuvent être réduits, sans commune mesure.
|
||||
|
||||
---
|
||||
|
||||
## Ancienneté
|
||||
|
||||
*[ fém. ]*
|
||||
|
||||
Pour corriger ce biais de genèse dans les échanges eux-mêmes, on peut appliquer un *coefficient relatif à l'ancienneté*. Une simple formule qui baisse le « prix » pour les nouvelles créatrices et l'augmente pour les plus anciennes, avec un effet qui s'amenuise dans le temps.
|
||||
|
||||
J'ai commencé à produire un petit utilitaire pour manipuler facilement ce coefficient. Il suffit de renseigner une valeur de référence pour soi-même et un pourcentage de réduction pour une débutante. L'utilitaire donne alors la valeur relative pour toutes les anciennetés.
|
||||
|
||||
[ *GrateWizard* : https://pricewizard.vercel.app ]
|
||||
|
||||
Ce coefficient ne fait aucun jugement de valeur sur le comportement économique de l'une ou l'autre partie. C'est un correctif mathématique d'un biais de genèse mathématique, qui s'opère uniquement dans les échanges avec le consentement des parties.
|
||||
|
||||
---
|
||||
|
||||
## Volume des offres
|
||||
|
||||
*[ masc. ]*
|
||||
|
||||
Une autre observation terrain : les offres sont réduites en variété et en volume. Pour un problème de volumes limités, on peut estimer les valeurs relativement aux quantités produites. Pratiquer une valeur de référence qui augmente avec le volume reçu — inversion de flux. Prendre trop de volume peut constituer une nuisance à l'autre menacé de pénurie ; on ne l'empêche pas, mais on le régule de façon organique. Cette inversion de flux est très symbolique.
|
||||
|
||||
Beaucoup déplorent les déséquilibres entre les personnes qui fournissent beaucoup et d'autres qui « se contentent de consommer ». Nous mettons immédiatement les pieds dans les jugements de valeur moraux. Mais si l'on est réaliste, on sait que c'est inévitable, donc voyons comment réduire ce recours.
|
||||
|
||||
---
|
||||
|
||||
## Diversité des offres
|
||||
|
||||
*[ fém. ]*
|
||||
|
||||
Sur les personnes *« qui ne font rien pour recevoir des DUs »*, j'évite de juger et je cherche des vecteurs d'attraction qui déclencheraient le mouvement.
|
||||
|
||||
Il peut s'agir initialement d'un produit ou d'un service dont tout le monde fait l'usage. Si mon économie fournit de la bière parce qu'elle aura trouvé le moyen de la produire, alors tout le monde pourra se dire « je peux attraper de la bière artisanale » avec des DUs. Disposer de telles valeurs dans un bassin de vie changerait la donne. Mais à nouveau cela commence par une production.
|
||||
|
||||
En attendant, on peut imaginer un coefficient relatif au solde. L'idée : soustraire le nombre de DUs créés de mon solde. S'il reste positif, c'est que j'ai reçu plus de DUs qu'il n'en valorise mes offres reçues ; j'ajuste donc le degré de gratitude en l'augmentant pour les soldes négatifs et en le diminuant pour les soldes positifs.
|
||||
|
||||
Coefficients relatifs à la moyenne monétaire, à l'ancienneté, progressifs au volume, relatifs au solde, à la pénibilité, … à tout ce qui vous semble juste. C'est ainsi qu'une économie peut remodeler nos échelles de valeur collectives.
|
||||
|
||||
Les vices de notre économie montrent à quel point il est illusoire de vouloir rectifier notre économie euro. Ces leçons donnent donc de bonnes raisons de vouloir en créer une autre. La question n'est pas tant pourquoi se mobiliser, elle est davantage :
|
||||
|
||||
> « qui va le faire ? »
|
||||
|
||||
---
|
||||
|
||||
## Échelles de valeurs
|
||||
|
||||
*[ masc. ]*
|
||||
|
||||
La question n'est pas tant de considérer un principe puis de dire qu'on y tient beaucoup. Il s'agit davantage, dans un contexte vécu, de positionner ce qui passe devant.
|
||||
|
||||
Dès que l'on compte en euros, on navigue dans des chiffres et des ordres de grandeur qui ne nous appartiennent pas. Si l'on trouve qu'un produit est trop cher, c'est évidemment dans une lecture des prix du marché, et non de la valeur que le produit devrait avoir par rapport aux autres, au travail qu'il représente, ni même souvent au besoin que j'en ai.
|
||||
|
||||
L'exercice de construire nos propres échelles de valeurs peut paraître difficile, mais il peut devenir amusant. Naturellement nous sommes tous paumés dès que l'on ne compte plus en euros, parce que l'on perd notre référentiel collectif. Mais c'est justement cela qui nous permet de les reconstruire, cette fois-ci de façon totalement décentralisée, sur chaque bassin de vie.
|
||||
|
||||
C'est en cela que le DU devient une sorte de condition car c'est notre invariant. Il aura toujours la même valeur relative pour tout le monde. Il nous permet d'avoir un référentiel commun tout en naviguant dans des milliards d'échelles de valeurs différentes.
|
||||
|
||||
> Chercher à constituer une seule communauté est une illusion, c'est même selon moi une grosse erreur stratégique. Au contraire, il me semble plus fertile de multiplier les groupes locaux par affinité et proximité, de créer donc « recruter » des équipes ; puis entretenir des relations avec ses groupes voisins et d'autres plus lointains, cultiver des protocoles.
|
||||
|
||||
Dès que j'affecte un DU, je signifie une valeur aussi simple soit-elle, cela devient un geste d'investissement, je construis une économie.
|
||||
|
||||
---
|
||||
|
||||
## Convergence à la moyenne
|
||||
|
||||
*[ fém. ]*
|
||||
|
||||
Imaginez 2 personnes, l'une avec 1.000, l'autre avec 9.000. L'écart est énorme. En ajoutant 1.000 aux deux, ça donne 2.000 et 10.000. L'autre n'a plus que 5 fois plus. Vous recommencez ; l'écart tombe à facteur 3 (4k contre 12k) ; 4 coups plus tard, l'autre n'a plus que 2 fois plus (8k contre 16k). Ainsi de suite, elles finissent par se rejoindre à la moyenne.
|
||||
|
||||
Pas besoin de déshabiller Jacques pour habiller Paul, pas besoin de prélever les uns pour établir des équilibres avec les autres. Plutôt bouleversant par rapport à nos schémas quantitatifs de redistribution monétaire, non ?
|
||||
|
||||
Lorsque le nombre de créateurs monétaires sera stable, tout le monde tendra vers une moyenne qui oscille entre 3.742 et 3.925 DUs. Au jour où j'écris ces lignes, la moyenne est à moins de 1.300 DUs.
|
||||
|
||||
En contrepartie, j'ai la garantie de ne jamais totalement manquer dans la durée. Dans les passes difficiles, je sais pouvoir compter sur une ressource mutualisée par mon économie, car mon DU me permet de valoriser quelques dons reçus. Un petit espace vital sécurisé, pour peu que cette économie soit suffisamment développée.
|
||||
|
||||
---
|
||||
|
||||
## Commun monétaire
|
||||
|
||||
*[ masc. ]*
|
||||
|
||||
> Considérer notre création monétaire comme un commun donne une bonne résonance au terme Dividende Universel, car il suggère le fait d'être tous co-propriétaires à part égale relative du capital monétaire de notre bassin de vie.
|
||||
|
||||
Faire des petites projections, mettre un peu de chiffres sur son bassin de vie, est très éclairant. Dans le mien par exemple, un premier jalon repère à 1.024 créateurs monétaires pour une population de 61.444 personnes donnerait un capital commun monétaire de près de 4 millions de DUs. Que pourrait-il couvrir, quelle économie peut-il alimenter ?
|
||||
|
||||
Aujourd'hui nous sommes approximativement 150, avec une moyenne au doigt mouillé de 800 DUs dans la vallée, soit 120.000 DUs. Par quoi on commence ?
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: "Créer une économie ?"
|
||||
description: "Passer de la théorie à la pratique : produire, greffer une économie du don sur le tissu local, inverser les flux."
|
||||
order: 6
|
||||
readingTime: "25 min"
|
||||
---
|
||||
|
||||
## Définition
|
||||
|
||||
Couvrir les besoins, pour vivre, nourrir les plaisirs de vivre. Avant tout : comment **produire**, comment **distribuer**, sans nuire. On en a déjà une économie. Alors pourquoi en créer une autre ? Pas « créer » au sens de partir de zéro — plutôt **greffer** une économie du don sur l'économie existante.
|
||||
|
||||
## Produire avant tout
|
||||
|
||||
Le message aux pionniers est clair : faire tourner la monnaie en rond, ce n'est pas créer. Se faire des virements autour d'une table, c'est juste du vent. L'équation de Fischer est formelle : si tu multiplies zéro production par mille transactions, ça fait toujours zéro. L'économie, c'est d'abord **produire et transformer**.
|
||||
|
||||
## Passer la seconde
|
||||
|
||||
L'analogie du régime moteur : la monnaie libre en est au « point mort ». Pour avancer, il faut **passer la seconde** — produire de vrais biens et services. Cinq vitesses de développement se dessinent, de la simple circulation monétaire à l'économie productive complète.
|
||||
|
||||
## L'économie de greffe
|
||||
|
||||
Le principe fondamental : **deux économies cohabitent**, l'économie classique (euros) et l'économie du don (monnaie libre). Il ne faut surtout pas les mélanger ni les confondre. On ne « quitte » pas l'euro — on développe une autonomie parallèle, progressive. La greffe prend quand l'économie locale du don couvre suffisamment de besoins réels.
|
||||
|
||||
## Connaître son bassin de vie
|
||||
|
||||
Avant de se lancer, il faut cartographier son territoire. Deux indicateurs clés :
|
||||
- Les **mobiz** : les « monnaie-libristes biz » — ceux qui produisent et échangent activement
|
||||
- Les **sherpiz** : les « sherpas biz » — ceux qui guident, forment, accompagnent
|
||||
|
||||
Le ratio mobiz/sherpiz sur un territoire donne une idée réaliste du potentiel de développement.
|
||||
|
||||
## La gestion à l'anglaise
|
||||
|
||||
Un concept emprunté à la mécanique : la **roue libre**. En monnaie libre, la « roue libre » est l'indicateur de rentabilité. Quand une activité tourne sans effort, en roue libre, c'est qu'elle est rentable en DU. Ce n'est pas la marge financière qui compte, c'est la **soutenabilité** du geste productif.
|
||||
|
||||
## L'économie de flux inversés
|
||||
|
||||
Le retournement fondamental : **je donne avant de recevoir**. En économie classique, le flux est conditionnel — je te donne si tu me payes. En économie du don, je donne d'abord, et la mesure (en DU) vient après, comme une gratitude.
|
||||
|
||||
Ce n'est pas de la naïveté : c'est un changement de posture qui transforme la relation économique. Le don est en amont. L'équipe offre le choix, le lieu, le son. Les références sont posées. Tu prends ou pas, tu gratifies selon ton estimation.
|
||||
|
||||
> *L'économie, c'est de l'énergie, de la chaleur c'est sûr. Je grave ma gratitude dans la chaîne.*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user