79 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 90f77da49b actes : mise en ligne og + favicon (armé par Yvv)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:52:31 +02:00
YvvandClaude Opus 5.5 74ae8c2666 og : image de partage et favicon avec le vrai §, spaghetti retiré
- og-default.png refaite (§ Playfair, Bricolage, trois autonomies en couleurs)
- favicon.ico : § (c'était le logo Nuxt par défaut)
- JSON-LD : logo pointait vers /images/og-default.png (404) → /og-default.png
- ?v=2 sur l'og:image pour que les réseaux sociaux la reprennent
- retrait des trois tracés « spaghetti » orphelins de public/images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:52:31 +02:00
YvvandClaude Opus 5.5 2144132c74 actes : mise en ligne de la refonte — exécutée et vérifiée en production
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:06:49 +02:00
YvvandClaude Opus 5.5 67cd869d83 actes : mise en ligne de la refonte (armé par Yvv)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:44:28 +02:00
YvvandClaude Opus 5.5 201cfbbb50 design : accueil console seul, vrai §, curseurs animés, statuts, lecteur
- accueil : la console devient l'accueil unique (variantes B/C, sélecteur
  et ancien accueil retirés) ; curseurs qui montent de l'asservissement et
  dérivent seuls, un axe mis en avant à tour de rôle, arrêt au clic,
  immobiles en prefers-reduced-motion ; « AUTONOMIE » devant chaque axe,
  leviers mis en valeur, ligne des trois autonomies en couleurs
- logo : § de Playfair Display (SIL OFL) vectorisé, composant partagé
  UiSectionSign (en-tête, page événement) + favicon.svg
- UiStatusBadge (production / développement / gestation) sur l'accueil,
  les pages d'axe et les pages projet
- liens libreDecision / SejeteralO fixés sur la prod (plus de repli localhost)
- logiciel libre : bouton v1 dans la carte projet en tête de page
- couverture du livre : recadrage corrigé (BookSection)
- texte courant en Public Sans (zéros non barrés)
- lecteur : la pastille persistante ne masque plus la navigation du
  BookPlayer ; lecture directe du MP3 (les .ogg n'existent pas : 404)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 05:59:33 +02:00
YvvandClaude Opus 5.5 4d8fe4895d contenus : LaPlanK, sweetHome, statuts de production, sous-titre événement
- LPK → LaPlanK partout ; sweethomeCloud → sweetHome partout
- statuts : Monnaie libre en production depuis 2017 ; wishBounty,
  Décision collective, Tarifs de l'eau en développement ; v1 wishBounty
  en production pour l'écosystème Duniter ; « bridée » retiré
- accueil : « Le librodrome est une invitation », « … une boîte à outils » ;
  échelle de la console Asservissement → Émancipation
- sweetHome clé en main pour chaque collectif, association ou entreprise
  coopérative ; page Le Librodrome : « Un format d'événement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 05:59:33 +02:00
YvvandClaude Opus 5.5 e9fce4be8f design : accueil en trois variantes (console, parcours, établi) + couleur par axe
- typo : Bricolage Grotesque (titres) + Atkinson Hyperlegible (texte),
  Syne gardée pour le logo seul ; une seule source de polices (Bunny)
- palettes : teinte propre à chaque axe dans les 4 ambiances ;
  pages d'axe en axis-scope, cartes sans bordure (canon design)
- accueil : console des trois curseurs (tablist clavier), boîte à outils,
  bloc format éditorial + événement + reproduction ; ?v=a|b|c|0
- numérique : carte LPK ; bouquet rendu avec statut et légende
- messages : sans bordure, deux colonnes en grand écran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:15:46 +02:00
YvvandClaude Opus 5.5 c3ada04e52 contenus : LPK sert l'axe numérique, bouquet réaligné sur les stacks HCO/LPK
- site.yml : bloc lpk (La Plank, asso 1901, financement des communs numériques)
- home.yml : bloc format + une ligne d'engagement par axe
- cloud-libre.yml : bouquet en objets {rôle, logiciel, statut, lien} —
  en service / au catalogue / à venir ; OpenCloud, Twenty, ERPNext,
  Vaultwarden, Woodpecker, Kubo… ; aucune ligne d'origine perdue ;
  équivalents « Gestion & organisation » ajoutés
- evenement.yml : Nextcloud → OpenCloud dans l'axe numérique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:15:46 +02:00
YvvandClaude Fable 5 38467bea10 build : packageManager épinglé + approbation des scripts de build en CI
- le pnpm choisi par corepack dans l'image Docker ignorait l'allowlist
  pnpm.onlyBuiltDependencies de package.json (les pnpm 10 récents la
  lisent dans pnpm-workspace.yaml) → ERR_PNPM_IGNORED_BUILDS, exit 1
- packageManager: pnpm@10.29.3 (déterministe, identique au poste local)
- pnpm-workspace.yaml : onlyBuiltDependencies (@parcel/watcher,
  better-sqlite3, esbuild, sharp)
- vérifié : install à froid CI=true --frozen-lockfile exit 0, build zéro
  erreur, prod :3099 → 200

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:54:53 +02:00
YvvandClaude Fable 5 da2a8aed49 build : @yvv/nuxt-base en git+https + git dans l'étape build Docker
- la dép git+ssh échouait en CI : pas de clé SSH ni de binaire git dans
  l'étape build (ENOENT sur git clone) — seule la prod installait git
- package.json + pnpm-lock : git+https://git.open.us.org/… (#v0.1.0 →
  b1c6a2f, même commit épinglé) — nécessite le repo yvv-nuxt-base public
- git + ca-certificates ajoutés à l'étape build du Dockerfile
- vérifié : pnpm install + build zéro erreur, prod :3099 → 200

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:13:25 +02:00
YvvandClaude Opus 4.8 e64d4cf003 design(sceau): hexagramme 益 traits épais, coins nets, aspect carré
- viewBox 112×105, height 8, rx supprimé (rendu cut)
- géométrie calée sur les modèles IMAGES/PERVIER/Hexagrammes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 06:54:36 +02:00
aya 483d226c8b empty commit 2026-07-24 23:25:51 +02:00
YvvandClaude Opus 4.8 069d206e94 feat(deps): adopte @yvv/nuxt-base via dépendance git (extends relatif retiré)
extends: ['../yvv-nuxt-base'] -> ['@yvv/nuxt-base'] + dépendance
git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0.
Rend le projet autonome de l'arborescence locale (débloque Docker/CI).
Build pnpm vert, prod :3099 -> 200, smoke moods OK (vars --color-*
distinctes automne/hiver, classes mood-* appliquées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:15:44 +02:00
YvvandClaude Fable 5 5ec6474b37 feat: adoption layer yvv-nuxt-base — palette.ts délègue à useMood
- extends ../yvv-nuxt-base dans nuxt.config.ts
- application DOM (vars --color-*, palette-light/dark, color-scheme) via useMood(vars) + lightDarkClasses
- storageKey 'palette' et défaut 'ete' conservés — zéro migration utilisateur
- couleurs et API du store inchangées (currentPalette, colors, setPalette, applyToDOM)
- vérifié Playwright : 4 palettes × desktop/mobile au reload + switch à chaud via PaletteSelector, zéro erreur console, prod :3099 → 200
- CONTRIBUTING.md : mécanique déléguée documentée + blocant distribution layer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:14:38 +02:00
YvvandClaude Sonnet 4.6 98fab43fe5 feat: ajout cartouche Ğ1flux dans Productions collectives
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 01:23:49 +02:00
YvvandClaude Sonnet 4.6 a334c97434 fix: Umami externe sur stats.open.us.org — supprime docker-compose.umami.yml
- host corrigé : stats.open.us.org (instance partagée, non self-hosted)
- docker-compose.umami.yml supprimé (obsolète)
- .env.example simplifié (plus de vars self-hosted)
- README + CONTRIBUTING mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 04:02:27 +02:00
YvvandClaude Sonnet 4.6 84e843479d feat: intégration nuxt-umami + refonte logo SVG TheHeader
- nuxt-umami@3.2.1 installé, module configuré (host + website ID)
- suppression injection script manuelle dans app.vue
- TheHeader : logo § repassé en tracé SVG unique avec dégradé primary→accent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 03:39:39 +02:00
YvvandClaude Sonnet 4.6 2f444ea7c7 fix: couverture BookPresentation — object-fit contain, largeur 100%
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:57:20 +02:00
YvvandClaude Sonnet 4.6 8408fd6466 feat: SEO complet + analytics Umami + og:image § logo
SEO :
- composable useSeoPage() : og:*, Twitter Cards, canonical sur toutes les pages (15 pages)
- app.vue : JSON-LD Organization + Book, og:image global og-default.png
- og-default.png 1200×630 : logo § calligraphique + texte (Pillow)
- nuxt.config.ts : @nuxtjs/sitemap avec 26 URLs statiques

Analytics Umami :
- useTracking() : helpers typés audio/pdf/player/scroll/cta
- useScrollTracking() : scroll depth 25/50/75/100% + liens externes auto
- useAudioPlayer : trackAudioPlay/Progress/Complete
- BookPdfReader : trackPdfOpen/Close avec durée
- BookPlayer : trackPlayerOpen/Chapter/Mode
- docker-compose : variables NUXT_PUBLIC_UMAMI_* passées au container

Images :
- Couv-Economie-du-don.jpg ajoutée dans public/images/
- bookplayer.config.yml + home.yml : références mises à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:25:28 +02:00
YvvandClaude Sonnet 4.6 dcf64cc924 fix: corrections lecteur PDF + couverture + navigation chapitres
- PDF viewer : suppression animation/lock isAnimating, navigation stable
- PDF reader : focus iframe au chargement → flèches actives immédiatement
- BookSection : couverture via background-image (right center) — fiable
- AxisBlock : boutons secondaires NuxtLink/button explicites (v-if/v-else)
- modele-eco/[slug] : scroll top au changement de chapitre (SPA reuse)
- router.options.ts : scrollBehavior global top/instant
- PDF mis à jour (numéros de pages chapitres 7–11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 05:12:37 +02:00
YvvandClaude Sonnet 4.6 f6339400fa seed messagerie : message d'embarquement persisté en git
- site/messages.yml : seed officielle avec le message premier embarquement
- server/plugins/seed-messages.ts : init data/messages.yml au démarrage si absent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:33:06 +01:00
YvvandClaude Sonnet 4.6 8fd000a153 numerique/[slug] : bouton v1 bridée sur la page logiciel-libre
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:18:41 +01:00
YvvandClaude Sonnet 4.6 95f82e4fee Numérique : blocs cliquables + bouton v1 bridée logiciel libre
- numerique/index : overlay z-index 1 sur pillar-card, zone actions z-index 2
  avec support pillar.actions (même pattern que citoyenne/index)
- home.yml + numerique.yml : bouton "Voir la v1 bridée (premier flux)"
  sur le bloc Logiciel libre → https://axiom-team.fr/dons
- economique/index : déjà des NuxtLink directs comme cartes, rien à corriger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:01:53 +01:00
YvvandClaude Sonnet 4.6 a038851895 Home/citoyenne : blocs entièrement cliquables + SejeteralO dans tarifs eau
- AxisBlock : overlay z-index 1 (au-dessus du contenu), actions z-index 2
  (boutons au-dessus de l'overlay) — fix blocage du clic sur tout le bloc
- citoyenne/index : même correction overlay/z-index ; suppression des
  NuxtLink "En savoir plus" devenus redondants (overlay gère la navigation)
- home.yml : ajout bouton "Ouvrir SejeteralO" sur le bloc Tarifs de l'eau

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:12:39 +01:00
YvvandClaude Sonnet 4.6 efed0b9033 Page événement : contenu structuré axes/espaces/config depuis PDF Genèse
- evenement.yml : kicker, titre, subtitle, leitmotiv, tagline, gestation,
  description, 3 axes (numérique/économique/politique), 6 espaces, 4 config
- evenement.vue : hero complet (shadoks, logo SVG inline, badges), sections
  axes/espaces/config, styles scoped responsive
- bookplayer.config.yml → slugs 06-produire/07-echanger déjà commités
- Ajout Librodrome-Logo.png + librodrome-logo.svg (vectorisation en cours)
- Ajout PDF genèse en public/pdf/
- .gitignore, CLAUDE.md, BookSection, économique : ajustements session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 04:08:00 +01:00
YvvandClaude Sonnet 4.6 99a8b84164 Vouvoiement, libreDecision : bouton + URL
- Vouvoiement partout dans les pages numerique (cloud-libre, logiciel-libre, wot)
- Bouton "Ouvrir libreDecision" dans home AxisBlock (Décision collective)
  - AxisAction supporte href pour liens externes
  - URL prod : decision.librodrome.org
- Labels "Ouvrir libreDecision" (sans Ğ) dans citoyenne/index et [slug]
- app.config.ts : URL prod libredecision mise à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 02:37:42 +01:00
YvvandClaude Sonnet 4.6 b9e6b4a96c Messages : types, réponses, sauts de ligne, data volume
- HomeMessages : type pill (Réaction/Question/Suggestion/Retour) + sélecteur dans le formulaire (sans Réaction)
- HomeMessages : white-space: pre-line sur les messages
- Page /messages : type pill + white-space: pre-line (idem home)
- Admin : badge type coloré + sélecteur d'édition + formulaire réponse
- API : type et reply dans PUT ; readDataYaml/writeDataYaml (data/ volume Docker)
- main.css : overrides light mode text-white/55, /75, /90

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 05:56:11 +01:00
YvvandClaude Sonnet 4.6 c52fa6007d Page /messages : afficher les réponses publiées
- Type badge + auteur + date comme sur la home
- Réponse avec liaison graphique (reply-thread/connector/block)
- Style adaptatif light/dark cohérent avec HomeMessages.vue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 05:41:24 +01:00
YvvandClaude Sonnet 4.6 d4ff840e13 bookplayer.config.yml : slugs 06-produire et 07-echanger
- chapterSongs et chapterPages : 06-economie → 06-produire, 07-echange → 07-echanger
- Les liens "Lire dans le PDF" depuis les chapitres 6 et 7 s'ouvrent maintenant sur les bonnes pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:31:55 +01:00
YvvandClaude Sonnet 4.6 7691cc4139 Chapitres condensés, renommages, numéros de page
- 06-economie → 06-produire (titre "Produire"), 07-echange → 07-echanger
- Frontmatter : champ page: ajouté sur les 11 chapitres (p.9 à p.199)
- content.config.ts : page: z.number().optional() dans le schéma
- modele-eco/index.vue : numéro de page affiché sur chaque carte, hero réduit
- 11 chapitres condensés à ~moitié : voix de l'auteur conservée,
  répétitions et transitions secondaires supprimées, concepts clés préservés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:28:09 +01:00
YvvandClaude Sonnet 4.6 088333e4d4 Monnaie libre : réécriture dans la voix du livre, liens forums, purge co-créer
- monnaie-libre.yml : texte entièrement réécrit (cavalerie, double symétrie, DU,
  toile de confiance, mesure de gratitude) + 3 nouveaux liens (duniter.org,
  forum.duniter.org, forum.monnaie-libre.fr)
- Suppression de "co-créer/co-créée/co-création" dans tous les fichiers :
  economique.yml, home.yml, authentification-wot.yml, cloud-libre.yml,
  content/book/05-trm.md, content/book/11-annexes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:51:28 +01:00
YvvandClaude Sonnet 4.6 07449de187 Pages détail numérique : sommaire flottant, nav ctx, shadoks geek, contenu enrichi
- [slug].vue : sommaire sticky (overflow:clip sur parent), prev/next en haut, 6 shadoks geek (pinguin+USB, web-of-trust, rubber-duck, caféine, debugger loupe, rack serveur)
- Nouveaux types de sections : territoire (bouquet sweethomeCloud, 2 modèles éco, tableau matériel dépliable), projet (carte gestation)
- cloud-libre.yml : section sweethomeCloud complète avec infra 50 000 hab. (~2€/an/hab)
- authentification-wot.yml : trustWallet, correction WoT Duniter (Ed25519+Scrypt, sigQty=5, stepMax=3), DID/VC standards
- logiciel-libre.yml : carte projet wishBounty
- home.yml + numerique.yml : cloud-libre → sweethomeCloud, description RGPD/local-first
- AxisBlock.vue : bulles de présentation inline dans les cards (plus de tooltip absolu)
- Analytics : useTracking.ts (Umami), docker-compose.umami.yml, /api/stats fédération
- nuxt.config.ts : config Umami runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 04:40:48 +01:00
YvvandClaude Opus 4.6 9d92c4a5b3 Fix typos blanches admin lightmode + hero audience
- Remplace color:white → hsl(var(--color-text)) dans tous les composants admin
  (AdminFieldText, AdminFieldTextarea, AdminFormSection, AdminMarkdownEditor,
  AdminMediaBrowser, AdminSidebar, book/index, book/[slug], login, messages, site, songs)
- Conserve color:white uniquement sur fond primary (AdminSaveButton, login-btn)
- Hero home : ajout bloc audience/addressees (clé distincte pour éviter conflit YAML)
- home.yml : réordonne axes (citoyenne en premier — effet triangle)
- TypewriterText : affiche le second bloc avec séparateur fin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:08:03 +01:00
YvvandClaude Opus 4.6 8f548afb17 Palette dynamique dans BookPlayer et admin
- BookPlayer : toutes les couleurs HSL en dur remplacées par variables CSS palette
- Admin (sidebar, formulaires, pages, book, songs, messages, media, login) : idem
- L'ambiance graphique suit maintenant la palette active partout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:41:23 +01:00
YvvandClaude Opus 4.6 9caf11c8ab Restructuration sections, contenu administrable, shadoks, palette été
- Structure par section : /numerique, /economique, /citoyenne (plus de /gestation)
- Chaque section a index + sous-pages avec contenu YAML administrable
- API content supporte les chemins imbriqués ([...path])
- Admin : liste des pages + éditeur par section
- Page /economique : monnaie libre (picto Ğ1), modèle éco, productions collectives, commande livre
- Page /citoyenne : decision (CTA Glibredecision), tarifs-eau (CTA SejeteralO)
- BookActions : composant partagé (player, PDF, chapitres, commande) sur home, eco et modele-eco
- GrateWizard remonté dans la section économique de la home
- Palette été par défaut, choix persisté en localStorage
- Fix lisibilité été (text-white/65 + variables CSS)
- Shadoks thématiques sur toutes les pages (8-10 par page, métiers variés)
- Redirections 301 : /gestation/*, /modele-eco/*, /decision, /lire/*
- README, CONTRIBUTING, CLAUDE.md mis à jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:13:46 +01:00
YvvandClaude Opus 4.6 c564e7be5f GrateWizard bloc dédié, messagerie libre, page numérique 3 piliers
- GrateWizard : lancement URL simple (plus de popup embed), bloc
  dédié violet sur la home entre axes et événement
- Messagerie : plus de champs obligatoires, plus de champ email
  séparé, hint email dans le message, remerciement onboarding
- Page /numerique : 3 piliers (Logiciel libre, WoT, Cloud libre)
  avec projets associés, remplace les extraits livre hors-sujet
- Admin : carte Messages ajoutée au dashboard
- Safelist icônes complétée

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:06:48 +01:00
YvvandClaude Opus 4.6 3a5c40a886 Décision : picto gavel titre + parchemin docs, GrateWizard embed sans double scroll
- Page décision : icône gavel pour le titre, scroll-text pour Documents de référence
- GrateWizard : ouverture en mode embed (?embed=true&hideTabBar=true) avec
  scrollbars=no pour supprimer le double scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:48:52 +01:00
YvvandClaude Opus 4.6 fbc2867163 Refonte accueil : hero typo statique, axes icônes, menu italic, page numérique
- Hero : 5 lignes typographiques alternées (bold/light/accent/caps/italic),
  citations et axes dans un bloc discret dépliable
- Icônes axes : Ğ1 custom, balance (éco don), graphe (WoT), marteau (décision),
  pictos plus lumineux (glow)
- Menu : Autonomie en italique + grand, Événement majuscule
- Page /autonomie renommée /numerique avec redirect 301
- Sceau hexagramme 益 Yì dans le layout, BookSection dans /modele-eco
- Fonts Syne + Space Grotesk, dark theme éclairci
- Popup GrateWizard agrandie (480×860)
- Actions AxisBlock : primary côte à côte, secondary séparé dessous

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:34:30 +01:00
YvvandClaude Opus 4.6 082a17d09b Fix accueil : hero fade doux, icônes safelist, blocs cliquables, menu, dark fort
- Hero : réécriture composable timeout pur (plus de Transition callbacks)
  Animation fade opacity 1s très douce, lisible
- Icônes : safelist UnoCSS dans nuxt.config.ts (résout pastilles vides)
- Menu : mis à jour site.yml (Numérique/Économique/Citoyenne/Événement)
- Blocs : card entière cliquable, zone actions séparée (border-top)
- Économie du don : lié à /modele-eco (page chapitres préservée)
- Tarifs de l'eau : bouton SejeteralO (localhost:3009 / collectivites.librodrome.org)
- Dark theme fort : bg 220 12% 15%, surface 19%, surface-light 24%
- Config SejeteralO + Glibredecision dans app.config.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:08:47 +01:00
YvvandClaude Opus 4.6 97ba6dd04c Redesign accueil : grille 3 axes, hero fade/swipe, pages gestation et décision
- Hero : animation fade-in/fade-out + swipe (useTypewriter composable + TypewriterText)
- 3 axes : Autonomie numérique, économique, citoyenne (AxisBlock + AxisGrid)
- Pages gestation avec présentations (wishBounty, trustWallet, Cloud libre)
- Page /decision : plateforme Décision collective (lien Glibredecision)
- Bloc événement distinct en bas des axes
- Nav : Numérique / Économique / Citoyenne / Événement
- Dark theme éclairci (bg 7→10%, surface 12→14%)
- Suppression BookSection + GrateWizardTeaser (remplacés par AxisGrid)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:49:07 +01:00
YvvandClaude Opus 4.6 f0338cca5e Fix déroulant PDF en production : polyfills DOMMatrix + worker pdfjs
pdfjs-dist en Node.js pur (hors Vite) nécessite :
- DOMMatrix, Path2D, ImageData polyfills (pas de DOM en Node)
- pdf.worker.mjs copié dans le build (traceInclude dans nitro config)
Testé : 61 entrées retournées en mode production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:59 +01:00
YvvandClaude Opus 4.6 c6b9abf2f3 Fix chemin PDF en production pour l'API pdf-outline
En prod le PDF est dans .output/public/, pas dans public/.
Cherche dans les deux emplacements (dev et prod).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:55:14 +01:00
YvvandClaude Opus 4.6 df0409fec3 Fix déroulant sommaire PDF : onMounted au lieu de import.meta.client
Le $fetch dans import.meta.client ne se ré-exécutait pas après hydratation SSR.
onMounted garantit l'exécution côté client après le montage du composant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:42:58 +01:00
YvvandClaude Opus 4.6 1af00cc64c Admin : déroulant sommaire PDF par chapitre, transitions pages, URL GrateWizard
- Ajout API /api/admin/pdf-outline (extraction sommaire PDF côté serveur via pdfjs-dist)
- Déroulant <select> dans chaque ligne de chapitre admin avec les 61 titres/sous-titres du PDF
- Sauvegarde des associations chapitre→page PDF via config YAML
- Transition douce (fondu 1s/1.2s) pour le changement de pages dans le viewer PDF
- Correction des numéros de pages réels dans chapterPages (extraits du sommaire PDF)
- URL GrateWizard prod → gratewizard.axiom-team.fr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:54 +01:00
YvvandClaude Opus 4.6 8a38c86794 Fix prod 404 : retrait volumes git sync qui cassent le conteneur
Les volumes ../site, ../content, ../public montés dans docker-compose
écrasaient les fichiers du conteneur avec des chemins host inexistants.
Retour à la config d'origine. Le git sync sera configuré ultérieurement
quand le serveur aura un clone avec accès push.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:44:44 +01:00
YvvandClaude Opus 4.6 17f39e735d Fix build prod : postinstall tolérant + copy-pdfjs dans Dockerfile
- postinstall : test -f avant d'appeler copy-pdfjs.sh (absent pendant pnpm install Docker)
- Dockerfile : RUN copy-pdfjs.sh après COPY . . pour copier les fichiers PDF.js
- Dockerfile : COPY content/ en production pour Nuxt Content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:26:05 +01:00
YvvandClaude Opus 4.6 7ea19e2247 Viewer PDF.js mode livre avec signets, fix hydratation SSR
- Viewer PDF.js mode livre : double page côte à côte, navigation prev/next visuelle et clavier
- Panneau signets (outline) avec tout déplier/replier, highlight du spread courant
- Page 1 = couverture seule, puis paires 2-3, 4-5, etc.
- Navigation clavier : flèches, espace, Home/End
- Redimensionnement auto des canvas au resize
- Fix hydratation SSR : bookData.init() sans await dans ChapterHeader et SongBadges
- BookPdfReader : iframe vers /pdfjs/viewer.html au lieu du viewer natif
- Script postinstall pour copier pdf.min.mjs depuis node_modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:16:47 +01:00
YvvandClaude Opus 4.6 9525ed3953 Bouton PDF par chapitre, badges morceaux améliorés, PDF configurable admin, git sync admin→prod
- Bouton PDF blanc par chapitre avec numéro de page (ChapterHeader)
- Badges morceaux plus visibles (bordure, poids, hover) dans ChapterHeader et SongBadges
- PDF viewer : page cible + panneau signets ouverts par défaut (BookPdfReader)
- Config YAML : pdfFile dans book, chapterPages pour le mapping chapitre→page
- Admin book : section PDF du livre avec chemin éditable et sauvegarde
- Git sync automatique : chaque sauvegarde admin commit+push en prod (ADMIN_GIT_SYNC=true)
- Docker : git installé en prod, volumes pour .git/site/content/public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:32:38 +01:00
YvvandClaude Opus 4.6 b02368a15b Fix build prod : Pinia CJS default import crash Node 22+
Pinia résout vers pinia.prod.cjs en production, Rollup convertit
require('vue') en default import ESM invalide. Script post-build
remplace par un namespace import (* as).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:36:37 +01:00
aya 1f47533c77 Merge branch 'develop' 2026-02-27 19:40:43 +01:00
aya 07bf07a942 update app src 2026-02-27 18:38:41 +01:00
YvvandClaude Opus 4.6 e6c91fea7d Replie les paroles par défaut dans le player persistant
Toggle discret pour les afficher au besoin (usage autonome).
Gain de place dans le panel déployé.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:11:32 +01:00
YvvandClaude Opus 4.6 25bfc07b59 Fix double-fire player, navigation par morceaux, admin labels morceaux
- BookPlayer : navigation par playlist (9 morceaux) au lieu de 11 chapitres
- stopPropagation clavier → plus de saut 1→3→5
- Sommaire aligné avec titres des morceaux
- Bouton back aligné avec clavier (toujours morceau précédent)
- Admin chapitres : tags morceaux cliquables avec étoile primary
- Admin liste chapitres : badges morceaux associés
- Éditeur markdown en vue split par défaut

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:08:58 +01:00
YvvandClaude Opus 4.6 8803087e77 Fix player : plus de saut de morceaux, mode scroll par défaut, supprime toggle paginé, media → sources
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:10:12 +01:00
YvvandClaude Opus 4.6 14d3a7b3e3 Logo § restauré, couleur unie partout, palettes printemps/été plus chaudes, rectifs admin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:42:44 +01:00
YvvandClaude Opus 4.6 d8439cba0f 9 morceaux : Relativité supprimé, Créer une économie = #8, Coder la liberté = #9
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 02:21:25 +01:00
YvvandClaude Opus 4.6 0308785de9 Supprime media/ du repo, gardé en local via .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 02:14:59 +01:00
YvvandClaude Opus 4.6 52c0af4c83 10 morceaux corrigés : titres, ordre, IDs et fichiers audio renommés
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:59:07 +01:00
YvvandClaude Opus 4.6 8d9feed760 Préfixe ED-## sur les fichiers audio et config à jour
Renomme les 9 morceaux avec préfixe album ED (Économie du Don) + numéro
d'ordre conforme aux sources dans media/musiques/. Met à jour les chemins
dans bookplayer.config.yml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:10:42 +01:00
YvvandClaude Opus 4.6 922afa2763 BookPlayer affiche les paroles du morceau, plus le contenu chapitre
Le BookPlayer chargeait les .md via Nuxt Content — qui contenaient avant
les paroles par erreur. Maintenant que les .md ont le vrai contenu du
livre, le BookPlayer doit afficher les lyrics depuis bookplayer.config.yml.

Supprime queryCollection('book') du BookPlayer, remplace ContentRenderer
par un rendu HTML des paroles avec tags stylisés.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:06:11 +01:00
YvvandClaude Opus 4.6 dd1d8baf4f Fix 404 chapitres : stem Nuxt Content inclut le dossier parent
Le `chapter.stem` de Nuxt Content renvoie `book/01-introduction` et non
`01-introduction`. Extraction du slug final via `.split('/').pop()` dans
les liens et la navigation prev/next.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:46:19 +01:00
YvvandClaude Opus 4.6 2f438d9d7a Refactoring complet : contenu livre, config unique, routes, admin et light mode
- Source unique : supprime app/data/librodrome.config.yml, renomme site/ en bookplayer.config.yml
- Morceaux : renommés avec slugs lisibles, fichiers audio renommés, inversion ch2↔ch3 corrigée
- Chapitres : 11 fichiers .md réécrits avec le vrai contenu du livre (synthèse fidèle du PDF)
- Routes : /lire → /modele-eco, /ecouter → /en-musique, redirections 301
- Admin chapitres : champs structurés (titre, description, temps lecture), compteur mots
- Éditeur markdown : mode split, plein écran, support Tab, meilleur rendu aperçu
- Admin morceaux : drag & drop, ajout/suppression, gestion playlist
- Light mode : palettes printemps/été plus saturées et contrastées, teintes primary
- Raccourcis clavier player : espace, flèches gauche/droite
- Paroles : toggle supprimé, toujours visibles et scrollables
- Nouvelles pages : autonomie, evenement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:20:52 +01:00
YvvandClaude Opus 4.6 4fce862df6 Affiche le mot de passe admin sous la saisie en mode dev
Endpoint /api/admin/auth/hint (dev-only, 404 en prod via import.meta.dev).
Le hint est aussi éliminé côté client au build grâce à import.meta.dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 f5cf98ce15 Ajout CLAUDE.md : conventions projet et ports dev
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 acf66513af Fix ports dev : librodrome→3000, grateWizard→3001
Évite les conflits de ports entre projets :
- nuxt.config.ts : devServer.port fixé à 3000
- app.config.ts : URL dev grateWizard corrigée 3009→3001

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 d412975a4c Fix light mode nav invisible, logo § calligraphié, typo ronde fine
- Navigation : btn-ghost, active-class, footer, nav mobile → CSS vars adaptatifs
- Overrides light mode renforcés avec !important (scoped styles compatibles)
- btn-primary garde text-white en light mode (fond coloré)
- Logo : symbole § calligraphié SVG inline remplace lucide-book-open
- Logo text : font-display weight 300 (ronde fine) + text-gradient
- Logo SVG aussi exporté en /public/images/logo-section.svg
- Header/footer : backgrounds et bordures via CSS vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 326ae0ca77 Shadoks visibles : opacités ×3, tailles augmentées, fonds moins noirs
- Opacités Shadoks : 0.1 → 0.25-0.35 (enfin visibles)
- Tailles SVG augmentées (clamp min/max relevés de 20-40%)
- Fix pumper hors écran (right: -15% → 3%)
- Footer pattern : opacités internes ×3
- Fonds palettes dark éclaircis (bg 4% → 7%, surface 9% → 12%)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 ac4aff4985 Refonte UI complète : palettes saisonnières, typo moderne, paroles nettoyées, Shadoks
- Nettoyage paroles : suppression instructions Suno AI, corrections prononciation (11 fichiers)
- 4 palettes saisonnières (Automne/Hiver dark, Printemps/Été light) avec sélecteur
- Typographie modernisée : Outfit (display) + Inter (sans) remplacent Syne + Space Grotesk
- Styles adaptatifs : CSS vars pour couleurs, overrides light mode complets
- Mini-player : bouton Next ajouté, flèche expand plus visible
- BookPlayer : fix scroll mode paginé, croix de fermeture visible
- Illustrations Shadoks inline SVG dans 11 composants/pages
- Suppression soulignés navigation, reset boutons, bordures propres

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 6f422a7369 Fix affichage paroles : white-space pre-line + échappement crochets markdown
Le parseur markdown convertissait [Intro] en <span>Intro</span> (perte des
crochets) et les \n dans les nœuds texte étaient collapsés en espaces HTML.

- Échappe tous les crochets dans les 11 fichiers markdown (\[Intro\] etc.)
- Ajoute white-space: pre-line sur les paragraphes dans BookPlayer et
  ChapterContent pour que les sauts de ligne des paroles soient visibles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 b29fc77c60 Reordonner morceaux selon PDF, paroles dans les pages, sync player↔chapitres
- Reordonne les songs dans le config YAML selon l'ordre du PDF (01→09, 06 en dernier)
- Met à jour les titres avec les noms du PDF et la numérotation correcte
- Remplace le contenu des 11 pages markdown par les paroles des chansons associées
- Ajoute getChapterForSong() dans useBookData pour la recherche inverse
- Ajoute un watcher dans BookPlayer qui navigue au chapitre quand le morceau change
- Flag _skipSongWatch pour éviter les boucles infinies player↔navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 ac2b8040b1 Refonte mini-player flottant, nettoyage GrateWizard, corrections UI
- PlayerPersistent: widget compact pill + panneau extensible, aligné au contenu
- BookPlayer: ajustements scroll mode, suppression bordures boutons
- UnoCSS: ajout border-none au shortcut btn-ghost
- GrateWizard: suppression composants, services et utils obsolètes
- Ajout du PDF source des paroles (media/)
- Mises à jour config et dépendances

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 0e1e704319 Extraire les paroles des chansons du PDF et les intégrer au YAML
8 chansons sur 9 peuplées depuis media/Paroles Chansons.pdf.
chanson-06 (La croissance, une option ?) reste sans paroles (absente du PDF).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 3612cc17f8 Clean up BookPlayer UI: remove button borders, move close into top bar
Remove borders from all buttons (bar, nav) for a minimal ghost style,
remove the viewport border, relocate the close button from the overlay
corner into the reader bar, and tighten spacing around controls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 837b5394fe Add BookPlayer scroll reading mode and floating mini-player widget
Replace paginated-only reading with a toggle between paginated (CSS columns)
and continuous vertical scroll modes. Replace the full-width fixed footer
player bar with a compact floating pill in the bottom-right corner,
expandable to show full controls, visualizer, and playlist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 554602ad52 Pin Bloc 0 and Arrivant juste at top of relations list, sort others alphabetically
- Rename "Newbie" to "Arrivant juste"
- Base friends (Bloc 0, Arrivant juste) always stay at the top
- User-added relations are sorted below them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 2b5543791f Migrate grateWizard from React/Next.js to native Nuxt integration
- Port all React components to Vue 3 (GwTabs, GwMN, GwCRA, GwCRS,
  GwMap, GwRelations, GwPerimeterList)
- Port hooks to Vue composables (useCesiumProfiles, useSavedPerimeters)
- Copy pure TS services and utils (duniter/, ss58, gratewizard utils)
- Add Leaflet + Geoman + MarkerCluster dependencies
- Serve grateWizard as popup via /gratewizard?popup (layout: false)
  and info page on /gratewizard (with Librodrome layout)
- Remove public/gratewizard-app/ static Next.js export
- Refine UI: compact tabs, buttons, inputs, cards, perimeter list
- Use Ğ1 breve everywhere, French locale for all dates and amounts
- Rename roles: vendeur→offre / acheteur→reçoit le produit ou service
- Rename prix→évaluation in all visible text
- Add calculated result column in CRA and CRS relation tables
- DU/Ğ1 selector uses toggle switch (same as role toggle)
- Auto-scroll to monetary data card on polygon selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
YvvandClaude Opus 4.6 524c7a0fc2 Ouverture directe du BookPlayer en lecture, corrections éditoriales
- Suppression des phases intro (livre 3D) et cover (page intermédiaire)
  du BookPlayer : le reader s'ouvre directement depuis la home
- Corrections textuelles : about.md, app.config.ts, app.vue
- Mise à jour de GrateWizard app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:05:43 +01:00
aya bf4b9e02f1 fix docker build 2026-02-25 02:23:58 +01:00
aya de0480de5b deploy 2026-02-25 01:09:01 +01:00
262 changed files with 18565 additions and 2936 deletions
+2
View File
@@ -0,0 +1,2 @@
.woodpecker.yml
docker
+6
View File
@@ -1,3 +1,9 @@
# Admin authentication # Admin authentication
NUXT_ADMIN_PASSWORD=changeme NUXT_ADMIN_PASSWORD=changeme
NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars 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=
+10
View File
@@ -18,6 +18,16 @@ logs
.fleet .fleet
.idea .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 # Local env files
.env .env
.env.* .env.*
+19
View File
@@ -0,0 +1,19 @@
steps:
- name: build
image: docker:dind
environment:
COMPOSE_PROJECT_NAME: ${CI_REPO_OWNER,,}-${CI_REPO_NAME,,}-${CI_COMMIT_BRANCH//\//-}
LETSENCRYPT_HOST: librodrome.org
SERVICE_3000_TAGS: urlprefix-librodrome.org:443/*
NUXT_PUBLIC_SITE_URL: https://librodrome.org
NUXT_ADMIN_PASSWORD:
from_secret: NUXT_ADMIN_PASSWORD
NUXT_ADMIN_SECRET:
from_secret: NUXT_ADMIN_SECRET
commands:
- docker compose -f docker/docker-compose.yml -f docker/docker-compose.fabio.yml up --build -d
volumes:
- ${DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock
when:
- branch: main
event: push
+131
View File
@@ -0,0 +1,131 @@
# Librodrome
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
## Protocole de début de session
1. `git pull --rebase origin main` (récupère les commits admin git sync YAML prod)
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) + Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image
- Icônes : Lucide + Phosphor (via @iconify-json) ; Package manager : pnpm
- Déploiement : Docker + Traefik, CI via Woodpecker
## Structure
```
app/
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/
messages.yml # Runtime — volume Docker ../data:/src/data — JAMAIS dans git
server/
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, docker-compose.yml, docker-compose.dev.yml
```
## Données runtime (CRITIQUE)
- `data/messages.yml` : volume Docker monté `../data:/src/data` (relatif à `docker/`)
- Persisté entre les rebuilds — **jamais écrasé par les commits ni par le déploiement**
- 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`
- **Avant toute migration de chemin ou écriture sur data/ : demander confirmation**
## Intégration GrateWizard
- URL dev : `app/app.config.ts` → `localhost:3001`
- URL prod : `https://gratewizard.axiom-team.fr`
## Contenu administrable
- YAML dans `site/pages/` par section ; API supporte les chemins imbriqués
- Admin : `/admin/pages` liste, `/admin/pages/{path}` édite en YAML
- Git sync auto en prod (`ADMIN_GIT_SYNC=true`) → d'où le `git pull --rebase` obligatoire en début de session
## Commandes
```bash
pnpm dev # Dev server :3000
pnpm build # Build production
PORT=3099 node .output/server/index.mjs # Test build prod (toujours avant commit)
```
## Conventions
- CSS via UnoCSS + variables CSS palettes ; composants Vue SFC `<script setup lang="ts">`
- Shadoks SVG inline thématiques sur chaque page (hidden mobile, opacity 0.18–0.28)
- Hexagramme 益 (#42 Yi, Augmentation) dans `layouts/default.vue`
- Signature § (logo calligraphique SVG gradient) dans `TheHeader.vue` — ne pas modifier sans demander
## 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
View File
@@ -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)
+60 -63
View File
@@ -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 ```bash
# npm
npm install
# pnpm
pnpm install pnpm install
pnpm dev # Dev server sur :3000
# yarn pnpm build # Build production
yarn install
# bun
bun install
``` ```
## Development Server Port réservé : **3000** (ne pas changer).
Start the development server on `http://localhost:3000`: ## Analytics
```bash Umami hébergé sur `stats.open.us.org`. Module `nuxt-umami` configuré dans `nuxt.config.ts`.
# npm Stats publiques exposées via `/api/stats` (nécessite `NUXT_UMAMI_API_KEY`).
npm run dev
# pnpm ## Déploiement
pnpm dev
# yarn Docker + Traefik, CI via Woodpecker. Domaine : `librodrome.org`.
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.
+30
View File
@@ -0,0 +1,30 @@
# 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 <SHA de ce commit>: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
- [ ] og:image en prod = /og-default.png?v=2, fichier servi 200 et identique au local
- [ ] favicon.ico 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
+11 -11
View File
@@ -1,29 +1,29 @@
export default defineAppConfig({ export default defineAppConfig({
site: { site: {
name: 'Le Librodrome', name: 'Le Librodrome',
description: 'Une économie du don — enfin concevable. Un livre et 9 chansons, lecture guidée et écoute libre.', description: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.',
url: 'https://librodrome.org', url: 'https://librodrome.org',
}, },
header: { header: {
height: '4rem', height: '4rem',
nav: [
{ label: 'Accueil', to: '/' },
{ label: 'Lire', to: '/lire' },
{ label: 'Écouter', to: '/ecouter' },
{ label: 'À propos', to: '/a-propos' },
],
}, },
footer: { footer: {
credits: '© 2026 Le Librodrome — Production collective', credits: '© 2026 Le Librodrome — Productions collectives',
links: [ links: [
{ label: 'Mentions légales', to: '/mentions-legales' }, { label: 'Mentions légales', to: '/mentions-legales' },
], ],
}, },
gratewizard: { gratewizard: {
url: '/gratewizard-app/', url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
popup: { popup: {
width: 420, width: 480,
height: 720, height: 860,
}, },
}, },
libredecision: {
url: 'https://decision.librodrome.org',
},
sejeteral0: {
url: 'https://collectivites.librodrome.org',
},
}) })
+54 -6
View File
@@ -14,15 +14,63 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() 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({ useHead({
titleTemplate: (title) => { titleTemplate: (title) => title ? `${title} — Le Librodrome` : 'Le Librodrome',
const siteName = site.value?.identity.name ?? 'Le Librodrome' })
return title ? `${title} — ${siteName}` : siteName
useSeoMeta({
ogSiteName: 'Le Librodrome',
ogType: 'website',
ogLocale: 'fr_FR',
ogImage: `${siteUrl}/og-default.png?v=2`,
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=2`,
},
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: site.value?.identity.description ?? '' },
], ],
}) })
</script> </script>
+2 -2
View File
@@ -60,10 +60,10 @@
@keyframes glow-pulse { @keyframes glow-pulse {
0%, 100% { 0%, 100% {
box-shadow: 0 0 8px hsl(12 76% 48% / 0.3); box-shadow: 0 0 8px hsl(var(--color-primary) / 0.3);
} }
50% { 50% {
box-shadow: 0 0 24px hsl(12 76% 48% / 0.6); box-shadow: 0 0 24px hsl(var(--color-primary) / 0.6);
} }
} }
+3 -3
View File
@@ -2,13 +2,13 @@
/* This file provides fallback and utility classes */ /* This file provides fallback and utility classes */
.font-display { .font-display {
font-family: 'Syne', system-ui, sans-serif; font-family: var(--font-display);
} }
.font-sans { .font-sans {
font-family: 'Space Grotesk', system-ui, sans-serif; font-family: var(--font-sans);
} }
.font-mono { .font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace; font-family: var(--font-mono);
} }
+152 -10
View File
@@ -3,26 +3,39 @@
@import './typography.css'; @import './typography.css';
:root { :root {
--color-primary: 12 76% 48%; --color-primary: 18 80% 45%;
--color-accent: 36 80% 52%; --color-accent: 32 85% 50%;
--color-bg: 20 8% 3.5%; --color-bg: 215 8% 22%;
--color-surface: 20 8% 8%; --color-surface: 213 7% 27%;
--color-surface-light: 20 8% 13%; --color-surface-light: 210 6% 32%;
--color-text: 0 0% 100%; --color-text: 0 0% 100%;
--color-text-muted: 0 0% 100% / 0.6; --color-text-muted: 0 0% 65%;
--header-height: 4rem; --header-height: 4rem;
--player-height: 5rem; --player-height: 0rem;
--sidebar-width: 280px; --sidebar-width: 280px;
--font-display: 'Syne', sans-serif; --font-display: 'Bricolage Grotesque', system-ui, sans-serif;
--font-sans: 'Space Grotesk', 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; --font-mono: 'JetBrains Mono', monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1); --ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
} }
/* Fader level of the home console — registered so it can transition smoothly */
@property --level {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -43,9 +56,22 @@ body {
min-height: 100dvh; min-height: 100dvh;
} }
button {
border: none;
background: none;
cursor: pointer;
font: inherit;
color: inherit;
}
a {
text-decoration: none;
color: inherit;
}
::selection { ::selection {
background-color: hsl(var(--color-primary) / 0.3); background-color: hsl(var(--color-primary) / 0.3);
color: white; color: hsl(var(--color-text));
} }
:focus-visible { :focus-visible {
@@ -72,6 +98,122 @@ body {
background: hsl(var(--color-text) / 0.25); 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;
}
/* Force all white text → adaptive text color in light mode.
Using !important to override scoped component styles and UnoCSS utilities. */
.palette-light,
.palette-light .text-white {
color: hsl(var(--color-text)) !important;
}
/* white with opacity → dark text with boosted opacity for punch */
.palette-light .text-white\/20 { color: hsl(var(--color-text) / 0.28) !important; }
.palette-light .text-white\/30 { color: hsl(var(--color-text) / 0.38) !important; }
.palette-light .text-white\/40 { color: hsl(var(--color-text) / 0.48) !important; }
.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; }
.palette-light .bg-white\/8 { background-color: hsl(var(--color-primary) / 0.07) !important; }
.palette-light .bg-white\/10 { background-color: hsl(var(--color-primary) / 0.09) !important; }
/* borders with primary tint */
.palette-light .border-white\/8 { border-color: hsl(var(--color-primary) / 0.15) !important; }
/* hover overrides */
.palette-light .hover\:text-white:hover,
.palette-light .hover\:text-white\/70:hover,
.palette-light .hover\:text-white\/80:hover {
color: hsl(var(--color-text)) !important;
}
.palette-light .hover\:text-white\/60:hover {
color: hsl(var(--color-text) / 0.7) !important;
}
.palette-light .hover\:bg-white\/5:hover {
background-color: hsl(var(--color-primary) / 0.08) !important;
}
.palette-light .hover\:bg-white\/10:hover {
background-color: hsl(var(--color-primary) / 0.12) !important;
}
/* group-hover overrides */
.palette-light .group:hover .group-hover\:text-primary\/60 {
color: hsl(var(--color-primary) / 0.7) !important;
}
/* placeholder overrides */
.palette-light .placeholder\:text-white\/30::placeholder {
color: hsl(var(--color-text) / 0.35) !important;
}
/* Prose/content in light mode */
.palette-light .prose { color: hsl(var(--color-text)); }
.palette-light .prose :where(h1,h2,h3,h4,h5,h6) { color: hsl(var(--color-text)); }
/* text-gradient — solid primary color everywhere */
/* card surfaces — subtle shadow for depth */
.palette-light .card-surface {
background: hsl(var(--color-surface)) !important;
border-color: hsl(var(--color-primary) / 0.12) !important;
box-shadow: 0 1px 3px hsl(var(--color-text) / 0.06);
}
/* btn-primary text stays white on colored bg */
.palette-light .btn-primary {
color: white !important;
}
/* input fields — cleaner contrast */
.palette-light input,
.palette-light textarea {
color: hsl(var(--color-text));
background-color: white;
border-color: hsl(var(--color-text) / 0.18);
}
.palette-light input:focus,
.palette-light textarea:focus {
border-color: hsl(var(--color-primary) / 0.5);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
/* Ecouter view toggle buttons */
.palette-light .bg-white\/10 {
background-color: hsl(var(--color-primary) / 0.1) !important;
}
/* Light mode scrollbar — tinted with primary */
.palette-light ::-webkit-scrollbar-thumb {
background: hsl(var(--color-primary) / 0.2);
}
.palette-light ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--color-primary) / 0.35);
}
/* Light mode selection — vivid */
.palette-light ::selection {
background-color: hsl(var(--color-accent) / 0.25);
}
/* Page transitions */ /* Page transitions */
.page-enter-active, .page-enter-active,
.page-leave-active { .page-leave-active {
+25 -25
View File
@@ -3,7 +3,7 @@
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.8; line-height: 1.8;
color: hsl(0 0% 100% / 0.90); color: hsl(var(--color-text) / 0.90);
max-width: 65ch; max-width: 65ch;
} }
@@ -13,11 +13,11 @@
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.25;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: white; color: hsl(var(--color-text));
margin-top: 0; margin-top: 0;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(12 76% 48% / 0.4); border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
} }
.prose h2 { .prose h2 {
@@ -26,11 +26,11 @@
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: white; color: hsl(var(--color-text));
margin-top: 3.5rem; margin-top: 3.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-left: 0.75rem; padding-left: 0.75rem;
border-left: 3px solid hsl(12 76% 48% / 0.5); border-left: 3px solid hsl(var(--color-primary) / 0.5);
} }
.prose h3 { .prose h3 {
@@ -38,7 +38,7 @@
font-size: clamp(1.25rem, 3vw, 1.625rem); font-size: clamp(1.25rem, 3vw, 1.625rem);
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
color: hsl(0 0% 100% / 0.92); color: hsl(var(--color-text) / 0.92);
margin-top: 3rem; margin-top: 3rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@@ -49,7 +49,7 @@
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
border-radius: 50%; border-radius: 50%;
background: hsl(36 80% 52%); background: hsl(var(--color-accent));
margin-right: 0.625rem; margin-right: 0.625rem;
vertical-align: middle; vertical-align: middle;
position: relative; position: relative;
@@ -61,7 +61,7 @@
font-size: clamp(1.065rem, 2.5vw, 1.25rem); font-size: clamp(1.065rem, 2.5vw, 1.25rem);
font-weight: 600; font-weight: 600;
line-height: 1.45; line-height: 1.45;
color: hsl(0 0% 100% / 0.85); color: hsl(var(--color-text) / 0.85);
margin-top: 2.5rem; margin-top: 2.5rem;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
} }
@@ -69,7 +69,7 @@
.prose h4::before { .prose h4::before {
content: '//'; content: '//';
font-family: var(--font-mono); font-family: var(--font-mono);
color: hsl(36 80% 52%); color: hsl(var(--color-accent));
margin-right: 0.5rem; margin-right: 0.5rem;
font-weight: 500; font-weight: 500;
} }
@@ -78,7 +78,7 @@
.prose h2 + p, .prose h2 + p,
.prose h3 + p { .prose h3 + p {
font-size: 1.175rem; font-size: 1.175rem;
color: hsl(0 0% 100% / 0.75); color: hsl(var(--color-text) / 0.75);
line-height: 1.85; line-height: 1.85;
} }
@@ -88,25 +88,25 @@
} }
.prose a { .prose a {
color: hsl(12 76% 68%); color: hsl(var(--color-primary) / 0.85);
text-decoration: underline; text-decoration: underline;
text-decoration-color: hsl(12 76% 58% / 0.3); text-decoration-color: hsl(var(--color-primary) / 0.3);
text-underline-offset: 3px; text-underline-offset: 3px;
transition: text-decoration-color 0.2s; transition: text-decoration-color 0.2s;
} }
.prose a:hover { .prose a:hover {
text-decoration-color: hsl(12 76% 58%); text-decoration-color: hsl(var(--color-primary));
} }
.prose blockquote { .prose blockquote {
margin: 2rem 0; margin: 2rem 0;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-left: 3px solid hsl(12 76% 58%); border-left: 3px solid hsl(var(--color-primary));
background: hsl(240 10% 8%); background: hsl(var(--color-surface));
border-radius: 0 0.5rem 0.5rem 0; border-radius: 0 0.5rem 0.5rem 0;
font-style: italic; font-style: italic;
color: hsl(0 0% 100% / 0.75); color: hsl(var(--color-text) / 0.75);
} }
.prose blockquote p:last-child { .prose blockquote p:last-child {
@@ -116,17 +116,17 @@
.prose code { .prose code {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.875em; font-size: 0.875em;
background: hsl(240 10% 12%); background: hsl(var(--color-surface-light));
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 0.25rem; border-radius: 0.25rem;
color: hsl(31 97% 66%); color: hsl(var(--color-accent));
} }
.prose pre { .prose pre {
margin: 2rem 0; margin: 2rem 0;
padding: 1.5rem; padding: 1.5rem;
background: hsl(240 10% 6%); background: hsl(var(--color-bg));
border: 1px solid hsl(0 0% 100% / 0.08); border: 1px solid hsl(var(--color-text) / 0.08);
border-radius: 0.75rem; border-radius: 0.75rem;
overflow-x: auto; overflow-x: auto;
} }
@@ -134,7 +134,7 @@
.prose pre code { .prose pre code {
background: none; background: none;
padding: 0; padding: 0;
color: hsl(0 0% 100% / 0.87); color: hsl(var(--color-text) / 0.87);
} }
.prose ul, .prose ul,
@@ -149,22 +149,22 @@
} }
.prose li::marker { .prose li::marker {
color: hsl(12 76% 58%); color: hsl(var(--color-primary));
} }
.prose hr { .prose hr {
margin: 3rem 0; margin: 3rem 0;
border: none; border: none;
border-top: 1px solid hsl(0 0% 100% / 0.1); border-top: 1px solid hsl(var(--color-text) / 0.1);
} }
.prose strong { .prose strong {
color: white; color: hsl(var(--color-text));
font-weight: 600; font-weight: 600;
} }
.prose em { .prose em {
color: hsl(0 0% 100% / 0.9); color: hsl(var(--color-text) / 0.9);
} }
.prose img { .prose img {
+7 -7
View File
@@ -58,7 +58,7 @@ function addItem() {
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.list-item { .list-item {
@@ -66,9 +66,9 @@ function addItem() {
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
} }
.list-item > :deep(*:first-child) { .list-item > :deep(*:first-child) {
@@ -101,9 +101,9 @@ function addItem() {
gap: 0.375rem; gap: 0.375rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px dashed hsl(20 8% 22%); border: 1px dashed hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@@ -111,7 +111,7 @@ function addItem() {
} }
.list-add:hover { .list-add:hover {
border-color: hsl(12 76% 48% / 0.4); border-color: hsl(var(--color-primary) / 0.4);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
</style> </style>
+5 -5
View File
@@ -36,21 +36,21 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.field-input { .field-input {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.875rem; font-size: 0.875rem;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.field-input:focus { .field-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+5 -5
View File
@@ -37,15 +37,15 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-label { .field-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
} }
.field-textarea { .field-textarea {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.875rem; font-size: 0.875rem;
resize: vertical; resize: vertical;
min-height: 5rem; min-height: 5rem;
@@ -55,6 +55,6 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
.field-textarea:focus { .field-textarea:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+4 -4
View File
@@ -19,7 +19,7 @@ defineProps<{
<style scoped> <style scoped>
.admin-section { .admin-section {
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -32,14 +32,14 @@ defineProps<{
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
color: white; color: hsl(var(--color-text));
cursor: pointer; cursor: pointer;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
user-select: none; user-select: none;
} }
.admin-section-header:hover { .admin-section-header:hover {
background: hsl(20 8% 8%); background: hsl(var(--color-surface));
} }
.admin-section[open] .section-arrow { .admin-section[open] .section-arrow {
+102 -18
View File
@@ -1,35 +1,58 @@
<template> <template>
<div class="md-editor"> <div class="md-editor">
<div class="md-toolbar">
<div class="md-tabs"> <div class="md-tabs">
<button <button
class="md-tab" class="md-tab"
:class="{ 'md-tab--active': tab === 'edit' }" :class="{ 'md-tab--active': tab === 'edit' }"
@click="tab = 'edit'" @click="tab = 'edit'"
> >
<div class="i-lucide-pencil h-3.5 w-3.5" />
Édition Édition
</button> </button>
<button
class="md-tab"
:class="{ 'md-tab--active': tab === 'split' }"
@click="tab = 'split'"
>
<div class="i-lucide-columns-2 h-3.5 w-3.5" />
Split
</button>
<button <button
class="md-tab" class="md-tab"
:class="{ 'md-tab--active': tab === 'preview' }" :class="{ 'md-tab--active': tab === 'preview' }"
@click="tab = 'preview'" @click="tab = 'preview'"
> >
<div class="i-lucide-eye h-3.5 w-3.5" />
Aperçu Aperçu
</button> </button>
</div> </div>
<button
class="md-fullscreen"
:class="{ 'md-fullscreen--active': fullscreen }"
@click="fullscreen = !fullscreen"
>
<div :class="fullscreen ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'" class="h-3.5 w-3.5" />
</button>
</div>
<div class="md-body" :class="{ 'md-body--split': tab === 'split', 'md-body--fullscreen': fullscreen }">
<textarea <textarea
v-if="tab === 'edit'" v-if="tab === 'edit' || tab === 'split'"
ref="textareaRef"
:value="modelValue" :value="modelValue"
class="md-textarea" class="md-textarea"
:rows="rows" :rows="rows"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" @input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.tab.prevent="insertTab"
/> />
<div <div
v-else v-if="tab === 'preview' || tab === 'split'"
class="md-preview prose" class="md-preview prose"
v-html="renderedHtml" v-html="renderedHtml"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -38,64 +61,123 @@ const props = defineProps<{
rows?: number rows?: number
}>() }>()
defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string] 'update:modelValue': [value: string]
}>() }>()
const tab = ref<'edit' | 'preview'>('edit') const tab = ref<'edit' | 'preview' | 'split'>('split')
const fullscreen = ref(false)
const textareaRef = ref<HTMLTextAreaElement>()
function insertTab(e: KeyboardEvent) {
const ta = e.target as HTMLTextAreaElement
const start = ta.selectionStart
const end = ta.selectionEnd
const val = ta.value
const newVal = val.substring(0, start) + ' ' + val.substring(end)
emit('update:modelValue', newVal)
nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 2
})
}
const renderedHtml = computed(() => { const renderedHtml = computed(() => {
// Simple markdown rendering for preview
return props.modelValue return props.modelValue
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>') .replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>') .replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>') .replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>') .replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n\n/g, '</p><p>') .replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hp])(.+)/gm, '<p>$1</p>') .replace(/^(?!<[hpulob])(.+)/gm, '<p>$1</p>')
}) })
</script> </script>
<style scoped> <style scoped>
.md-editor { .md-editor {
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
} }
.md-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: hsl(var(--color-bg));
border-bottom: 1px solid hsl(var(--color-surface-light));
}
.md-tabs { .md-tabs {
display: flex; display: flex;
background: hsl(20 8% 6%);
border-bottom: 1px solid hsl(20 8% 14%);
} }
.md-tab { .md-tab {
padding: 0.5rem 1rem; display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: none; border: none;
background: none; background: none;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.md-tab--active { .md-tab--active {
color: white; color: hsl(var(--color-text));
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
}
.md-fullscreen {
padding: 0.5rem 0.75rem;
color: hsl(var(--color-text-muted));
transition: color 0.2s;
}
.md-fullscreen:hover,
.md-fullscreen--active { color: hsl(var(--color-text)); }
.md-body {
display: flex;
}
.md-body--split .md-textarea,
.md-body--split .md-preview {
width: 50%;
}
.md-body--split .md-preview {
border-left: 1px solid hsl(var(--color-surface-light));
}
.md-body--fullscreen {
position: fixed;
inset: 0;
z-index: 50;
background: hsl(var(--color-bg));
}
.md-body--fullscreen .md-textarea,
.md-body--fullscreen .md-preview {
height: 100vh;
} }
.md-textarea { .md-textarea {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: none; border: none;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.7; line-height: 1.7;
resize: vertical; resize: vertical;
min-height: 20rem; min-height: 24rem;
tab-size: 2;
} }
.md-textarea:focus { .md-textarea:focus {
@@ -104,7 +186,9 @@ const renderedHtml = computed(() => {
.md-preview { .md-preview {
padding: 1rem; padding: 1rem;
min-height: 20rem; min-height: 24rem;
background: hsl(20 8% 4%); max-height: 70vh;
overflow-y: auto;
background: hsl(var(--color-bg));
} }
</style> </style>
+16 -16
View File
@@ -102,18 +102,18 @@ function formatSize(bytes: number): string {
.filter-btn { .filter-btn {
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: none; background: none;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.filter-btn--active { .filter-btn--active {
background: hsl(12 76% 48% / 0.15); background: hsl(var(--color-primary) / 0.15);
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.media-grid { .media-grid {
@@ -123,7 +123,7 @@ function formatSize(bytes: number): string {
} }
.media-card { .media-card {
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@@ -131,18 +131,18 @@ function formatSize(bytes: number): string {
} }
.media-card:hover { .media-card:hover {
border-color: hsl(20 8% 22%); border-color: hsl(var(--color-surface-light));
} }
.media-card--selected { .media-card--selected {
border-color: hsl(12 76% 48%); border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3); box-shadow: 0 0 0 1px hsl(var(--color-primary) / 0.3);
} }
.media-thumb { .media-thumb {
aspect-ratio: 1; aspect-ratio: 1;
overflow: hidden; overflow: hidden;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
} }
.media-thumb img { .media-thumb img {
@@ -156,8 +156,8 @@ function formatSize(bytes: number): string {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
} }
.media-info { .media-info {
@@ -169,7 +169,7 @@ function formatSize(bytes: number): string {
.media-name { .media-name {
font-size: 0.72rem; font-size: 0.72rem;
color: white; color: hsl(var(--color-text));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -177,7 +177,7 @@ function formatSize(bytes: number): string {
.media-size { .media-size {
font-size: 0.65rem; font-size: 0.65rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
} }
.media-actions { .media-actions {
@@ -186,9 +186,9 @@ function formatSize(bytes: number): string {
justify-content: space-between; justify-content: space-between;
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
} }
.delete-btn { .delete-btn {
+5 -5
View File
@@ -73,7 +73,7 @@ async function upload(files: FileList) {
<style scoped> <style scoped>
.upload-zone { .upload-zone {
border: 2px dashed hsl(20 8% 22%); border: 2px dashed hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
@@ -82,12 +82,12 @@ async function upload(files: FileList) {
} }
.upload-zone:hover { .upload-zone:hover {
border-color: hsl(12 76% 48% / 0.4); border-color: hsl(var(--color-primary) / 0.4);
} }
.upload-zone--active { .upload-zone--active {
border-color: hsl(12 76% 48%); border-color: hsl(var(--color-primary));
background: hsl(12 76% 48% / 0.05); background: hsl(var(--color-primary) / 0.05);
} }
.upload-progress { .upload-progress {
@@ -95,7 +95,7 @@ async function upload(files: FileList) {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
font-size: 0.85rem; font-size: 0.85rem;
} }
+2 -2
View File
@@ -31,7 +31,7 @@ defineEmits<{
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
background: hsl(12 76% 48%); background: hsl(var(--color-primary));
color: white; color: white;
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.85rem;
@@ -40,7 +40,7 @@ defineEmits<{
} }
.save-btn:hover:not(:disabled) { .save-btn:hover:not(:disabled) {
background: hsl(12 76% 42%); background: hsl(var(--color-primary));
} }
.save-btn:disabled { .save-btn:disabled {
+28 -16
View File
@@ -23,21 +23,33 @@
</NuxtLink> </NuxtLink>
<p class="sidebar-section">Pages</p> <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"> <NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-home h-4 w-4" /> <div class="i-lucide-home h-4 w-4" />
Accueil Accueil
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active"> <NuxtLink to="/admin/pages/numerique" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-book-open h-4 w-4" /> <div class="i-lucide-monitor h-4 w-4" />
Lire Numérique
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/ecouter" class="sidebar-link" active-class="sidebar-link--active"> <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" /> <div class="i-lucide-headphones h-4 w-4" />
Écouter En musique
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active"> <NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-sparkles h-4 w-4" /> <div class="i-lucide-sparkles h-4 w-4" />
GrateWizard grateWizard
</NuxtLink> </NuxtLink>
<p class="sidebar-section">Livre</p> <p class="sidebar-section">Livre</p>
@@ -79,8 +91,8 @@ async function logout() {
<style scoped> <style scoped>
.admin-sidebar { .admin-sidebar {
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
border-right: 1px solid hsl(20 8% 14%); border-right: 1px solid hsl(var(--color-surface-light));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100dvh; height: 100dvh;
@@ -90,7 +102,7 @@ async function logout() {
.sidebar-header { .sidebar-header {
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
border-bottom: 1px solid hsl(20 8% 14%); border-bottom: 1px solid hsl(var(--color-surface-light));
} }
.sidebar-nav { .sidebar-nav {
@@ -104,7 +116,7 @@ async function logout() {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
padding: 1rem 0.75rem 0.375rem; padding: 1rem 0.75rem 0.375rem;
margin: 0; margin: 0;
} }
@@ -116,7 +128,7 @@ async function logout() {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
border: none; border: none;
@@ -126,18 +138,18 @@ async function logout() {
} }
.sidebar-link:hover { .sidebar-link:hover {
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
color: white; color: hsl(var(--color-text));
} }
.sidebar-link--active { .sidebar-link--active {
background: hsl(12 76% 48% / 0.12); background: hsl(var(--color-primary) / 0.12);
color: hsl(12 76% 68%); color: hsl(var(--color-primary));
} }
.sidebar-footer { .sidebar-footer {
padding: 0.5rem; padding: 0.5rem;
border-top: 1px solid hsl(20 8% 14%); border-top: 1px solid hsl(var(--color-surface-light));
} }
@media (max-width: 768px) { @media (max-width: 768px) {
+33
View File
@@ -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>
+24 -16
View File
@@ -1,6 +1,5 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="pdf-overlay">
<div <div
v-if="isOpen" v-if="isOpen"
class="pdf-reader" class="pdf-reader"
@@ -22,38 +21,58 @@
<!-- PDF embed --> <!-- PDF embed -->
<div class="pdf-viewport"> <div class="pdf-viewport">
<iframe <iframe
:key="pdfUrl"
:src="pdfUrl" :src="pdfUrl"
class="pdf-frame" class="pdf-frame"
:title="bpContent?.pdf.iframeTitle" :title="bpContent?.pdf.iframeTitle"
ref="iframeRef"
@load="iframeRef?.focus()"
/> />
</div> </div>
</div> </div>
</Transition>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean; page?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { data: bpContent } = await usePageContent('book-player') const { data: bpContent } = await usePageContent('book-player')
const bookData = useBookData()
await bookData.init()
const { trackPdfOpen, trackPdfClose } = useTracking()
const overlayRef = ref<HTMLElement>() const overlayRef = ref<HTMLElement>()
const iframeRef = ref<HTMLIFrameElement>()
const isOpen = computed({ const isOpen = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), 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() { function close() {
isOpen.value = false isOpen.value = false
} }
// Tracking state
let pdfOpenedAt = 0
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (open) { if (open) {
nextTick(() => overlayRef.value?.focus()) 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) { if (import.meta.client) {
document.body.style.overflow = open ? 'hidden' : '' document.body.style.overflow = open ? 'hidden' : ''
@@ -91,7 +110,7 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.625rem;
font-family: var(--font-display, 'Syne', sans-serif); font-family: var(--font-display);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: white; color: white;
@@ -130,15 +149,4 @@ onUnmounted(() => {
background: white; 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> </style>
+293 -369
View File
@@ -23,45 +23,8 @@
aria-hidden="true" aria-hidden="true"
/> />
<!-- Close --> <!-- ═══ READER ═══ -->
<button class="bp-close" @click="close" aria-label="Fermer"> <div class="bp-phase bp-reader">
<div class="i-lucide-x h-5 w-5" />
</button>
<!-- ═══ PHASE TRANSITIONS ═══ -->
<Transition name="phase" mode="out-in">
<!-- ─── INTRO: 3D spinning book ─── -->
<div v-if="phase === 'intro'" key="intro" class="bp-phase bp-intro">
<div class="spin-scene">
<div class="spin-book" @animationend="onSpinEnd">
<div class="spin-face spin-front">
<img src="/images/book-cover-spread.jpg" alt="Couverture" />
</div>
<div class="spin-face spin-back">
<img src="/images/book-cover-spread.jpg" alt="" />
</div>
</div>
</div>
</div>
<!-- ─── COVER: title + CTA ─── -->
<div v-else-if="phase === 'cover'" key="cover" class="bp-phase bp-cover">
<div class="cover-frame">
<img src="/images/book-cover-spread.jpg" :alt="bpContent?.cover.coverAlt ?? 'Couverture'" class="cover-img" />
</div>
<h1 class="cover-title text-gradient">{{ bpContent?.cover.title }}</h1>
<p class="cover-sub">{{ bpContent?.cover.subtitle }}</p>
<p class="cover-desc">
{{ bpContent?.cover.description }}
</p>
<button class="cover-cta" @click="startReading">
{{ bpContent?.cover.cta }}
<div class="i-lucide-arrow-right ml-2 h-5 w-5 inline-block align-middle" />
</button>
</div>
<!-- ─── READING: paginated book reader ─── -->
<div v-else key="reading" class="bp-phase bp-reader">
<!-- Top bar --> <!-- Top bar -->
<div class="reader-bar"> <div class="reader-bar">
<button <button
@@ -72,12 +35,13 @@
<div class="i-lucide-list h-5 w-5" /> <div class="i-lucide-list h-5 w-5" />
</button> </button>
<div class="reader-bar-title"> <div class="reader-bar-title">
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span> <span class="reader-bar-num">{{ trackIdx + 1 }}.</span>
{{ chapters[chapterIdx].title }} {{ currentTrack?.title ?? '' }}
</div> </div>
<span class="reader-bar-pages"> <span class="reader-bar-pages">{{ scrollPercent }}%</span>
{{ currentPage + 1 }}<span class="op-40">/</span>{{ totalPages }} <button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
</span> <div class="i-lucide-x h-5 w-5" />
</button>
</div> </div>
<!-- Sommaire sidebar --> <!-- Sommaire sidebar -->
@@ -86,73 +50,85 @@
<div class="sommaire-panel"> <div class="sommaire-panel">
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4> <h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
<button <button
v-for="(ch, i) in chapters" v-for="(track, i) in tracks"
:key="i" :key="track.id"
class="sommaire-item" class="sommaire-item"
:class="{ 'sommaire-item--active': chapterIdx === i }" :class="{ 'sommaire-item--active': trackIdx === i }"
@click="goToChapter(i)" @click="goToTrack(i)"
> >
<span class="sommaire-num">{{ i + 1 }}</span> <span class="sommaire-num">{{ i + 1 }}</span>
{{ ch.title }} {{ track.title }}
</button> </button>
</div> </div>
</aside> </aside>
</Transition> </Transition>
<!-- Content viewport --> <!-- Content viewport -->
<div class="reader-viewport" ref="viewportEl"> <div
class="reader-viewport"
:class="{ 'reader-viewport--scroll': isScrollMode }"
ref="viewportEl"
@scroll="onViewportScroll"
>
<div <div
class="reader-columns prose" class="reader-columns prose"
:class="{ 'reader-columns--scroll': isScrollMode }"
ref="contentEl" ref="contentEl"
:style="contentStyle" :style="contentStyle"
> >
<ContentRenderer v-if="activeChapter" :value="activeChapter" /> <div v-if="currentLyrics" class="lyrics-content" v-html="currentLyricsHtml" />
<div v-else-if="currentSong" class="lyrics-empty">
<p class="op-40 italic">Paroles à venir pour « {{ currentSong.title }} »</p>
</div> </div>
<!-- Page turn shadow overlay --> <div v-else class="lyrics-empty">
<div class="reader-shadow" :class="{ visible: isTurning }" /> <p class="op-40 italic">Aucun morceau sélectionné</p>
</div>
</div>
<!-- Page turn shadow overlay (paginated only) -->
<div v-if="!isScrollMode" class="reader-shadow" :class="{ visible: isTurning }" />
</div> </div>
<!-- Bottom navigation --> <!-- Bottom navigation -->
<div class="reader-nav"> <div class="reader-nav">
<button <button
class="reader-nav-btn" class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': currentPage <= 0 && chapterIdx <= 0 }" :class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx <= 0 : (currentPage <= 0 && trackIdx <= 0) }"
@click="prevPage" @click="isScrollMode ? prevTrack() : prevPage()"
aria-label="Page précédente" :aria-label="isScrollMode ? 'Morceau précédent' : 'Page précédente'"
> >
<div class="i-lucide-chevron-left h-5 w-5" /> <div class="i-lucide-chevron-left h-5 w-5" />
</button> </button>
<!-- Song disc (if chapter has a song) --> <!-- Song disc -->
<div v-if="chapterSong" class="reader-song"> <div v-if="currentSong" class="reader-song">
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }"> <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 class="reader-disc-hole" />
</div> </div>
<span class="reader-song-name">{{ chapterSong.title }}</span> <span class="reader-song-name">{{ currentSong.title }}</span>
</div> </div>
<div v-else class="reader-song" /> <div v-else class="reader-song" />
<button <button
class="reader-nav-btn" class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1 }" :class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx >= tracks.length - 1 : (currentPage >= totalPages - 1 && trackIdx >= tracks.length - 1) }"
@click="nextPage" @click="isScrollMode ? nextTrack() : nextPage()"
aria-label="Page suivante" :aria-label="isScrollMode ? 'Morceau suivant' : 'Page suivante'"
> >
<div class="i-lucide-chevron-right h-5 w-5" /> <div class="i-lucide-chevron-right h-5 w-5" />
</button> </button>
</div> </div>
</div> </div>
</Transition>
<!-- Hint --> <!-- Hint -->
<p class="bp-hint"> <p class="bp-hint">
<template v-if="phase === 'reading'"> <template v-if="isScrollMode">
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktop }}</span> <span class="hidden md:inline">{{ bpContent?.reader.hints.desktopScroll ?? '← → morceaux · Défilement libre · Esc fermer' }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobile }}</span> <span class="md:hidden">{{ bpContent?.reader.hints.mobileScroll ?? 'Défilez pour lire' }}</span>
</template> </template>
<template v-else> <template v-else>
<span class="hidden md:inline">{{ bpContent?.reader.hints.default }}</span> <span class="hidden md:inline">{{ bpContent?.reader.hints.desktop }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobile }}</span>
</template> </template>
</p> </p>
</div> </div>
@@ -162,6 +138,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: bpContent } = await usePageContent('book-player') const { data: bpContent } = await usePageContent('book-player')
const { trackPlayerOpen, trackPlayerChapter, trackPlayerMode } = useTracking()
const COL_GAP = 80 const COL_GAP = 80
@@ -177,90 +154,98 @@ const overlayRef = ref<HTMLElement>()
const viewportEl = ref<HTMLElement>() const viewportEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>() const contentEl = ref<HTMLElement>()
// ── Phase state ── const trackIdx = ref(0)
const phase = ref<'intro' | 'cover' | 'reading'>('intro')
const chapterIdx = ref(0)
const currentPage = ref(0) const currentPage = ref(0)
const totalPages = ref(1) const totalPages = ref(1)
const colWidth = ref(500) const colWidth = ref(500)
const showSommaire = ref(false) const showSommaire = ref(false)
const isTurning = ref(false) const isTurning = ref(false)
const { init: initBookData, getSongs, getPrimarySong, getPlaylistOrder } = useBookData() // ── Reading mode ──
const readingMode = ref<'paginated' | 'scroll'>('scroll')
const isScrollMode = computed(() => readingMode.value === 'scroll')
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()
setTimeout(recalcPages, 100)
}
})
function onViewportScroll() {
if (!isScrollMode.value || !viewportEl.value) return
const el = viewportEl.value
const max = el.scrollHeight - el.clientHeight
scrollPercent.value = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0
}
const { init: initBookData, getPlaylistOrder } = useBookData()
const audioPlayer = useAudioPlayer() const audioPlayer = useAudioPlayer()
const playerStore = usePlayerStore() const playerStore = usePlayerStore()
// ── Content from Nuxt Content ── // ── Tracks: built from playlist order (songs), not chapters ──
const chaptersContent = ref<any[]>([]) const tracks = computed(() => {
const contentLoaded = ref(false) return playerStore.playlist.map(song => ({
id: song.id,
async function loadContent() { title: song.title,
if (contentLoaded.value) return song,
try { }))
const data = await queryCollection('book').order('order', 'ASC').all()
chaptersContent.value = data
contentLoaded.value = true
}
catch (err) {
console.error('Failed to load book content:', err)
}
}
const activeChapter = computed(() => {
if (chapterIdx.value < 0 || !chaptersContent.value.length) return null
return chaptersContent.value[chapterIdx.value] ?? null
}) })
// ── Chapter metadata ── const currentTrack = computed(() => tracks.value[trackIdx.value] ?? null)
const chapters = [ const currentSong = computed(() => currentTrack.value?.song ?? null)
{ slug: 'introduction', title: 'Introduction' },
{ slug: 'de-quel-don-parlons-nous', title: 'De quel don parlons-nous ?' },
{ slug: 'la-mesure-du-don', title: 'La mesure du don' },
{ slug: 'raison-d-etre-d-une-monnaie', title: 'Raison d\'être d\'une monnaie' },
{ slug: 'la-trm', title: 'La TRM' },
{ slug: 'creer-une-economie', title: 'Créer une économie ?' },
{ slug: 'echanger', title: 'Échanger' },
{ slug: 'relation-institutionnelle', title: 'Relation institutionnelle' },
{ slug: 'autres-greffes', title: 'Autres greffes' },
{ slug: 'et-maintenant', title: 'Et maintenant ?… action ?' },
{ slug: 'annexes', title: 'Chapitres annexes' },
]
// ── Per-chapter color hues ── const currentLyrics = computed(() => {
const chapterHues: [number, number][] = [ return currentSong.value?.lyrics?.trim() || ''
[12, 36], // cover (intro + cover phases) })
[15, 35], // 1
[350, 15], // 2 const currentLyricsHtml = computed(() => {
[36, 50], // 3 if (!currentLyrics.value) return ''
[170, 200], // 4 return currentLyrics.value
[220, 250], // 5 .replace(/&/g, '&amp;')
[270, 300], // 6 .replace(/</g, '&lt;')
[320, 345], // 7 .replace(/>/g, '&gt;')
[150, 170], // 8 .replace(/\[([^\]]+)\]/g, '<span class="lyrics-tag">[$1]</span>')
[190, 220], // 9 .replace(/\n\n/g, '</p><p>')
[40, 20], // 10 .replace(/\n/g, '<br>')
[210, 240], // 11 .replace(/^/, '<p>')
.replace(/$/, '</p>')
})
// ── Per-track color hues (9 tracks) ──
const trackHues: [number, number][] = [
[15, 35], // 1 Ce livre est une façon
[350, 15], // 2 De quel don nous parlons
[36, 50], // 3 Les asymétries
[170, 200], // 4 Inverser les flux
[220, 250], // 5 Ainsi soit-il
[270, 300], // 6 La croissance
[320, 345], // 7 Monnaie libre
[150, 170], // 8 Créer une économie
[190, 220], // 9 Coder la liberté
] ]
const sceneVars = computed(() => { const sceneVars = computed(() => {
const idx = phase.value === 'reading' ? chapterIdx.value + 1 : 0 const [h1, h2] = trackHues[trackIdx.value] ?? trackHues[0]
const [h1, h2] = chapterHues[idx] ?? chapterHues[0]
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number> return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
}) })
const chapterSong = computed(() => {
if (phase.value !== 'reading') return null
return getPrimarySong(chapters[chapterIdx.value].slug)
})
// ── CSS columns pagination ── // ── CSS columns pagination ──
const contentStyle = computed(() => ({ const contentStyle = computed(() => {
if (isScrollMode.value) return {}
return {
columnWidth: colWidth.value + 'px', columnWidth: colWidth.value + 'px',
columnGap: COL_GAP + 'px', columnGap: COL_GAP + 'px',
transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`, transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`,
})) }
})
function recalcPages() { function recalcPages() {
if (isScrollMode.value) return
if (!contentEl.value || !viewportEl.value) return if (!contentEl.value || !viewportEl.value) return
colWidth.value = viewportEl.value.offsetWidth colWidth.value = viewportEl.value.offsetWidth
const sw = contentEl.value.scrollWidth const sw = contentEl.value.scrollWidth
@@ -269,25 +254,17 @@ function recalcPages() {
let resizeObs: ResizeObserver | null = null let resizeObs: ResizeObserver | null = null
// Recalc when chapter content changes // Recalc when track changes
watch(activeChapter, async () => { watch(trackIdx, async () => {
currentPage.value = 0 currentPage.value = 0
// Wait for ContentRenderer to update DOM
await nextTick() await nextTick()
await nextTick() await nextTick()
setTimeout(recalcPages, 100) setTimeout(recalcPages, 100)
}) })
// ── Phase transitions ── async function initReading() {
function onSpinEnd() { trackIdx.value = 0
phase.value = 'cover'
}
async function startReading() {
await loadContent()
chapterIdx.value = 0
currentPage.value = 0 currentPage.value = 0
phase.value = 'reading'
await nextTick() await nextTick()
await nextTick() await nextTick()
// Set up ResizeObserver // Set up ResizeObserver
@@ -298,14 +275,37 @@ async function startReading() {
setTimeout(recalcPages, 200) setTimeout(recalcPages, 200)
} }
// ── Navigation ── // ── Navigation by tracks (songs) ──
function goToChapter(idx: number) { let _skipSongWatch = false
chapterIdx.value = idx
function goToTrack(idx: number) {
if (idx < 0 || idx >= tracks.value.length) return
trackIdx.value = idx
currentPage.value = 0 currentPage.value = 0
showSommaire.value = false showSommaire.value = false
// Play chapter song // Scroll to top in scroll mode
const song = getPrimarySong(chapters[idx].slug) if (isScrollMode.value && viewportEl.value) {
if (song) audioPlayer.loadAndPlay(song) viewportEl.value.scrollTop = 0
}
// Play the song
const song = tracks.value[idx]?.song
if (song) {
trackPlayerChapter(song.id)
_skipSongWatch = true
audioPlayer.loadAndPlay(song)
}
}
function nextTrack() {
if (trackIdx.value < tracks.value.length - 1) {
goToTrack(trackIdx.value + 1)
}
}
function prevTrack() {
if (trackIdx.value > 0) {
goToTrack(trackIdx.value - 1)
}
} }
function nextPage() { function nextPage() {
@@ -313,9 +313,8 @@ function nextPage() {
triggerTurn() triggerTurn()
currentPage.value++ currentPage.value++
} }
else if (chapterIdx.value < chapters.length - 1) { else if (trackIdx.value < tracks.value.length - 1) {
// Next chapter goToTrack(trackIdx.value + 1)
goToChapter(chapterIdx.value + 1)
} }
} }
@@ -324,22 +323,8 @@ function prevPage() {
triggerTurn() triggerTurn()
currentPage.value-- currentPage.value--
} }
else if (chapterIdx.value > 0) { else if (trackIdx.value > 0) {
// Previous chapter, go to last page goToTrack(trackIdx.value - 1)
chapterIdx.value--
currentPage.value = 0
showSommaire.value = false
const song = getPrimarySong(chapters[chapterIdx.value].slug)
if (song) audioPlayer.loadAndPlay(song)
// After content loads, go to last page
watch(activeChapter, async () => {
await nextTick()
await nextTick()
setTimeout(() => {
recalcPages()
currentPage.value = Math.max(0, totalPages.value - 1)
}, 150)
}, { once: true })
} }
} }
@@ -353,15 +338,26 @@ function close() {
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (phase.value === 'reading') { // CRITICAL: stop propagation so useKeyboardShortcuts doesn't also fire
e.stopPropagation()
if (e.key === 'Escape') { close(); return }
if (e.key === ' ') {
e.preventDefault()
audioPlayer.togglePlayPause()
return
}
if (isScrollMode.value) {
if (e.key === 'ArrowRight') { e.preventDefault(); nextTrack() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevTrack() }
}
else {
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() } if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() } else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (chapterIdx.value < chapters.length - 1) goToChapter(chapterIdx.value + 1) } else if (e.key === 'ArrowDown') { e.preventDefault(); nextTrack() }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (chapterIdx.value > 0) goToChapter(chapterIdx.value - 1) } else if (e.key === 'ArrowUp') { e.preventDefault(); prevTrack() }
else if (e.key === 'Escape') close()
}
else if (e.key === 'Escape') {
close()
} }
} }
@@ -373,6 +369,7 @@ function onTouchStart(e: TouchEvent) {
} }
function onTouchEnd(e: TouchEvent) { function onTouchEnd(e: TouchEvent) {
if (isScrollMode.value) return
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0) const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
if (Math.abs(diff) > 50) { if (Math.abs(diff) > 50) {
if (diff > 0) nextPage() if (diff > 0) nextPage()
@@ -381,13 +378,19 @@ function onTouchEnd(e: TouchEvent) {
} }
// ── Lifecycle ── // ── 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) => { watch(isOpen, async (open) => {
if (open) { if (open) {
phase.value = 'intro'
chapterIdx.value = 0
currentPage.value = 0
showSommaire.value = false showSommaire.value = false
contentLoaded.value = false trackPlayerOpen()
await initBookData() await initBookData()
await nextTick() await nextTick()
overlayRef.value?.focus() overlayRef.value?.focus()
@@ -396,8 +399,11 @@ watch(isOpen, async (open) => {
// Load playlist & play first song // Load playlist & play first song
const playlist = getPlaylistOrder() const playlist = getPlaylistOrder()
if (playlist.length) playerStore.setPlaylist(playlist) if (playlist.length) playerStore.setPlaylist(playlist)
const first = getSongs().find(s => s.id === 'chanson-01') if (playlist.length) {
if (first) audioPlayer.loadAndPlay(first) _skipSongWatch = true
audioPlayer.loadAndPlay(playlist[0])
}
await initReading()
} }
else { else {
overlayRef.value?.removeEventListener('touchstart', onTouchStart) overlayRef.value?.removeEventListener('touchstart', onTouchStart)
@@ -406,6 +412,24 @@ watch(isOpen, async (open) => {
} }
}) })
// ── Sync: when song changes externally (persistent player controls), update trackIdx ──
watch(() => playerStore.currentSong, (song) => {
if (!song || !isOpen.value) return
if (_skipSongWatch) {
_skipSongWatch = false
return
}
const idx = tracks.value.findIndex(t => t.id === song.id)
if (idx !== -1 && idx !== trackIdx.value) {
trackIdx.value = idx
currentPage.value = 0
showSommaire.value = false
if (isScrollMode.value && viewportEl.value) {
viewportEl.value.scrollTop = 0
}
}
})
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (import.meta.client) document.body.style.overflow = open ? 'hidden' : '' if (import.meta.client) document.body.style.overflow = open ? 'hidden' : ''
}) })
@@ -436,13 +460,13 @@ onUnmounted(() => {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 60; z-index: 60;
background: hsl(20 8% 3%); background: hsl(var(--color-bg));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
outline: none; outline: none;
overflow: hidden; overflow: hidden;
padding-bottom: 4.5rem; padding-bottom: 1rem;
transition: --scene-h1 1.6s ease, --scene-h2 1.6s ease; transition: --scene-h1 1.6s ease, --scene-h2 1.6s ease;
} }
@@ -519,38 +543,15 @@ onUnmounted(() => {
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.04); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.04); }
} }
/* ─── CLOSE ─── */
.bp-close {
position: absolute;
top: 1rem; right: 1rem;
z-index: 75;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem; height: 2.5rem;
border-radius: 50%;
background: hsl(20 8% 8% / 0.6);
backdrop-filter: blur(12px);
color: hsl(20 8% 55%);
border: 1px solid hsl(20 8% 18% / 0.5);
cursor: pointer;
transition: all 0.3s;
}
.bp-close:hover {
background: hsl(var(--scene-h1) 60% 45% / 0.25);
color: white;
border-color: hsl(var(--scene-h1) 60% 50% / 0.4);
}
/* ─── HINT ─── */ /* ─── HINT ─── */
.bp-hint { .bp-hint {
position: absolute; position: absolute;
bottom: 5rem; bottom: 0.5rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 10; z-index: 10;
font-size: 0.68rem; font-size: 0.68rem;
color: hsl(20 8% 28%); color: hsl(var(--color-text-muted));
white-space: nowrap; white-space: nowrap;
} }
@@ -565,135 +566,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
width: 100%; width: 100%;
flex: 1; flex: 1;
} min-height: 0;
/* Phase transitions */
.phase-enter-active { animation: phase-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
.phase-leave-active { animation: phase-out 0.3s cubic-bezier(0.7, 0, 0.84, 0) both; }
@keyframes phase-in {
from { opacity: 0; transform: scale(0.97); filter: blur(4px); }
to { opacity: 1; transform: scale(1); filter: blur(0); }
}
@keyframes phase-out {
from { opacity: 1; transform: scale(1); filter: blur(0); }
to { opacity: 0; transform: scale(0.97); filter: blur(4px); }
}
/* ═══════════════════════════════════════
INTRO: 3D SPINNING BOOK
═══════════════════════════════════════ */
.bp-intro {
justify-content: center;
}
.spin-scene {
perspective: 1200px;
}
.spin-book {
position: relative;
width: min(220px, 45vw);
aspect-ratio: 3 / 4;
transform-style: preserve-3d;
animation: book-spin 2.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.spin-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid hsl(20 8% 18%);
box-shadow: 0 20px 60px hsl(0 0% 0% / 0.5);
}
.spin-front img {
width: 200%;
height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.spin-back {
transform: rotateY(180deg);
}
.spin-back img {
width: 200%;
height: 100%;
object-fit: cover;
}
@keyframes book-spin {
0% { transform: rotateY(0deg) scale(0.65); opacity: 0; }
8% { opacity: 1; }
45% { transform: rotateY(180deg) scale(0.9); }
75% { transform: rotateY(320deg) scale(1); }
90% { transform: rotateY(352deg) scale(1); }
100% { transform: rotateY(360deg) scale(1); }
}
/* ═══════════════════════════════════════
COVER
═══════════════════════════════════════ */
.bp-cover {
justify-content: center;
text-align: center;
}
.cover-frame {
width: min(200px, 42vw);
aspect-ratio: 3 / 4;
border-radius: 0.625rem;
overflow: hidden;
border: 1px solid hsl(20 8% 18%);
box-shadow:
0 25px 60px hsl(0 0% 0% / 0.5),
0 0 40px hsl(var(--scene-h1) 60% 40% / 0.1);
margin-bottom: 2rem;
animation: cover-float 7s ease-in-out infinite;
}
.cover-img {
width: 200%; height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
@keyframes cover-float {
0%, 100% { transform: translateY(0) rotate(-0.5deg); }
50% { transform: translateY(-10px) rotate(0.5deg); }
}
.cover-title {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(1.75rem, 5vw, 2.75rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: 0.25rem;
}
.cover-sub {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(1rem, 3vw, 1.4rem);
color: hsl(20 8% 55%);
margin-bottom: 1.5rem;
}
.cover-desc {
font-size: 0.9rem;
color: hsl(20 8% 45%);
max-width: 26rem;
line-height: 1.65;
margin-bottom: 2rem;
}
.cover-cta {
display: inline-flex;
align-items: center;
padding: 0.75rem 2rem;
border-radius: 9999px;
background: hsl(var(--scene-h1) 70% 45%);
color: white;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 0 24px hsl(var(--scene-h1) 70% 45% / 0.3);
}
.cover-cta:hover {
background: hsl(var(--scene-h1) 70% 52%);
box-shadow: 0 0 36px hsl(var(--scene-h1) 70% 50% / 0.45);
transform: translateY(-2px);
} }
/* ═══════════════════════════════════════ /* ═══════════════════════════════════════
@@ -704,14 +577,15 @@ onUnmounted(() => {
max-width: 52rem; max-width: 52rem;
padding: 0 1rem; padding: 0 1rem;
gap: 0; gap: 0;
min-height: 0;
} }
/* ─── Top bar ─── */ /* ─── Top bar ─── */
.reader-bar { .reader-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.75rem 0; padding: 0.5rem 0;
width: 100%; width: 100%;
} }
.reader-bar-btn { .reader-bar-btn {
@@ -720,22 +594,28 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
width: 2.25rem; height: 2.25rem; width: 2.25rem; height: 2.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 8% / 0.5); background: transparent;
backdrop-filter: blur(8px); color: hsl(var(--color-text-muted));
color: hsl(20 8% 55%); border: none;
border: 1px solid hsl(20 8% 16% / 0.5);
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
flex-shrink: 0; flex-shrink: 0;
} }
.reader-bar-btn:hover { .reader-bar-btn:hover {
color: white; color: white;
background: hsl(var(--scene-h1) 50% 40% / 0.2); background: hsl(0 0% 100% / 0.06);
border-color: hsl(var(--scene-h1) 50% 50% / 0.3); }
.reader-bar-close {
color: hsl(0 0% 100% / 0.7);
background: hsl(0 0% 100% / 0.08);
}
.reader-bar-close:hover {
color: white;
background: hsl(0 70% 55% / 0.3);
} }
.reader-bar-title { .reader-bar-title {
flex: 1; 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-size: clamp(0.85rem, 2.5vw, 1.1rem);
font-weight: 600; font-weight: 600;
color: white; color: white;
@@ -750,7 +630,7 @@ onUnmounted(() => {
.reader-bar-pages { .reader-bar-pages {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -759,16 +639,16 @@ onUnmounted(() => {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 72; z-index: 72;
background: hsl(20 8% 3% / 0.6); background: hsl(var(--color-bg) / 0.6);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: flex;
} }
.sommaire-panel { .sommaire-panel {
width: min(300px, 80vw); width: min(300px, 80vw);
height: 100%; height: 100%;
background: hsl(20 8% 6% / 0.95); background: hsl(var(--color-surface) / 0.95);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border-right: 1px solid hsl(20 8% 14%); border-right: 1px solid hsl(var(--color-surface-light));
padding: 1.5rem; padding: 1.5rem;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
@@ -776,10 +656,10 @@ onUnmounted(() => {
gap: 0.25rem; gap: 0.25rem;
} }
.sommaire-title { .sommaire-title {
font-family: var(--font-display, 'Syne', sans-serif); font-family: var(--font-display);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: hsl(20 8% 45%); color: hsl(var(--color-text-muted));
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -791,7 +671,7 @@ onUnmounted(() => {
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(20 8% 55%); color: hsl(var(--color-text-muted));
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
@@ -800,7 +680,7 @@ onUnmounted(() => {
width: 100%; width: 100%;
} }
.sommaire-item:hover { .sommaire-item:hover {
background: hsl(20 8% 12%); background: hsl(var(--color-surface));
color: white; color: white;
} }
.sommaire-item--active { .sommaire-item--active {
@@ -816,8 +696,8 @@ onUnmounted(() => {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
background: hsl(20 8% 12%); background: hsl(var(--color-surface));
flex-shrink: 0; flex-shrink: 0;
} }
.sommaire-item--active .sommaire-num { .sommaire-item--active .sommaire-num {
@@ -844,12 +724,11 @@ onUnmounted(() => {
position: relative; position: relative;
flex: 1; flex: 1;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden auto;
border-radius: 0.75rem; border-radius: 0.75rem;
background: hsl(20 8% 5% / 0.4); background: hsl(var(--color-bg) / 0.4);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(20 8% 14% / 0.4);
} }
.reader-columns { .reader-columns {
@@ -860,6 +739,23 @@ onUnmounted(() => {
max-width: none; max-width: none;
} }
/* ─── Scroll mode overrides ─── */
.reader-viewport--scroll {
overflow: hidden auto;
min-height: 0;
}
.reader-columns--scroll {
height: auto;
column-fill: unset;
column-width: unset !important;
transition: none;
}
/* Lyrics: preserve line breaks from \n in text nodes */
.reader-columns :deep(p) {
white-space: pre-line;
}
/* Tighten prose for column context */ /* Tighten prose for column context */
.reader-columns :deep(h1) { .reader-columns :deep(h1) {
font-size: clamp(1.5rem, 3.5vw, 2rem); font-size: clamp(1.5rem, 3.5vw, 2rem);
@@ -880,12 +776,42 @@ onUnmounted(() => {
.reader-columns :deep(h3) { .reader-columns :deep(h3) {
break-after: avoid; break-after: avoid;
} }
.reader-columns :deep(p),
.reader-columns :deep(blockquote), .reader-columns :deep(blockquote),
.reader-columns :deep(ul), .reader-columns :deep(ul),
.reader-columns :deep(ol) { .reader-columns :deep(ol) {
break-inside: avoid; break-inside: avoid;
} }
/* Lyrics content */
.lyrics-content {
white-space: pre-line;
line-height: 1.9;
font-size: clamp(0.9rem, 2vw, 1.05rem);
}
.lyrics-content :deep(.lyrics-tag) {
display: block;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.35;
}
.lyrics-content :deep(p) {
break-inside: auto;
overflow-y: auto;
}
.lyrics-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.reader-columns :deep(p) {
break-inside: auto;
overflow-y: auto;
}
/* Page-turn shadow overlay */ /* Page-turn shadow overlay */
.reader-shadow { .reader-shadow {
@@ -896,7 +822,7 @@ onUnmounted(() => {
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
opacity: 0; 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; transition: opacity 0.15s;
border-radius: 0 0.75rem 0.75rem 0; border-radius: 0 0.75rem 0.75rem 0;
} }
@@ -910,7 +836,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
padding: 0.5rem 0; padding: 0.25rem 0;
gap: 1rem; gap: 1rem;
} }
.reader-nav-btn { .reader-nav-btn {
@@ -919,18 +845,16 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
width: 2.5rem; height: 2.5rem; width: 2.5rem; height: 2.5rem;
border-radius: 50%; border-radius: 50%;
background: hsl(20 8% 8% / 0.5); background: transparent;
backdrop-filter: blur(8px); color: hsl(var(--color-text-muted));
color: hsl(20 8% 55%); border: none;
border: 1px solid hsl(20 8% 16% / 0.5);
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
flex-shrink: 0; flex-shrink: 0;
} }
.reader-nav-btn:hover { .reader-nav-btn:hover {
background: hsl(var(--scene-h1) 60% 42% / 0.2); background: hsl(0 0% 100% / 0.06);
color: white; color: white;
border-color: hsl(var(--scene-h1) 60% 50% / 0.35);
} }
.reader-nav-btn--hidden { .reader-nav-btn--hidden {
opacity: 0; opacity: 0;
@@ -950,7 +874,7 @@ onUnmounted(() => {
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
flex-shrink: 0; 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); box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
} }
.reader-disc.spinning { .reader-disc.spinning {
@@ -967,12 +891,12 @@ onUnmounted(() => {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 0.5rem; height: 0.5rem; width: 0.5rem; height: 0.5rem;
border-radius: 50%; border-radius: 50%;
background: hsl(20 8% 5%); background: hsl(var(--color-bg));
border: 1.5px solid hsl(20 8% 18%); border: 1.5px solid hsl(var(--color-surface-light));
} }
.reader-song-name { .reader-song-name {
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 50%); color: hsl(var(--color-text-muted));
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+7 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<article class="prose"> <article class="prose prose-lyrics">
<ContentRenderer :value="content" /> <ContentRenderer :value="content" />
</article> </article>
</template> </template>
@@ -9,3 +9,9 @@ defineProps<{
content: any content: any
}>() }>()
</script> </script>
<style scoped>
.prose-lyrics :deep(p) {
white-space: pre-line;
}
</style>
+70 -4
View File
@@ -15,18 +15,28 @@
{{ description }} {{ description }}
</p> </p>
<!-- Songs + PDF actions row -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<!-- Associated songs badges --> <!-- Associated songs badges -->
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
<button <button
v-for="song in songs" v-for="song in songs"
:key="song.id" :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)" @click="playSong(song)"
> >
<div class="i-lucide-music h-3 w-3" /> <div class="i-lucide-music h-3 w-3" />
{{ song.title }} {{ song.title }}
</button> </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> </div>
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
</header> </header>
</template> </template>
@@ -44,9 +54,12 @@ const props = defineProps<{
const bookData = useBookData() const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer() const { loadAndPlay } = useAudioPlayer()
await bookData.init() bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug)) const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
const showPdf = ref(false)
function playSong(song: Song) { function playSong(song: Song) {
loadAndPlay(song) loadAndPlay(song)
@@ -57,6 +70,59 @@ function playSong(song: Song) {
.chapter-title { .chapter-title {
font-size: clamp(2rem, 5vw, 2.75rem); font-size: clamp(2rem, 5vw, 2.75rem);
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(12 76% 48% / 0.4); 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> </style>
+1 -1
View File
@@ -6,7 +6,7 @@
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
<li v-for="chapter in chapters" :key="chapter.path"> <li v-for="chapter in chapters" :key="chapter.path">
<NuxtLink <NuxtLink
:to="`/lire/${chapter.stem}`" :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" 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" active-class="bg-primary/10 text-primary font-medium"
> >
+159
View File
@@ -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>
+96 -9
View File
@@ -1,5 +1,56 @@
<template> <template>
<section class="section-padding"> <section class="relative overflow-hidden section-padding">
<!-- Shadok thinker: ovoid character sitting, hand on chin, thinking bubble -->
<svg class="shadok-thinker" viewBox="0 0 220 280" fill="none" aria-hidden="true">
<!-- Body (seated, leaning forward) -->
<ellipse cx="100" cy="160" rx="42" ry="50" fill="currentColor" opacity="0.85"/>
<!-- Head (tilted) -->
<ellipse cx="110" cy="95" rx="25" ry="24" fill="currentColor" opacity="0.8"/>
<!-- Neck -->
<path d="M100 118 Q105 110 108 105" stroke="currentColor" stroke-width="6" stroke-linecap="round" opacity="0.6" fill="none"/>
<!-- Eyes (contemplative) -->
<circle cx="103" cy="90" r="4.5" fill="currentColor" opacity="0.2"/>
<circle cx="120" cy="90" r="4.5" fill="currentColor" opacity="0.2"/>
<circle cx="104" cy="89" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="121" cy="89" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Arm to chin -->
<line x1="140" y1="145" x2="130" y2="108" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Hand on chin -->
<circle cx="130" cy="105" r="5" fill="currentColor" opacity="0.45"/>
<!-- Seated legs (crossed/bent) -->
<path d="M75 205 Q60 230 50 240" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6" fill="none"/>
<path d="M120 205 Q140 220 145 240" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6" fill="none"/>
<!-- Feet -->
<path d="M50 240 L38 243 M50 240 L45 246" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M145 240 L133 243 M145 240 L140 246" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<!-- Thinking bubbles -->
<circle cx="150" cy="78" r="5" fill="currentColor" opacity="0.3"/>
<circle cx="165" cy="62" r="8" fill="currentColor" opacity="0.25"/>
<circle cx="185" cy="42" r="12" fill="currentColor" opacity="0.2"/>
</svg>
<!-- Shadok menuisier: character with plane and plank -->
<svg class="shadok-menuisier" 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"/>
<path d="M98 82 Q120 68 142 82" fill="currentColor" opacity="0.35"/>
<rect x="100" y="80" width="40" height="5" rx="1" fill="currentColor" opacity="0.3"/>
<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="89" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="89" r="1.8" fill="currentColor" opacity="0.5"/>
<line x1="114" y1="103" x2="126" y2="103" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/>
<line x1="160" y1="145" x2="195" y2="160" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<rect x="190" y="155" width="30" height="12" rx="2" fill="currentColor" opacity="0.4"/>
<rect x="200" y="150" width="8" height="8" rx="1" fill="currentColor" opacity="0.35"/>
<line x1="80" y1="148" x2="50" y2="168" 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="35" y="170" width="80" height="8" rx="1" fill="currentColor" opacity="0.3"/>
<path d="M60 168 Q55 162 62 160" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M80 166 Q76 158 83 157" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.18"/>
</svg>
<div class="container-content"> <div class="container-content">
<div class="grid items-center gap-12 md:grid-cols-2"> <div class="grid items-center gap-12 md:grid-cols-2">
<!-- Book cover --> <!-- Book cover -->
@@ -63,10 +114,10 @@ const { data: content } = await usePageContent('home')
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-text) / 0.1);
box-shadow: box-shadow:
0 12px 40px hsl(0 0% 0% / 0.5), 0 12px 40px hsl(var(--color-text) / 0.15),
0 0 0 1px hsl(20 8% 15%); 0 0 0 1px hsl(var(--color-text) / 0.08);
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1), transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
box-shadow 0.5s ease; box-shadow 0.5s ease;
max-width: 360px; max-width: 360px;
@@ -75,18 +126,54 @@ const { data: content } = await usePageContent('home')
.book-cover-3d:hover { .book-cover-3d:hover {
transform: rotateY(-8deg) rotateX(3deg) scale(1.02); transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
box-shadow: box-shadow:
12px 16px 48px hsl(0 0% 0% / 0.6), 12px 16px 48px hsl(var(--color-text) / 0.2),
0 0 0 1px hsl(12 76% 48% / 0.2); 0 0 0 1px hsl(var(--color-primary) / 0.2);
} }
.book-cover-img { .book-cover-img {
width: 200%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
transform: translateX(-50%);
} }
.heading-section { .heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem); font-size: clamp(1.625rem, 4vw, 2.125rem);
} }
.shadok-thinker {
position: absolute;
right: 2%;
bottom: 6%;
width: clamp(110px, 14vw, 200px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-thinker 10s ease-in-out infinite;
}
@keyframes shadok-float-thinker {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.shadok-menuisier {
position: absolute;
left: 2%;
top: 5%;
width: clamp(100px, 13vw, 190px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-menuisier 10s ease-in-out infinite;
}
@keyframes shadok-float-menuisier {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
@media (max-width: 768px) {
.shadok-thinker { display: none; }
.shadok-menuisier { display: none; }
}
</style> </style>
+66 -37
View File
@@ -1,46 +1,47 @@
<template> <template>
<section class="section-padding"> <section :class="compact ? 'section-book-compact' : 'section-padding'">
<div class="container-content"> <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 --> <!-- Book cover -->
<UiScrollReveal> <UiScrollReveal>
<div class="book-cover-wrapper"> <div class="book-cover-wrapper relative">
<div class="book-cover-3d"> <div
<img :class="['book-cover-3d', compact && 'book-cover-3d--compact']"
:src="content?.book.coverImage" :style="{ backgroundImage: `url(${content?.book.coverImage})` }"
:alt="content?.book.coverAlt" role="img"
class="book-cover-img" :aria-label="content?.book.coverAlt"
/> />
</div> </div>
</div>
</UiScrollReveal> </UiScrollReveal>
<!-- Content + CTAs --> <!-- Content + CTAs -->
<div> <div>
<UiScrollReveal> <UiScrollReveal>
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p> <p :class="['font-mono tracking-widest text-accent uppercase', compact ? 'text-xs mb-1' : 'text-sm mb-2']">
<h2 class="heading-section font-display font-bold tracking-tight text-white"> {{ content?.book.kicker }}
{{ content?.book.title }} </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> </h2>
</UiScrollReveal> </UiScrollReveal>
<UiScrollReveal :delay="100"> <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 }} {{ content?.book.description }}
</p> </p>
</UiScrollReveal> </UiScrollReveal>
<UiScrollReveal :delay="200"> <UiScrollReveal :delay="200">
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4"> <BookActions
<UiBaseButton @click="$emit('open-player')"> :show-chapters="showChapters"
<div class="i-lucide-play mr-2 h-5 w-5" /> @open-player="$emit('open-player')"
{{ content?.book.cta.player }} @open-pdf="$emit('open-pdf')"
</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>
</UiScrollReveal> </UiScrollReveal>
</div> </div>
</div> </div>
@@ -49,15 +50,36 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{
showChapters?: boolean
compact?: boolean
}>(), {
showChapters: true,
compact: false,
})
defineEmits<{ defineEmits<{
'open-player': [] 'open-player': []
'open-pdf': [] 'open-pdf': []
}>() }>()
const { data: content } = await usePageContent('home') 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> </script>
<style scoped> <style scoped>
.section-book-compact {
padding: 0;
}
.book-cover-wrapper { .book-cover-wrapper {
perspective: 800px; perspective: 800px;
display: flex; display: flex;
@@ -65,33 +87,40 @@ const { data: content } = await usePageContent('home')
} }
.book-cover-3d { .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; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-text) / 0.1);
box-shadow: box-shadow:
0 12px 40px hsl(0 0% 0% / 0.5), 0 12px 40px hsl(var(--color-text) / 0.15),
0 0 0 1px hsl(20 8% 15%); 0 0 0 1px hsl(var(--color-text) / 0.08);
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1), transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
box-shadow 0.5s ease; box-shadow 0.5s ease;
max-width: 360px; }
.book-cover-3d--compact {
max-width: 120px;
} }
.book-cover-3d:hover { .book-cover-3d:hover {
transform: rotateY(-8deg) rotateX(3deg) scale(1.02); transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
box-shadow: box-shadow:
12px 16px 48px hsl(0 0% 0% / 0.6), 12px 16px 48px hsl(var(--color-text) / 0.2),
0 0 0 1px hsl(12 76% 48% / 0.2); 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); font-size: clamp(1.625rem, 4vw, 2.125rem);
} }
.book-heading--compact {
font-size: clamp(0.95rem, 2.5vw, 1.15rem);
line-height: 1.35;
}
</style> </style>
+53 -1
View File
@@ -1,5 +1,37 @@
<template> <template>
<section class="section-padding"> <section class="relative overflow-hidden section-padding">
<!-- Shadok scale: balance with absurd objects -->
<svg class="shadok-scale" viewBox="0 0 260 280" fill="none" aria-hidden="true">
<!-- Vertical pole -->
<line x1="130" y1="40" x2="130" y2="240" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
<!-- Base triangle -->
<polygon points="100,240 160,240 130,220" fill="currentColor" opacity="0.5"/>
<!-- Horizontal beam -->
<line x1="40" y1="80" x2="220" y2="60" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.7"/>
<!-- Pivot circle -->
<circle cx="130" cy="70" r="8" fill="currentColor" opacity="0.6"/>
<!-- Left pan (chain lines) -->
<line x1="40" y1="80" x2="30" y2="120" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<line x1="40" y1="80" x2="70" y2="120" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<!-- Left pan dish -->
<path d="M20 120 Q50 135 80 120" stroke="currentColor" stroke-width="2.5" fill="currentColor" opacity="0.35"/>
<!-- Absurd object on left: a snail -->
<ellipse cx="50" cy="112" rx="14" ry="8" fill="currentColor" opacity="0.5"/>
<path d="M60 108 Q68 95 58 92 Q48 90 52 100" stroke="currentColor" stroke-width="2" fill="none" opacity="0.4"/>
<!-- Right pan (chain lines) -->
<line x1="220" y1="60" x2="210" y2="100" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<line x1="220" y1="60" x2="250" y2="100" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<!-- Right pan dish -->
<path d="M200 100 Q230 115 260 100" stroke="currentColor" stroke-width="2.5" fill="currentColor" opacity="0.35"/>
<!-- Absurd object on right: a star/coin -->
<circle cx="230" cy="92" r="10" fill="currentColor" opacity="0.4"/>
<circle cx="230" cy="92" r="5" fill="currentColor" opacity="0.2"/>
<!-- Tiny Shadok perched on top -->
<ellipse cx="130" cy="35" rx="12" ry="10" fill="currentColor" opacity="0.6"/>
<circle cx="130" cy="22" r="7" fill="currentColor" opacity="0.55"/>
<circle cx="133" cy="20" r="2" fill="currentColor" opacity="0.3"/>
</svg>
<div class="container-content"> <div class="container-content">
<div class="mx-auto max-w-3xl text-center"> <div class="mx-auto max-w-3xl text-center">
<UiScrollReveal> <UiScrollReveal>
@@ -41,4 +73,24 @@ const { data: content } = await usePageContent('home')
.heading-section { .heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem); font-size: clamp(1.625rem, 4vw, 2.125rem);
} }
.shadok-scale {
position: absolute;
left: 2%;
top: 8%;
width: clamp(120px, 16vw, 240px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-scale 9s ease-in-out infinite;
}
@keyframes shadok-float-scale {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(2deg); }
}
@media (max-width: 768px) {
.shadok-scale { display: none; }
}
</style> </style>
-78
View File
@@ -1,78 +0,0 @@
<template>
<section class="section-padding">
<div class="container-content">
<UiScrollReveal>
<div class="gw-card">
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8">
<!-- 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 @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 { 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;
}
</style>
-57
View File
@@ -1,57 +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%)]" />
<!-- 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);
}
</style>
+42
View File
@@ -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>
+555
View File
@@ -0,0 +1,555 @@
<template>
<div
ref="root"
class="console"
:class="{ 'console--compact': compact, 'console--still': reduced }"
@pointerenter="hovered = true"
@pointerleave="hovered = false"
@focusin="hovered = true"
@focusout="hovered = false"
>
<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 }"
:style="{ '--hue': `var(${axis.hue})`, '--level': levels[axis.key] ?? 0, '--i': i }"
:aria-selected="axis.key === active"
:aria-controls="`panel-${uid}`"
:tabindex="axis.key === active ? 0 : -1"
@click="choose(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 ──────────────────────────────────────────────────────────────
// The cursors move on their own, slowly: each one drifts, and every few
// beats another axis is pushed up towards émancipation. Visitors watch it;
// they can still pick an axis (click, arrows), which stops the rotation.
// SSR renders every cursor at the bottom, so the first move is the rise.
const BEAT_MS = 2600
const BEATS_PER_AXIS = 3
const levels = reactive<Partial<Record<AxisKey, number>>>({})
const reduced = ref(false)
const hovered = ref(false)
const visible = ref(true)
const chosen = ref(false)
let beat = 0
let timer: ReturnType<typeof setInterval> | undefined
function place() {
props.axes.forEach((axis, i) => {
const isActive = axis.key === active.value
if (reduced.value) {
levels[axis.key] = isActive ? 0.94 : 0.3
return
}
const wave = Math.sin(beat * 0.9 + i * 2.1)
levels[axis.key] = isActive ? 0.87 + 0.06 * wave : 0.3 + 0.14 * wave
})
}
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) return
beat++
if (!chosen.value && !hovered.value && beat % BEATS_PER_AXIS === 0) advance()
place()
}
function choose(key: AxisKey) {
chosen.value = true
active.value = key
}
watch(active, place)
function onKeydown(e: KeyboardEvent) {
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']
if (!keys.includes(e.key)) return
e.preventDefault()
const idx = props.axes.findIndex(a => a.key === 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
choose(props.axes[next]!.key)
nextTick(() => tabs.value[next]?.focus())
}
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);
}
.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-fill,
.fader-name,
.fader-lever,
.panel-enter-active,
.panel-leave-active {
transition: none;
}
}
</style>
+362
View File
@@ -0,0 +1,362 @@
<template>
<div class="format">
<header v-if="format && showHeader" class="format-head">
<h2 class="format-title">{{ format.title }}</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">
<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 {
display: grid;
gap: 1.25rem;
}
@media (min-width: 1024px) {
.format-grid { grid-template-columns: 1.35fr 1fr; }
}
.format-card {
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>
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="va">
<!-- Hero : the headline, and the three cursors to push -->
<section class="va-hero">
<HomeShadoks />
<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>
<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>
</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
: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'
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')
</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-title {
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>
+154 -45
View File
@@ -1,62 +1,78 @@
<template> <template>
<div class="mt-16"> <section class="messages-section">
<div class="container-content messages-grid">
<!-- Formulaire --> <!-- Formulaire -->
<UiScrollReveal :delay="500"> <UiScrollReveal>
<div class="message-form-card"> <div class="message-form-card">
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3> <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"> <form v-if="!submitted" class="space-y-3" @submit.prevent="send">
<div class="grid gap-3 sm:grid-cols-2">
<input <input
v-model="form.author" v-model="form.author"
type="text" type="text"
placeholder="Votre nom *" placeholder="Votre nom"
required
class="msg-input" class="msg-input"
/> />
<input <p class="hint-text text-xs -mt-1 px-1">Pour recevoir une réponse, laissez votre e-mail dans le message.</p>
v-model="form.email" <select v-model="form.type" class="msg-input msg-input--select">
type="email" <option value="question">Question</option>
placeholder="Email (optionnel)" <option value="suggestion">Suggestion</option>
class="msg-input" <option value="retour">Retour d'expérience</option>
/> </select>
</div>
<textarea <textarea
v-model="form.text" v-model="form.text"
placeholder="Votre message *" placeholder="Votre message"
required
rows="3" rows="3"
class="msg-input resize-none" class="msg-input resize-none"
/> />
<div class="flex justify-end"> <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" /> <div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
Envoyer Envoyer
</button> </button>
</div> </div>
</form> </form>
<div v-else class="text-center py-4"> <div v-else class="text-center py-6">
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" /> <div class="i-lucide-heart-handshake h-10 w-10 text-primary mx-auto mb-3" />
<p class="text-white/80">Merci pour votre message !</p> <p class="msg-confirm-title font-display text-lg font-semibold">Merci pour votre message !</p>
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</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>
</div> </div>
</UiScrollReveal> </UiScrollReveal>
<!-- 2 derniers messages publiés --> <!-- 2 derniers messages publiés -->
<UiScrollReveal v-if="messages?.length" :delay="600"> <UiScrollReveal v-if="messages?.length" :delay="100">
<div class="mt-8 space-y-4"> <div class="space-y-4">
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3> <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"> <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> <!-- En-tête auteur -->
<div class="mt-2 flex items-center gap-2 text-xs text-white/40"> <div class="flex items-center gap-2 mb-2">
<span class="font-semibold text-white/60">{{ msg.author }}</span> <span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
<span>&middot;</span> <span class="type-pill">{{ typeLabel(msg.type) }}</span>
<span>{{ formatDate(msg.createdAt) }}</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>
<div class="text-center"> </div>
<div>
<NuxtLink to="/messages" class="btn-ghost text-sm"> <NuxtLink to="/messages" class="btn-ghost text-sm">
Voir tous les messages Voir tous les messages
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" /> <div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
@@ -65,16 +81,31 @@
</div> </div>
</UiScrollReveal> </UiScrollReveal>
</div> </div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: messages } = await useFetch('/api/messages') 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 sending = ref(false)
const submitted = 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() { async function send() {
if (!canSend.value) return
sending.value = true sending.value = true
try { try {
await $fetch('/api/messages', { method: 'POST', body: form }) await $fetch('/api/messages', { method: 'POST', body: form })
@@ -105,37 +136,115 @@ function formatDate(iso: string) {
</script> </script>
<style scoped> <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 { .message-form-card {
background: hsl(20 8% 6%); background: hsl(var(--color-surface));
border: 1px solid hsl(20 8% 14%); border-radius: 16px;
border-radius: 0.75rem;
padding: 1.5rem; padding: 1.5rem;
} }
.msg-input { .msg-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; min-height: 2.5rem;
border-radius: 0.5rem; padding: 0.55rem 0.85rem;
border: 1px solid hsl(20 8% 18%); border-radius: 12px;
background: hsl(20 8% 4%); border: none;
color: white; background: hsl(var(--color-bg));
font-size: 0.875rem; color: hsl(var(--color-text));
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 { .msg-input::placeholder {
color: hsl(20 8% 40%); color: hsl(var(--color-text) / 0.35);
} }
.msg-input:focus { .msg-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.5);
} }
/* ── Carte message ── */
.message-card { .message-card {
background: hsl(20 8% 6%); background: hsl(var(--color-surface));
border: 1px solid hsl(20 8% 14%); border-radius: 16px;
border-radius: 0.75rem;
padding: 1rem 1.25rem; 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> </style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div class="shadoks" aria-hidden="true">
<!-- 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 -->
<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>
</div>
</template>
<style scoped>
.shadoks {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.shadok-bird {
position: absolute;
right: 3%;
top: 6%;
width: clamp(90px, 10vw, 150px);
opacity: 0.22;
color: hsl(var(--axis-num));
animation: shadok-float 8s ease-in-out infinite;
}
.shadok-boulanger {
position: absolute;
left: 1.5%;
bottom: 4%;
width: clamp(80px, 9vw, 140px);
opacity: 0.2;
color: hsl(var(--axis-eco));
animation: shadok-float 9s ease-in-out infinite reverse;
}
@keyframes shadok-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadoks { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.shadok-bird, .shadok-boulanger { animation: none; }
}
</style>
+198
View File
@@ -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>
+241
View File
@@ -0,0 +1,241 @@
<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}`"
>
<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 { HomeAxis } from '~/composables/useHomeData'
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
}>(), {
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;
}
}
.toolbox-axis {
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>
+53 -1
View File
@@ -1,5 +1,37 @@
<template> <template>
<section class="section-padding bg-surface-600/50"> <section class="relative overflow-hidden section-padding bg-surface-600/50">
<!-- Shadok musician: round character playing a trumpet -->
<svg class="shadok-musician" viewBox="0 0 220 280" fill="none" aria-hidden="true">
<!-- Body (ovoid) -->
<ellipse cx="100" cy="150" rx="45" ry="55" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="100" cy="80" r="28" fill="currentColor" opacity="0.8"/>
<!-- Eyes -->
<circle cx="90" cy="74" r="5" fill="currentColor" opacity="0.2"/>
<circle cx="110" cy="74" r="5" fill="currentColor" opacity="0.2"/>
<circle cx="91" cy="73" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="111" cy="73" r="2" fill="currentColor" opacity="0.5"/>
<!-- Mouth (blowing) -->
<circle cx="125" cy="86" r="4" fill="currentColor" opacity="0.3"/>
<!-- Trumpet -->
<line x1="128" y1="86" x2="185" y2="78" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
<path d="M185 68 Q200 78 185 88" stroke="currentColor" stroke-width="3" fill="currentColor" opacity="0.45"/>
<!-- Trumpet valves -->
<circle cx="155" cy="80" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="165" cy="79" r="3" fill="currentColor" opacity="0.3"/>
<!-- Music notes floating -->
<circle cx="205" cy="60" r="4" fill="currentColor" opacity="0.4"/>
<line x1="209" y1="60" x2="209" y2="42" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>
<circle cx="195" cy="45" r="3" fill="currentColor" opacity="0.3"/>
<line x1="198" y1="45" x2="198" y2="30" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
<!-- Legs -->
<line x1="82" y1="202" x2="72" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="118" y1="202" x2="128" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Feet -->
<path d="M72 255 L58 258 M72 255 L66 261" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M128 255 L114 258 M128 255 L122 261" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
</svg>
<div class="container-content"> <div class="container-content">
<UiScrollReveal> <UiScrollReveal>
<div class="text-center mb-12"> <div class="text-center mb-12">
@@ -48,4 +80,24 @@ const featuredSongs = computed(() => bookData.getSongs().slice(0, 6))
.heading-section { .heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem); font-size: clamp(1.625rem, 4vw, 2.125rem);
} }
.shadok-musician {
position: absolute;
right: 2%;
top: 4%;
width: clamp(110px, 15vw, 210px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-musician 8s ease-in-out infinite;
}
@keyframes shadok-float-musician {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadok-musician { display: none; }
}
</style> </style>
+2 -2
View File
@@ -11,7 +11,7 @@
<Transition name="slide-menu"> <Transition name="slide-menu">
<nav <nav
v-if="open" v-if="open"
class="fixed inset-y-0 right-0 z-50 w-72 bg-surface-600 border-l border-white/8 p-6 shadow-2xl" class="fixed inset-y-0 right-0 z-50 w-72 bg-[hsl(var(--color-surface))] border-l border-[hsl(var(--color-text)/0.1)] p-6 shadow-2xl"
aria-label="Menu mobile" aria-label="Menu mobile"
> >
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
@@ -29,7 +29,7 @@
<li v-for="item in nav" :key="item.to"> <li v-for="item in nav" :key="item.to">
<NuxtLink <NuxtLink
:to="item.to" :to="item.to"
class="flex items-center gap-3 rounded-lg px-4 py-3 text-base font-medium transition-colors hover:bg-white/5" class="flex items-center gap-3 rounded-lg px-4 py-3 text-base font-medium text-[hsl(var(--color-text)/0.7)] transition-colors hover:bg-[hsl(var(--color-text)/0.06)] hover:text-[hsl(var(--color-text))]"
active-class="bg-primary/10 text-primary" active-class="bg-primary/10 text-primary"
@click="emit('update:open', false)" @click="emit('update:open', false)"
> >
+61 -4
View File
@@ -1,9 +1,48 @@
<template> <template>
<footer class="border-t border-white/8 bg-surface-600 pb-[var(--player-height)]"> <footer class="footer-wrap border-t border-[hsl(var(--color-text)/0.1)] bg-[hsl(var(--color-surface))]">
<div class="container-content px-4 py-8"> <!-- 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>
<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"> <div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
<!-- Credits --> <!-- Credits -->
<p class="text-sm text-white/40"> <p class="text-sm text-[hsl(var(--color-text)/0.4)]">
{{ site?.footer.credits }} {{ site?.footer.credits }}
</p> </p>
@@ -13,7 +52,7 @@
v-for="link in site?.footer.links" v-for="link in site?.footer.links"
:key="link.to" :key="link.to"
:to="link.to" :to="link.to"
class="text-sm text-white/40 transition-colors hover:text-white/70" class="text-sm text-[hsl(var(--color-text)/0.4)] transition-colors hover:text-[hsl(var(--color-text)/0.7)]"
> >
{{ link.label }} {{ link.label }}
</NuxtLink> </NuxtLink>
@@ -26,3 +65,21 @@
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() const { data: site } = await useSiteContent()
</script> </script>
<style scoped>
.footer-wrap {
position: relative;
overflow: hidden;
}
.footer-shadok-pattern {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: auto;
opacity: 1;
pointer-events: none;
color: hsl(var(--color-primary));
}
</style>
+99 -8
View File
@@ -1,23 +1,40 @@
<template> <template>
<header class="sticky top-0 z-40 border-b border-white/8 bg-surface-bg/80 backdrop-blur-xl"> <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"> <div class="container-content flex h-[var(--header-height)] items-center justify-between px-4">
<!-- Logo --> <!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-2 font-display text-lg font-bold tracking-tight"> <NuxtLink to="/" class="logo-link flex items-center gap-2.5">
<div class="i-lucide-book-open h-6 w-6 text-primary" /> <UiSectionSign class="logo-icon" />
<span class="text-gradient">{{ site?.identity.name }}</span> <span class="logo-text">{{ site?.identity.name }}</span>
</NuxtLink> </NuxtLink>
<!-- Desktop navigation --> <!-- Desktop navigation -->
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale"> <nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
<!-- Autonomie : prefix + axis buttons -->
<span class="nav-prefix">Autonomie&thinsp;:</span>
<NuxtLink <NuxtLink
v-for="item in site?.navigation" v-for="(item, i) in axes"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="btn-ghost text-sm" class="btn-ghost text-sm nav-axis"
active-class="text-white! bg-white/5" :style="{ '--hue': `var(${AXIS_HUES[i] ?? '--color-primary'})` }"
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
> >
{{ item.label }} {{ item.label }}
</NuxtLink> </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> </nav>
<!-- Mobile menu button --> <!-- Mobile menu button -->
@@ -31,11 +48,85 @@
</div> </div>
<!-- Mobile menu --> <!-- Mobile menu -->
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" /> <LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="allNav" />
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: site } = await useSiteContent() const { data: site } = await useSiteContent()
const isMobileMenuOpen = ref(false) 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> </script>
<style scoped>
.logo-link {
transition: opacity 0.2s;
}
.logo-link:hover {
opacity: 0.8;
}
.logo-icon {
width: auto;
height: 2.1rem;
flex-shrink: 0;
transform: rotate(-8deg);
transition: transform 0.2s ease;
}
.logo-link:hover .logo-icon {
transform: rotate(0deg) scale(1.08);
}
@media (prefers-reduced-motion: reduce) {
.logo-icon { transition: none; }
}
.logo-text {
font-family: var(--font-logo);
font-weight: 400;
font-size: 1.25rem;
letter-spacing: 0.04em;
background-image: linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-accent)));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-prefix {
font-family: var(--font-display);
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);
}
</style>
+393 -63
View File
@@ -2,41 +2,43 @@
<Transition name="player-slide"> <Transition name="player-slide">
<div <div
v-if="store.currentSong" v-if="store.currentSong"
class="player-bar fixed inset-x-0 bottom-0 z-70 border-t border-white/8 bg-surface-600/80 backdrop-blur-xl" ref="widgetRef"
class="mini-player"
> >
<!-- Expanded panel --> <!-- ═══ EXPANDED PANEL ═══ -->
<Transition name="panel-expand"> <Transition name="panel-expand">
<div v-if="store.isExpanded" class="border-b border-white/8"> <div v-if="isExpanded" class="mini-panel">
<div class="container-content grid gap-4 p-4 md:grid-cols-2"> <!-- Track info + visualizer -->
<div class="panel-top">
<div class="panel-track">
<p class="panel-title">{{ store.currentSong.title }}</p>
<p class="panel-artist">{{ store.currentSong.artist }}</p>
</div>
<div class="panel-viz">
<KeepAlive>
<PlayerVisualizer /> <PlayerVisualizer />
<PlayerPlaylist /> </KeepAlive>
</div> </div>
</div> </div>
</Transition>
<!-- Progress bar (top of player) --> <!-- Progress -->
<div class="panel-progress">
<PlayerProgress /> <PlayerProgress />
<div class="panel-times">
<!-- Main player bar --> <span>{{ store.formattedCurrentTime }}</span>
<div class="container-content flex items-center gap-4 px-4 py-2"> <span>{{ store.formattedDuration }}</span>
<!-- Track info --> </div>
<div class="flex-1 min-w-0">
<PlayerTrackInfo />
</div> </div>
<!-- Controls --> <!-- Controls -->
<div class="flex items-center gap-4"> <div class="panel-controls">
<PlayerControls /> <PlayerControls />
</div> </div>
<!-- Right section: mode + volume + expand -->
<div class="hidden md:flex items-center gap-3 flex-shrink-0">
<PlayerModeToggle />
<!-- Volume --> <!-- Volume -->
<div class="flex items-center gap-2"> <div class="panel-volume-row">
<button class="btn-ghost !p-1" @click="toggleMute"> <button class="panel-vol-btn" @click="toggleMute">
<div :class="volumeIcon" class="h-4 w-4" /> <div :class="volumeIcon" class="h-3.5 w-3.5" />
</button> </button>
<input <input
type="range" type="range"
@@ -44,39 +46,101 @@
max="1" max="1"
step="0.01" step="0.01"
:value="store.volume" :value="store.volume"
class="volume-slider w-20" class="volume-slider"
@input="handleVolumeChange" @input="handleVolumeChange"
> >
</div> </div>
<!-- Time display --> <!-- Lyrics (collapsed by default, available for standalone use) -->
<span class="font-mono text-xs text-white/40 w-24 text-center"> <div v-if="store.currentSong.lyrics && showLyrics" class="panel-lyrics">
{{ store.formattedCurrentTime }} / {{ store.formattedDuration }} <pre class="panel-lyrics-text">{{ store.currentSong.lyrics }}</pre>
</span>
<!-- Expand toggle -->
<button
class="btn-ghost !p-2"
:aria-label="store.isExpanded ? 'Réduire' : 'Développer'"
@click="store.toggleExpanded()"
>
<div :class="store.isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" />
</button>
</div> </div>
<button
v-if="store.currentSong.lyrics"
class="panel-lyrics-toggle"
@click="showLyrics = !showLyrics"
>
<div :class="showLyrics ? 'i-lucide-chevron-up' : 'i-lucide-text'" class="h-3 w-3" />
{{ showLyrics ? 'Masquer les paroles' : 'Paroles' }}
</button>
<!-- Playlist -->
<div class="panel-playlist">
<PlayerPlaylist />
</div>
</div>
</Transition>
<!-- ═══ COMPACT PILL ═══ -->
<div class="mini-pill" @click="toggleExpanded">
<!-- Progress ring -->
<div class="pill-ring">
<svg viewBox="0 0 36 36" class="pill-ring-svg">
<circle
cx="18" cy="18" r="16"
fill="none"
stroke="hsl(0 0% 100% / 0.06)"
stroke-width="2"
/>
<circle
cx="18" cy="18" r="16"
fill="none"
stroke="hsl(12 76% 48%)"
stroke-width="2"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="circumference - (circumference * store.progress / 100)"
class="pill-ring-progress"
/>
</svg>
</div>
<!-- Title -->
<span class="pill-title">{{ store.currentSong.title }}</span>
<!-- Play/Pause -->
<button
class="pill-play"
:aria-label="store.isPlaying ? 'Pause' : 'Lecture'"
@click.stop="togglePlayPause"
>
<div :class="store.isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" class="h-4 w-4" />
</button>
<!-- Next -->
<button class="pill-next" aria-label="Suivant" @click.stop="playNext" :disabled="!store.hasNext">
<div class="i-lucide-skip-forward h-3.5 w-3.5" />
</button>
<!-- Expand -->
<button
class="pill-expand"
:aria-label="isExpanded ? 'Réduire' : 'Développer'"
@click.stop="toggleExpanded"
>
<div :class="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" />
</button>
</div> </div>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
const store = usePlayerStore() const store = usePlayerStore()
const { setVolume } = useAudioPlayer() const { setVolume, togglePlayPause, playNext } = useAudioPlayer()
// Initialize media session
useMediaSession() useMediaSession()
useKeyboardShortcuts()
const widgetRef = ref<HTMLElement>()
const isExpanded = ref(false)
const showLyrics = ref(false)
let previousVolume = 0.8 let previousVolume = 0.8
const circumference = 2 * Math.PI * 16
const volumeIcon = computed(() => { const volumeIcon = computed(() => {
if (store.volume === 0) return 'i-lucide-volume-x' if (store.volume === 0) return 'i-lucide-volume-x'
if (store.volume < 0.3) return 'i-lucide-volume' if (store.volume < 0.3) return 'i-lucide-volume'
@@ -98,62 +162,328 @@ function toggleMute() {
setVolume(previousVolume) setVolume(previousVolume)
} }
} }
function toggleExpanded() {
isExpanded.value = !isExpanded.value
}
onClickOutside(widgetRef, () => {
if (isExpanded.value) isExpanded.value = false
})
</script> </script>
<style scoped> <style scoped>
.player-slide-enter-active, /* ═══════════════════════════════════════
.player-slide-leave-active { POSITION
transition: transform 0.3s var(--ease-out-expo); ═══════════════════════════════════════ */
.mini-player {
position: fixed;
bottom: 1rem;
transition: bottom 0.3s ease;
right: max(1rem, calc((100vw - 80rem) / 2));
z-index: 70;
display: flex;
flex-direction: column;
align-items: flex-end;
} }
.player-slide-enter-from, /* Book player open: stay above its navigation bar */
.player-slide-leave-to { :global(html.book-player-open .mini-player) {
transform: translateY(100%); bottom: 5.5rem;
} }
.panel-expand-enter-active, /* ═══════════════════════════════════════
.panel-expand-leave-active { PILL
═══════════════════════════════════════ */
.mini-pill {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.375rem 0.5rem 0.375rem 0.5rem;
border-radius: 9999px;
background: hsl(20 8% 7% / 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
cursor: pointer;
transition: all 0.3s var(--ease-out-expo); transition: all 0.3s var(--ease-out-expo);
box-shadow: 0 4px 20px hsl(0 0% 0% / 0.35);
}
.mini-pill:hover {
background: hsl(20 8% 9% / 0.96);
}
/* Progress ring */
.pill-ring {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
}
.pill-ring-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.pill-ring-progress {
transition: stroke-dashoffset 0.3s ease;
}
/* Title */
.pill-title {
font-size: 0.8rem;
font-weight: 500;
color: hsl(0 0% 100% / 0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
/* Play/Pause — white circle */
.pill-play {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: white;
border: none;
color: hsl(20 8% 6%);
cursor: pointer;
transition: transform 0.15s var(--ease-out-expo);
flex-shrink: 0;
}
.pill-play:hover { transform: scale(1.08); }
.pill-play:active { transform: scale(0.94); }
/* Next */
.pill-next {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: transparent;
border: none;
color: hsl(0 0% 100% / 0.6);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.pill-next:hover { color: white; }
.pill-next:disabled { opacity: 0.3; cursor: default; }
/* Expand chevron */
.pill-expand {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: hsl(0 0% 100% / 0.08);
border: none;
color: hsl(0 0% 100% / 0.5);
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.pill-expand:hover { color: hsl(0 0% 100% / 0.9); background: hsl(0 0% 100% / 0.15); }
/* ═══════════════════════════════════════
PANEL
═══════════════════════════════════════ */
.mini-panel {
width: 360px;
margin-bottom: 0.5rem;
border-radius: 1rem;
background: hsl(20 8% 6% / 0.94);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
box-shadow: 0 8px 40px hsl(0 0% 0% / 0.4);
overflow: hidden; overflow: hidden;
} }
.panel-expand-enter-from, /* ─── Track + visualizer ─── */
.panel-expand-leave-to { .panel-top {
max-height: 0; padding: 1rem 1.25rem 0.5rem;
opacity: 0; }
.panel-track {
margin-bottom: 0.5rem;
}
.panel-title {
font-size: 0.95rem;
font-weight: 600;
color: white;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-artist {
font-size: 0.75rem;
color: hsl(0 0% 100% / 0.35);
margin-top: 0.125rem;
}
.panel-viz {
opacity: 0.6;
} }
.panel-expand-enter-to, /* ─── Progress ─── */
.panel-expand-leave-from { .panel-progress {
max-height: 400px; padding: 0.5rem 1.25rem 0;
opacity: 1;
} }
.panel-times {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.625rem;
color: hsl(0 0% 100% / 0.25);
letter-spacing: 0.02em;
}
/* ─── Controls ─── */
.panel-controls {
display: flex;
justify-content: center;
padding: 0.25rem 1.25rem 0.375rem;
}
/* ─── Volume ─── */
.panel-volume-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1.25rem 0.75rem;
}
.panel-vol-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: hsl(0 0% 100% / 0.35);
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.panel-vol-btn:hover { color: hsl(0 0% 100% / 0.7); }
.volume-slider { .volume-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 4px; flex: 1;
background: hsl(0 0% 100% / 0.15); height: 3px;
background: hsl(0 0% 100% / 0.08);
border-radius: 2px; border-radius: 2px;
outline: none; outline: none;
} }
.volume-slider::-webkit-slider-thumb { .volume-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 12px; width: 10px;
height: 12px; height: 10px;
background: white; background: hsl(0 0% 100% / 0.7);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: transform 0.15s;
} }
.volume-slider::-webkit-slider-thumb:hover { transform: scale(1.3); }
.volume-slider::-moz-range-thumb { .volume-slider::-moz-range-thumb {
width: 12px; width: 10px;
height: 12px; height: 10px;
background: white; background: hsl(0 0% 100% / 0.7);
border: none; border: none;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
} }
/* ─── Lyrics ─── */
.panel-lyrics {
max-height: 160px;
overflow-y: auto;
padding: 0.75rem 1.25rem;
border-top: 1px solid hsl(0 0% 100% / 0.04);
}
.panel-lyrics-text {
font-family: var(--font-sans, sans-serif);
font-size: 0.75rem;
line-height: 1.6;
color: hsl(0 0% 100% / 0.4);
white-space: pre-wrap;
margin: 0;
}
/* ─── Lyrics toggle ─── */
.panel-lyrics-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem;
border: none;
border-top: 1px solid hsl(0 0% 100% / 0.04);
background: none;
color: hsl(0 0% 100% / 0.25);
font-size: 0.65rem;
cursor: pointer;
transition: color 0.15s;
}
.panel-lyrics-toggle:hover {
color: hsl(0 0% 100% / 0.5);
}
/* ─── Playlist ─── */
.panel-playlist {
max-height: 200px;
overflow-y: auto;
border-top: 1px solid hsl(0 0% 100% / 0.04);
}
/* ═══════════════════════════════════════
TRANSITIONS
═══════════════════════════════════════ */
.player-slide-enter-active,
.player-slide-leave-active {
transition: transform 0.35s var(--ease-out-expo), opacity 0.35s var(--ease-out-expo);
}
.player-slide-enter-from,
.player-slide-leave-to {
transform: translateY(16px);
opacity: 0;
}
.panel-expand-enter-active,
.panel-expand-leave-active {
transition: all 0.35s var(--ease-out-expo);
overflow: hidden;
}
.panel-expand-enter-from,
.panel-expand-leave-to {
max-height: 0;
opacity: 0;
transform: translateY(8px);
}
.panel-expand-enter-to,
.panel-expand-leave-from {
max-height: 800px;
opacity: 1;
transform: translateY(0);
}
/* ═══════════════════════════════════════
MOBILE
═══════════════════════════════════════ */
@media (max-width: 768px) {
.mini-player {
right: 0.75rem;
left: 0.75rem;
align-items: stretch;
}
.mini-panel { width: auto; }
.pill-title { max-width: none; flex: 1; }
}
</style> </style>
+2 -2
View File
@@ -3,7 +3,7 @@
<span <span
v-for="song in songs" v-for="song in songs"
:key="song.id" :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" /> <div class="i-lucide-music h-2.5 w-2.5" />
{{ song.title }} {{ song.title }}
@@ -17,7 +17,7 @@ const props = defineProps<{
}>() }>()
const bookData = useBookData() const bookData = useBookData()
await bookData.init() bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug)) const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
</script> </script>
+27
View File
@@ -1,4 +1,5 @@
<template> <template>
<div class="song-item-wrapper">
<div <div
class="card-surface flex cursor-pointer items-center gap-4" class="card-surface flex cursor-pointer items-center gap-4"
:class="{ 'border-primary/40! shadow-primary/10!': isCurrent }" :class="{ 'border-primary/40! shadow-primary/10!': isCurrent }"
@@ -34,6 +35,12 @@
{{ formatDuration(song.duration) }} {{ formatDuration(song.duration) }}
</span> </span>
</div> </div>
<!-- Lyrics panel (always visible) -->
<div v-if="song.lyrics" class="lyrics-panel">
<pre class="lyrics-text">{{ song.lyrics }}</pre>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -63,3 +70,23 @@ function formatDuration(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}` return `${mins}:${secs.toString().padStart(2, '0')}`
} }
</script> </script>
<style scoped>
.lyrics-panel {
margin-top: 0.25rem;
padding: 1rem 1.25rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-text) / 0.08);
max-height: 24rem;
overflow-y: auto;
}
.lyrics-text {
white-space: pre-wrap;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.7;
color: hsl(var(--color-text) / 0.6);
}
</style>
+1 -35
View File
@@ -1,21 +1,9 @@
<template> <template>
<div v-if="song.lyrics" class="rounded-xl bg-surface p-6"> <div v-if="song.lyrics" class="rounded-xl bg-surface p-6">
<button
class="flex w-full items-center justify-between text-left"
@click="isOpen = !isOpen"
>
<span class="font-display text-sm font-semibold text-white/70">Paroles</span> <span class="font-display text-sm font-semibold text-white/70">Paroles</span>
<div <div class="mt-4 max-h-96 overflow-y-auto">
:class="isOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="h-4 w-4 text-white/40 transition-transform"
/>
</button>
<Transition name="lyrics-expand">
<div v-if="isOpen" class="mt-4">
<pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-white/60">{{ song.lyrics }}</pre> <pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-white/60">{{ song.lyrics }}</pre>
</div> </div>
</Transition>
</div> </div>
</template> </template>
@@ -25,26 +13,4 @@ import type { Song } from '~/types/song'
defineProps<{ defineProps<{
song: Song song: Song
}>() }>()
const isOpen = ref(false)
</script> </script>
<style scoped>
.lyrics-expand-enter-active,
.lyrics-expand-leave-active {
transition: all 0.3s var(--ease-out-expo);
overflow: hidden;
}
.lyrics-expand-enter-from,
.lyrics-expand-leave-to {
max-height: 0;
opacity: 0;
}
.lyrics-expand-enter-to,
.lyrics-expand-leave-from {
max-height: 500px;
opacity: 1;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<template>
<div class="settings-selector" ref="selectorRef">
<button
class="settings-trigger"
aria-label="Réglages d'affichage"
@click="isOpen = !isOpen"
>
<div class="i-lucide-settings h-5 w-5" />
</button>
<Transition name="settings-dropdown">
<div v-if="isOpen" class="settings-dropdown">
<h4 class="settings-title">Affichage</h4>
<!-- Palette grid : 4 saisons -->
<div class="settings-section">
<span class="settings-label">Ambiance</span>
<div class="settings-palette-grid">
<button
v-for="name in paletteNames"
:key="name"
class="settings-palette-btn"
:class="{
'settings-palette-btn--active': paletteStore.currentPalette === name,
'settings-palette-btn--light': paletteStore.palettes[name].isLight,
}"
:title="paletteStore.palettes[name].label"
@click="paletteStore.setPalette(name)"
>
<span class="settings-palette-preview">
<span class="settings-palette-dot" :style="{ background: `hsl(${paletteStore.palettes[name].primary})` }" />
<span class="settings-palette-dot" :style="{ background: `hsl(${paletteStore.palettes[name].accent})` }" />
</span>
<span class="settings-palette-info">
<span class="settings-palette-name">{{ paletteStore.palettes[name].label }}</span>
<span class="settings-palette-mode">{{ paletteStore.palettes[name].isLight ? 'Clair' : 'Sombre' }}</span>
</span>
</button>
</div>
</div>
<!-- Font size -->
<div class="settings-section">
<span class="settings-label">Taille texte</span>
<div class="settings-toggle-group">
<button
v-for="size in fontSizes"
:key="size.value"
class="settings-toggle"
:class="{ 'settings-toggle--active': currentFontSize === size.value }"
@click="setFontSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import type { PaletteName } from '~/stores/palette'
const paletteStore = usePaletteStore()
const selectorRef = ref<HTMLElement>()
const isOpen = ref(false)
const paletteNames: PaletteName[] = ['automne', 'hiver', 'printemps', 'ete']
const currentFontSize = ref(
(import.meta.client && localStorage.getItem('fontSize')) || 'normal',
)
const fontSizes = [
{ label: 'A-', value: 'small' },
{ label: 'A', value: 'normal' },
{ label: 'A+', value: 'large' },
]
function setFontSize(size: string) {
currentFontSize.value = size
if (import.meta.client) {
localStorage.setItem('fontSize', size)
const root = document.documentElement
const map: Record<string, string> = { small: '14px', normal: '16px', large: '18px' }
root.style.fontSize = map[size] || '16px'
}
}
// Apply font size on mount
onMounted(() => {
const saved = localStorage.getItem('fontSize')
if (saved) setFontSize(saved)
})
onClickOutside(selectorRef, () => { isOpen.value = false })
</script>
<style scoped>
.settings-selector {
position: relative;
}
.settings-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
color: hsl(var(--color-text) / 0.7);
transition: all 0.2s;
}
.settings-trigger:hover {
color: hsl(var(--color-text));
background: hsl(var(--color-text) / 0.1);
}
.settings-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: 260px;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
backdrop-filter: blur(16px);
border: 1px solid hsl(var(--color-text) / 0.08);
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.3);
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 50;
}
.settings-title {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: hsl(var(--color-text) / 0.4);
margin: 0;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.settings-label {
font-size: 0.7rem;
font-weight: 600;
color: hsl(var(--color-text) / 0.5);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-toggle-group {
display: flex;
gap: 0.25rem;
background: hsl(var(--color-text) / 0.04);
border-radius: 0.5rem;
padding: 0.125rem;
}
.settings-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-text) / 0.5);
transition: all 0.15s;
}
.settings-toggle:hover {
color: hsl(var(--color-text) / 0.8);
}
.settings-toggle--active {
background: hsl(var(--color-primary));
color: white;
box-shadow: 0 1px 4px hsl(var(--color-primary) / 0.3);
}
.settings-palette-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.settings-palette-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
transition: all 0.2s;
color: hsl(var(--color-text) / 0.6);
background: hsl(var(--color-text) / 0.02);
}
.settings-palette-btn:hover {
background: hsl(var(--color-text) / 0.06);
color: hsl(var(--color-text) / 0.9);
}
.settings-palette-btn--active {
background: hsl(var(--color-text) / 0.1);
color: hsl(var(--color-text));
box-shadow: inset 0 0 0 1.5px hsl(var(--color-primary) / 0.5);
}
.settings-palette-preview {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
}
.settings-palette-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.25);
}
.settings-palette-info {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.settings-palette-name {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.settings-palette-mode {
font-size: 0.6rem;
opacity: 0.5;
line-height: 1.2;
}
.settings-dropdown-enter-active {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.settings-dropdown-leave-active {
transition: all 0.15s ease;
}
.settings-dropdown-enter-from,
.settings-dropdown-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<template>
<!-- § — glyphe de Playfair Display 700 (SIL OFL), vectorisé ; dégradé primaire → accent -->
<svg class="section-sign" viewBox="0 0 511 970" aria-hidden="true">
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="hsl(var(--color-primary))" />
<stop offset="100%" stop-color="hsl(var(--color-accent))" />
</linearGradient>
</defs>
<path :fill="`url(#${gradId})`" d="M277 0Q320 0 349 7.5Q378 15 395 22Q426 37 443.5 59.5Q461 82 461 115Q461 143 441.5 164Q422 185 387 185Q354 185 332.5 165.5Q311 146 311 116Q311 92 324.5 71Q338 50 362 42Q357 34 344.5 29Q332 24 316 21.5Q300 19 284 19Q246 19 223 32.5Q200 46 190 66.5Q180 87 180 110Q180 150 200 177.5Q220 205 256 230Q292 255 340 287Q394 324 432.5 354.5Q471 385 491 418Q511 451 511 495Q511 548 475.5 593Q440 638 352 677L343 667Q373 646 388 623Q403 600 403 567Q403 536 386 511.5Q369 487 333.5 459Q298 431 241 394Q178 353 135.5 318.5Q93 284 71.5 248Q50 212 50 167Q50 116 78.5 78.5Q107 41 158.5 20.5Q210 0 277 0ZM158 292 168 303Q137 323 122.5 346Q108 369 108 403Q108 433 124.5 458Q141 483 177 511Q213 539 270 576Q332 617 374.5 651.5Q417 686 439 722Q461 758 461 803Q461 853 432 891Q403 929 352 949.5Q301 970 233 970Q190 970 161 962.5Q132 955 115 948Q85 933 67 910.5Q49 888 49 855Q49 827 69 806Q89 785 123 785Q157 785 178 804.5Q199 824 199 854Q199 878 185.5 898.5Q172 919 148 928Q152 935 165 940.5Q178 946 194.5 948.5Q211 951 226 951Q265 951 287.5 937.5Q310 924 320.5 903Q331 882 331 860Q331 820 311 792.5Q291 765 255 740Q219 715 171 683Q116 646 78 615.5Q40 585 20 551.5Q0 518 0 475Q0 421 34.5 376Q69 331 158 292Z" />
</svg>
</template>
<script setup lang="ts">
const gradId = `sect-grad-${useId()}`
</script>
+69
View File
@@ -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>
+22 -22
View File
@@ -2,9 +2,12 @@ import type { Song } from '~/types/song'
let audio: HTMLAudioElement | null = null let audio: HTMLAudioElement | null = null
let animationFrameId: number | 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() { export function useAudioPlayer() {
const store = usePlayerStore() const store = usePlayerStore()
const { trackAudioPlay, trackAudioComplete, trackAudioProgress } = useTracking()
function getAudio(): HTMLAudioElement { function getAudio(): HTMLAudioElement {
if (!audio) { if (!audio) {
@@ -17,6 +20,10 @@ export function useAudioPlayer() {
}) })
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
if (store.currentSong) {
trackAudioComplete(store.currentSong.id, store.currentSong.title)
}
firedMilestones.clear()
const next = store.nextSong() const next = store.nextSong()
if (next) { if (next) {
loadAndPlay(next) loadAndPlay(next)
@@ -36,6 +43,16 @@ export function useAudioPlayer() {
const update = () => { const update = () => {
if (audio && !audio.paused) { if (audio && !audio.paused) {
store.setCurrentTime(audio.currentTime) 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) animationFrameId = requestAnimationFrame(update)
} }
@@ -52,34 +69,23 @@ export function useAudioPlayer() {
async function loadAndPlay(song: Song) { async function loadAndPlay(song: Song) {
const el = getAudio() const el = getAudio()
store.setSong(song) store.setSong(song)
firedMilestones.clear()
// Try OGG first, fall back to MP3 // Only MP3 files are published (no .ogg on disk nor in production):
const oggPath = song.file.replace(/\.mp3$/, '.ogg') // asking for OGG first cost a 404 and a delay on every track.
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis') el.src = song.file
el.src = canOgg ? oggPath : song.file
el.volume = store.volume el.volume = store.volume
try { try {
await el.play() await el.play()
store.play() store.play()
startTimeUpdate() startTimeUpdate()
} trackAudioPlay(song.id, song.title)
catch {
// If OGG failed, try MP3
if (el.src !== song.file) {
el.src = song.file
try {
await el.play()
store.play()
startTimeUpdate()
} }
catch (err) { catch (err) {
console.error('Playback failed:', err) console.error('Playback failed:', err)
} }
} }
}
}
function pause() { function pause() {
getAudio().pause() getAudio().pause()
@@ -124,15 +130,9 @@ export function useAudioPlayer() {
function playPrev() { function playPrev() {
const song = store.prevSong() const song = store.prevSong()
if (song) { if (song) {
if (song === store.currentSong && store.currentTime <= 3) {
// prevSong already reset time
seek(0)
}
else {
loadAndPlay(song) loadAndPlay(song)
} }
} }
}
// Watch volume changes from store // Watch volume changes from store
watch(() => store.volume, (vol) => { watch(() => store.volume, (vol) => {
+23 -4
View File
@@ -1,23 +1,23 @@
import yaml from 'yaml'
import type { Song } from '~/types/song' 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 let _configCache: BookConfig | null = null
async function loadConfig(): Promise<BookConfig> { async function loadConfig(): Promise<BookConfig> {
if (_configCache) return _configCache if (_configCache) return _configCache
const raw = await import('~/data/librodrome.config.yml?raw').then(m => m.default) const parsed = await $fetch<any>('/api/content/config')
const parsed = yaml.parse(raw)
_configCache = { _configCache = {
title: parsed.book.title, title: parsed.book.title,
author: parsed.book.author, author: parsed.book.author,
description: parsed.book.description, description: parsed.book.description,
coverImage: parsed.book.coverImage, coverImage: parsed.book.coverImage,
pdfFile: parsed.book.pdfFile,
chapters: [], chapters: [],
songs: parsed.songs as Song[], songs: parsed.songs as Song[],
chapterSongs: parsed.chapterSongs as ChapterSongLink[], chapterSongs: parsed.chapterSongs as ChapterSongLink[],
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[], defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
} }
@@ -70,6 +70,22 @@ export function useBookData() {
.filter((s): s is Song => !!s) .filter((s): s is Song => !!s)
} }
function getChapterForSong(songId: string): string | undefined {
if (!config.value) return undefined
const link = config.value.chapterSongs.find(
cs => cs.songId === songId && cs.primary,
)
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() { function getBookMeta() {
if (!config.value) return null if (!config.value) return null
return { return {
@@ -89,7 +105,10 @@ export function useBookData() {
getChapterSongs, getChapterSongs,
getPrimarySong, getPrimarySong,
getChapterSongLinks, getChapterSongLinks,
getChapterForSong,
getPlaylistOrder, getPlaylistOrder,
getBookMeta, getBookMeta,
getChapterPage,
getPdfUrl,
} }
} }
+5 -10
View File
@@ -1,16 +1,11 @@
export function useGrateWizard() { export function useGrateWizard() {
const appConfig = useAppConfig() const appConfig = useAppConfig()
const { url } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
function launch() { function launch(e?: Event) {
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } } window.open(url, '_blank', 'noopener,noreferrer')
const left = Math.round((window.screen.width - popup.width) / 2) e?.preventDefault()
const top = Math.round((window.screen.height - popup.height) / 2)
window.open(
url,
'GrateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
)
} }
return { launch } return { url, launch }
} }
+151
View File
@@ -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),
}
}
+41
View File
@@ -0,0 +1,41 @@
export function useKeyboardShortcuts() {
const store = usePlayerStore()
const { togglePlayPause, playNext, playPrev } = useAudioPlayer()
function isInputFocused(): boolean {
const el = document.activeElement
if (!el) return false
const tag = el.tagName.toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
if ((el as HTMLElement).isContentEditable) return true
return false
}
function onKeyDown(e: KeyboardEvent) {
if (isInputFocused()) return
if (!store.currentSong) return
switch (e.code) {
case 'Space':
e.preventDefault()
togglePlayPause()
break
case 'ArrowRight':
e.preventDefault()
playNext()
break
case 'ArrowLeft':
e.preventDefault()
playPrev()
break
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
}
+55
View File
@@ -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)
})
}
+52
View File
@@ -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=2'
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 }],
})
}
+6
View File
@@ -14,6 +14,12 @@ interface SiteContent {
credits: string credits: string
links: NavItem[] links: NavItem[]
} }
lpk?: {
name: string
text: string
role: string
to: string
}
gratewizard: { gratewizard: {
url: string url: string
popup: { popup: {
+96
View File
@@ -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,
}
}
-180
View File
@@ -1,180 +0,0 @@
book:
title: "Une économie du don — enfin concevable"
author: "Yvv"
description: "Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don."
coverImage: "/images/book-cover.jpg"
license: "CC-BY-NC"
isbn: "979-1-042-45206-3"
songs:
- id: chanson-01
title: "1. Ce livre est une façon"
artist: Yvv
file: /audio/chanson-01.mp3
duration: 718
lyrics: ""
tags: [introduction, livre, don]
- id: chanson-02
title: "2. Un don qui se mesure"
artist: Yvv
file: /audio/chanson-02.mp3
duration: 589
lyrics: ""
tags: [don, mesure, valeur]
- id: chanson-03
title: "3. Les asymétries"
artist: Yvv
file: /audio/chanson-03.mp3
duration: 727
lyrics: ""
tags: [asymétrie, communauté, philosophie]
- id: chanson-04
title: "4. Inverser les flux"
artist: Yvv
file: /audio/chanson-04.mp3
duration: 610
lyrics: ""
tags: [flux, économie, production]
- id: chanson-05
title: "5. Ainsi soit-il"
artist: Yvv
file: /audio/chanson-05.mp3
duration: 545
lyrics: ""
tags: [action, engagement, avenir]
- id: chanson-06
title: "6. La croissance, une option ?"
artist: Yvv
file: /audio/chanson-06.mp3
duration: 510
lyrics: ""
tags: [croissance, monnaie, questionnement]
- id: chanson-07
title: "7. Monnaie libre essence"
artist: Yvv
file: /audio/chanson-07.mp3
duration: 475
lyrics: ""
tags: [monnaie libre, TRM, June]
- id: chanson-08
title: "8. Des cercles qui se croisent"
artist: Yvv
file: /audio/chanson-08.mp3
duration: 496
lyrics: ""
tags: [échange, réseau, cercles]
- id: chanson-09
title: "9. Coder la liberté"
artist: Yvv
file: /audio/chanson-09.mp3
duration: 376
lyrics: ""
tags: [logiciel libre, code, liberté]
chapterSongs:
# Chapitre 1 — Introduction
- chapterSlug: introduction
songId: chanson-01
primary: true
- chapterSlug: introduction
songId: chanson-02
primary: false
# Chapitre 2 — De quel don parlons-nous ?
- chapterSlug: de-quel-don-parlons-nous
songId: chanson-03
primary: true
- chapterSlug: de-quel-don-parlons-nous
songId: chanson-01
primary: false
# Chapitre 3 — La mesure du don
- chapterSlug: la-mesure-du-don
songId: chanson-02
primary: true
- chapterSlug: la-mesure-du-don
songId: chanson-03
primary: false
# Chapitre 4 — Raison d'être d'une monnaie
- chapterSlug: raison-d-etre-d-une-monnaie
songId: chanson-06
primary: true
- chapterSlug: raison-d-etre-d-une-monnaie
songId: chanson-07
primary: false
# Chapitre 5 — La TRM
- chapterSlug: la-trm
songId: chanson-07
primary: true
- chapterSlug: la-trm
songId: chanson-06
primary: false
# Chapitre 6 — Créer une économie ?
- chapterSlug: creer-une-economie
songId: chanson-04
primary: true
- chapterSlug: creer-une-economie
songId: chanson-07
primary: false
# Chapitre 7 — Échanger
- chapterSlug: echanger
songId: chanson-08
primary: true
- chapterSlug: echanger
songId: chanson-04
primary: false
# Chapitre 8 — Relation institutionnelle
- chapterSlug: relation-institutionnelle
songId: chanson-05
primary: false
- chapterSlug: relation-institutionnelle
songId: chanson-08
primary: false
# Chapitre 9 — Autres greffes
- chapterSlug: autres-greffes
songId: chanson-04
primary: false
- chapterSlug: autres-greffes
songId: chanson-08
primary: false
# Chapitre 10 — Et maintenant ?
- chapterSlug: et-maintenant
songId: chanson-05
primary: true
- chapterSlug: et-maintenant
songId: chanson-09
primary: false
# Chapitre 11 — Annexes
- chapterSlug: annexes
songId: chanson-09
primary: true
- chapterSlug: annexes
songId: chanson-07
primary: false
defaultPlaylistOrder:
- chanson-01
- chanson-02
- chanson-03
- chanson-04
- chanson-05
- chanson-06
- chanson-07
- chanson-08
- chanson-09
+35 -1
View File
@@ -1,15 +1,49 @@
<template> <template>
<div class="app-layout grid grid-cols-1 min-h-dvh"> <div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader /> <LayoutTheHeader />
<main class="pb-[var(--player-height)]"> <main class="app-main">
<slot /> <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> </main>
<LayoutTheFooter /> <LayoutTheFooter />
</div> </div>
</template> </template>
<script setup lang="ts">
useScrollTracking()
</script>
<style scoped> <style scoped>
.app-layout { .app-layout {
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
} }
/* === Seal — 益 Yì (Increase) === */
.app-seal {
display: block;
width: 44px;
margin: 2rem 1.5rem 1rem 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> </style>
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="app-layout grid grid-cols-1 min-h-dvh"> <div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader /> <LayoutTheHeader />
<div class="reading-layout pb-[var(--player-height)]"> <div class="reading-layout">
<aside class="chapter-sidebar hidden lg:block"> <aside class="chapter-sidebar hidden lg:block">
<BookChapterNav /> <BookChapterNav />
</aside> </aside>
@@ -35,7 +35,7 @@
top: var(--header-height); top: var(--header-height);
height: calc(100dvh - var(--header-height)); height: calc(100dvh - var(--header-height));
overflow-y: auto; overflow-y: auto;
border-right: 1px solid hsl(0 0% 100% / 0.08); border-right: 1px solid hsl(var(--color-text) / 0.08);
padding: 1.5rem; padding: 1.5rem;
} }
+55 -3
View File
@@ -1,5 +1,34 @@
<template> <template>
<div class="section-padding"> <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 mx-auto max-w-3xl">
<ContentRenderer v-if="page" :value="page" class="prose" /> <ContentRenderer v-if="page" :value="page" class="prose" />
</div> </div>
@@ -11,11 +40,34 @@ definePageMeta({
layout: 'default', layout: 'default',
}) })
useHead({ useSeoPage({
title: 'À propos', 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', () => const { data: page } = await useAsyncData('about', () =>
queryCollection('pages').path('/pages/about').first(), queryCollection('pages').path('/pages/about').first(),
) )
</script> </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; }
}
</style>
+231 -29
View File
@@ -6,24 +6,66 @@
← Chapitres ← Chapitres
</NuxtLink> </NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1"> <h1 class="font-display text-2xl font-bold text-white mt-1">
{{ chapter?.slug }} {{ chapterTitle || slug }}
</h1> </h1>
<span class="text-xs text-white/30 font-mono">{{ slug }}</span>
</div> </div>
<div class="flex items-center gap-3">
<span v-if="wordCount" class="text-xs text-white/30">{{ wordCount }} mots</span>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" /> <AdminSaveButton :saving="saving" :saved="saved" @save="save" />
</div> </div>
</div>
<template v-if="chapter"> <template v-if="chapter">
<AdminFormSection title="Frontmatter" open> <AdminFormSection title="Métadonnées">
<textarea <div class="grid gap-3 sm:grid-cols-2">
v-model="frontmatter" <div>
class="fm-textarea" <label class="field-label">Titre</label>
rows="6" <input v-model="title" class="field-input" placeholder="Titre du chapitre" />
spellcheck="false" </div>
/> <div>
<label class="field-label">Temps de lecture</label>
<input v-model="readingTime" class="field-input" placeholder="15 min" />
</div>
<div class="sm:col-span-2">
<label class="field-label">Description</label>
<input v-model="description" class="field-input" placeholder="Description courte pour le SEO" />
</div>
</div>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="Contenu Markdown" open> <AdminFormSection title="Morceaux associés">
<AdminMarkdownEditor v-model="body" :rows="30" /> <p class="text-xs text-white/40 mb-3">
Cliquez pour associer/dissocier. Cliquez sur l'étoile pour définir le morceau principal.
</p>
<div class="flex flex-wrap gap-2">
<div
v-for="song in allSongs"
:key="song.id"
class="song-tag"
:class="{
'song-tag--active': isLinked(song.id),
'song-tag--primary': isPrimary(song.id),
}"
>
<button
v-if="isLinked(song.id)"
class="song-star"
:class="{ 'song-star--active': isPrimary(song.id) }"
@click="setPrimary(song.id)"
aria-label="Définir comme principal"
>
<div class="i-lucide-star h-3 w-3" />
</button>
<button class="song-tag-label" @click="toggleSong(song.id)">
{{ song.title }}
</button>
</div>
</div>
</AdminFormSection>
<AdminFormSection title="Contenu" open>
<AdminMarkdownEditor v-model="body" :rows="35" />
</AdminFormSection> </AdminFormSection>
</template> </template>
</div> </div>
@@ -39,17 +81,80 @@ const route = useRoute()
const slug = computed(() => route.params.slug as string) const slug = computed(() => route.params.slug as string)
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`) const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
const { data: bookConfig } = await useFetch<any>('/api/content/config')
const frontmatter = ref('') const title = ref('')
const description = ref('')
const readingTime = ref('')
const body = ref('') const body = ref('')
const chapterTitle = computed(() => title.value)
const wordCount = computed(() => {
if (!body.value) return 0
return body.value.trim().split(/\s+/).filter(Boolean).length
})
watch(chapter, (val) => { watch(chapter, (val) => {
if (val) { if (val) {
frontmatter.value = val.frontmatter ?? '' const fm = val.frontmatter ?? ''
title.value = extractFmField(fm, 'title')
description.value = extractFmField(fm, 'description')
readingTime.value = extractFmField(fm, 'readingTime')
body.value = val.body ?? '' body.value = val.body ?? ''
} }
}, { immediate: true }) }, { immediate: true })
function extractFmField(fm: string, field: string): string {
const match = fm.match(new RegExp(`^${field}:\\s*"?([^"\\n]*)"?`, 'm'))
return match ? match[1].trim() : ''
}
// ── Morceaux associés ──
const allSongs = computed(() => bookConfig.value?.songs ?? [])
const linkedSongIds = ref<Set<string>>(new Set())
const primarySongId = ref<string | null>(null)
watch(bookConfig, (val) => {
if (!val) return
const links = (val.chapterSongs ?? []).filter(
(cs: any) => cs.chapterSlug === slug.value,
)
linkedSongIds.value = new Set(links.map((l: any) => l.songId))
const primary = links.find((l: any) => l.primary)
primarySongId.value = primary?.songId ?? null
}, { immediate: true })
function isLinked(songId: string) {
return linkedSongIds.value.has(songId)
}
function isPrimary(songId: string) {
return primarySongId.value === songId
}
function toggleSong(songId: string) {
const next = new Set(linkedSongIds.value)
if (next.has(songId)) {
next.delete(songId)
if (primarySongId.value === songId) primarySongId.value = null
}
else {
next.add(songId)
if (!primarySongId.value) primarySongId.value = songId
}
linkedSongIds.value = next
}
function setPrimary(songId: string) {
if (!linkedSongIds.value.has(songId)) {
const next = new Set(linkedSongIds.value)
next.add(songId)
linkedSongIds.value = next
}
primarySongId.value = songId
}
// ── Save ──
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
@@ -57,13 +162,40 @@ async function save() {
saving.value = true saving.value = true
saved.value = false saved.value = false
try { try {
// 1. Sauvegarder le contenu du chapitre
const order = chapter.value?.frontmatter?.match(/order:\s*(\d+)/)?.[1] ?? '1'
const frontmatter = [
`title: "${title.value}"`,
`description: "${description.value}"`,
`order: ${order}`,
`readingTime: "${readingTime.value}"`,
].join('\n')
await $fetch(`/api/admin/chapters/${slug.value}`, { await $fetch(`/api/admin/chapters/${slug.value}`, {
method: 'PUT', method: 'PUT',
body: { body: { frontmatter, body: body.value },
frontmatter: frontmatter.value,
body: body.value,
},
}) })
// 2. Sauvegarder les liaisons morceaux dans la config
if (bookConfig.value) {
const otherLinks = (bookConfig.value.chapterSongs ?? []).filter(
(cs: any) => cs.chapterSlug !== slug.value,
)
const newLinks = [...linkedSongIds.value].map(songId => ({
chapterSlug: slug.value,
songId,
primary: songId === primarySongId.value,
}))
const updatedConfig = {
...bookConfig.value,
chapterSongs: [...otherLinks, ...newLinks],
}
await $fetch('/api/admin/content/config', {
method: 'PUT',
body: updatedConfig,
})
}
saved.value = true saved.value = true
setTimeout(() => { saved.value = false }, 2000) setTimeout(() => { saved.value = false }, 2000)
} }
@@ -74,21 +206,91 @@ async function save() {
</script> </script>
<style scoped> <style scoped>
.fm-textarea { .field-label {
width: 100%; display: block;
padding: 0.75rem; font-size: 0.75rem;
border: 1px solid hsl(20 8% 18%); color: hsl(var(--color-text-muted));
border-radius: 0.5rem; margin-bottom: 0.25rem;
background: hsl(20 8% 4%);
color: hsl(36 80% 76%);
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
line-height: 1.7;
resize: vertical;
} }
.fm-textarea:focus { .field-input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
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; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
}
/* ── Song tags ── */
.song-tag {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border: 1px solid hsl(var(--color-surface-light));
transition: all 0.15s;
overflow: hidden;
}
.song-tag:hover {
border-color: hsl(var(--color-primary) / 0.4);
}
.song-tag--active {
border-color: hsl(var(--color-primary) / 0.6);
background: hsl(var(--color-primary) / 0.08);
}
.song-tag--primary {
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(var(--color-text-muted));
font-size: 0.8rem;
cursor: pointer;
transition: color 0.15s;
}
.song-tag--active .song-tag-label {
color: hsl(var(--color-primary));
}
.song-tag--primary .song-tag-label {
color: hsl(var(--color-accent));
}
.song-tag-label:hover {
color: hsl(var(--color-text));
}
.song-star {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0 0.375rem 0.625rem;
background: none;
border: none;
color: hsl(var(--color-text-muted));
cursor: pointer;
transition: color 0.15s;
}
.song-star:hover {
color: hsl(var(--color-accent));
}
.song-star--active {
color: hsl(var(--color-accent));
} }
</style> </style>
+381 -14
View File
@@ -1,20 +1,107 @@
<template> <template>
<div> <div>
<h1 class="font-display text-2xl font-bold text-white mb-6">Chapitres</h1> <div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Livre &amp; 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 class="flex flex-col gap-2">
<NuxtLink <div
v-for="chapter in chapters" v-for="(chapter, i) in chapters"
:key="chapter.slug" :key="chapter.slug"
:to="`/admin/book/${chapter.slug}`"
class="chapter-item" class="chapter-item"
draggable="true"
@dragstart="onDragStart(i, $event)"
@dragover.prevent="onDragOver(i)"
@dragend="onDragEnd"
:class="{ 'chapter-item--dragging': dragIdx === i, 'chapter-item--over': dropIdx === i && dropIdx !== dragIdx }"
> >
<span class="chapter-order">{{ String(chapter.order ?? 0).padStart(2, '0') }}</span> <div class="drag-handle" aria-label="Réordonner">
<span class="chapter-title">{{ chapter.title }}</span> <div class="i-lucide-grip-vertical h-4 w-4" />
</div>
<span class="chapter-order">{{ String(i + 1).padStart(2, '0') }}</span>
<div class="chapter-info">
<NuxtLink
:to="`/admin/book/${chapter.slug}`"
class="chapter-title"
>
{{ chapter.title }}
</NuxtLink>
<div v-if="getChapterSongNames(chapter.slug).length" class="chapter-songs">
<span
v-for="name in getChapterSongNames(chapter.slug)"
:key="name"
class="song-badge"
>{{ 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)"
aria-label="Supprimer"
>
<div class="i-lucide-trash-2 h-4 w-4" />
</button>
<NuxtLink :to="`/admin/book/${chapter.slug}`">
<div class="i-lucide-chevron-right h-4 w-4 text-white/20" /> <div class="i-lucide-chevron-right h-4 w-4 text-white/20" />
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<!-- Add chapter -->
<div class="mt-6 flex items-end gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Titre</label>
<input v-model="newTitle" class="admin-input w-full" placeholder="Nouveau chapitre" />
</div>
<div>
<label class="block text-xs text-white/40 mb-1">Slug</label>
<input v-model="newSlug" class="admin-input w-full font-mono text-xs" placeholder="12-slug" />
</div>
<button class="add-btn" @click="addChapter" :disabled="!newTitle || !newSlug">
<div class="i-lucide-plus h-4 w-4" />
Ajouter
</button>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -23,37 +110,317 @@ definePageMeta({
middleware: 'admin', middleware: 'admin',
}) })
const { data: chapters } = await useFetch('/api/admin/chapters') const { data: chapters, refresh } = await useFetch<any[]>('/api/admin/chapters')
const { data: bookConfig } = await useFetch<any>('/api/content/config')
function getChapterSongNames(chapterSlug: string): string[] {
if (!bookConfig.value) return []
const links = (bookConfig.value.chapterSongs ?? []).filter(
(cs: any) => cs.chapterSlug === chapterSlug,
)
return links.map((link: any) => {
const song = bookConfig.value.songs.find((s: any) => s.id === link.songId)
return song?.title ?? link.songId
})
}
const saving = ref(false)
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)
function onDragStart(i: number, e: DragEvent) {
dragIdx.value = i
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(i: number) {
dropIdx.value = i
}
function onDragEnd() {
if (dragIdx.value !== null && dropIdx.value !== null && dragIdx.value !== dropIdx.value && chapters.value) {
const [moved] = chapters.value.splice(dragIdx.value, 1)
chapters.value.splice(dropIdx.value, 0, moved)
}
dragIdx.value = null
dropIdx.value = null
}
async function saveOrder() {
if (!chapters.value) return
saving.value = true
saved.value = false
try {
const orderedChapters = chapters.value.map((ch: any, i: number) => ({
slug: ch.slug,
order: i + 1,
}))
await $fetch('/api/admin/chapters', {
method: 'PUT',
body: { chapters: orderedChapters },
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
finally {
saving.value = false
}
}
async function addChapter() {
if (!newTitle.value || !newSlug.value) return
const order = (chapters.value?.length ?? 0) + 1
await $fetch('/api/admin/chapters', {
method: 'POST',
body: { slug: newSlug.value, title: newTitle.value, order },
})
newTitle.value = ''
newSlug.value = ''
await refresh()
}
async function removeChapter(slug: string) {
if (!confirm(`Supprimer le chapitre "${slug}" ?`)) return
await $fetch(`/api/admin/chapters/${slug}`, { method: 'DELETE' })
await refresh()
}
</script> </script>
<style scoped> <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 { .chapter-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
} }
.chapter-item:hover { .chapter-item:hover {
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
}
.chapter-item--dragging {
opacity: 0.4;
}
.chapter-item--over {
border-top: 2px solid hsl(var(--color-primary));
}
.drag-handle {
cursor: grab;
padding: 0.25rem;
color: hsl(var(--color-text-muted));
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
} }
.chapter-order { .chapter-order {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
color: hsl(12 76% 48% / 0.5); color: hsl(var(--color-primary) / 0.5);
font-weight: 600; font-weight: 600;
width: 1.75rem; width: 1.75rem;
} }
.chapter-title { .chapter-info {
flex: 1; flex: 1;
color: white; min-width: 0;
}
.chapter-title {
display: block;
color: hsl(var(--color-text));
font-weight: 500; font-weight: 500;
text-decoration: none;
}
.chapter-title:hover {
color: hsl(var(--color-primary));
}
.chapter-songs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.song-badge {
font-size: 0.65rem;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
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 {
flex-shrink: 0;
padding: 0.375rem;
border-radius: 0.375rem;
color: hsl(0 60% 50%);
background: none;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.delete-btn:hover {
background: hsl(0 60% 50% / 0.1);
}
.admin-input {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
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(var(--color-primary) / 0.5);
}
.add-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.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.add-btn:hover:not(:disabled) {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
}
.add-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
} }
</style> </style>
+12 -6
View File
@@ -9,8 +9,8 @@
<p class="text-sm text-white/50">Identité, navigation, footer</p> <p class="text-sm text-white/50">Identité, navigation, footer</p>
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/pages/home" class="dash-card"> <NuxtLink to="/admin/pages" class="dash-card">
<div class="i-lucide-home h-8 w-8 text-primary mb-2" /> <div class="i-lucide-file-text h-8 w-8 text-primary mb-2" />
<h2 class="text-lg font-semibold text-white">Pages</h2> <h2 class="text-lg font-semibold text-white">Pages</h2>
<p class="text-sm text-white/50">Contenus des pages publiques</p> <p class="text-sm text-white/50">Contenus des pages publiques</p>
</NuxtLink> </NuxtLink>
@@ -27,6 +27,12 @@
<p class="text-sm text-white/50">Métadonnées des pistes</p> <p class="text-sm text-white/50">Métadonnées des pistes</p>
</NuxtLink> </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"> <NuxtLink to="/admin/media" class="dash-card">
<div class="i-lucide-image h-8 w-8 text-accent mb-2" /> <div class="i-lucide-image h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Médias</h2> <h2 class="text-lg font-semibold text-white">Médias</h2>
@@ -47,14 +53,14 @@ definePageMeta({
.dash-card { .dash-card {
padding: 1.5rem; padding: 1.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
} }
.dash-card:hover { .dash-card:hover {
border-color: hsl(12 76% 48% / 0.3); border-color: hsl(var(--color-primary) / 0.3);
background: hsl(20 8% 8%); background: hsl(var(--color-surface));
} }
</style> </style>
+35 -10
View File
@@ -22,6 +22,10 @@
<div v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin" /> <div v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
Se connecter Se connecter
</button> </button>
<p v-if="devHint" class="dev-hint">
Dev : <code>{{ devHint }}</code>
</p>
</form> </form>
</div> </div>
</template> </template>
@@ -34,6 +38,13 @@ definePageMeta({
const password = ref('') const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const devHint = ref('')
if (import.meta.dev) {
$fetch('/api/admin/auth/hint').then((res: any) => {
devHint.value = res.password
}).catch(() => {})
}
async function login() { async function login() {
error.value = '' error.value = ''
@@ -61,16 +72,16 @@ async function login() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: hsl(20 8% 3.5%); background: hsl(var(--color-bg));
} }
.login-form { .login-form {
width: 100%; width: 100%;
max-width: 24rem; max-width: 24rem;
padding: 2.5rem; padding: 2.5rem;
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 1rem; border-radius: 1rem;
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
} }
.login-error { .login-error {
@@ -86,7 +97,7 @@ async function login() {
display: block; display: block;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
@@ -94,16 +105,16 @@ async function login() {
width: 100%; width: 100%;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.login-input:focus { .login-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.login-btn { .login-btn {
@@ -115,7 +126,7 @@ async function login() {
padding: 0.625rem; padding: 0.625rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: none; border: none;
background: hsl(12 76% 48%); background: hsl(var(--color-primary));
color: white; color: white;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
@@ -124,11 +135,25 @@ async function login() {
} }
.login-btn:hover:not(:disabled) { .login-btn:hover:not(:disabled) {
background: hsl(12 76% 42%); background: hsl(var(--color-primary));
} }
.login-btn:disabled { .login-btn:disabled {
opacity: 0.7; opacity: 0.7;
cursor: wait; cursor: wait;
} }
.dev-hint {
margin-top: 1rem;
text-align: center;
font-size: 0.75rem;
color: hsl(var(--color-text-muted));
}
.dev-hint code {
color: hsl(var(--color-primary));
background: hsl(var(--color-surface));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
</style> </style>
+161 -33
View File
@@ -5,7 +5,7 @@
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span> <span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
</div> </div>
<div v-if="messages?.length" class="space-y-3"> <div v-if="messages?.length" class="space-y-4">
<div <div
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
@@ -17,6 +17,9 @@
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span> <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 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>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span> <span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
@@ -29,23 +32,54 @@
</div> </div>
</div> </div>
<!-- Texte éditable --> <!-- Texte du message -->
<div v-if="editing === msg.id" class="mb-3"> <div v-if="editing === msg.id" class="mb-3 space-y-2">
<input <input v-model="editForm.author" class="admin-input w-full" placeholder="Auteur" />
v-model="editForm.author" <select v-model="editForm.type" class="admin-input w-full">
class="admin-input mb-2 w-full" <option value="question">Question</option>
placeholder="Auteur" <option value="suggestion">Suggestion</option>
/> <option value="retour">Retour d'expérience</option>
<textarea </select>
v-model="editForm.text" <textarea v-model="editForm.text" class="admin-input w-full" rows="3" />
class="admin-input w-full" </div>
rows="3" <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> </div>
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-wrap">
<button class="action-btn" @click="togglePublished(msg)"> <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" /> <div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
{{ msg.published ? 'Dépublier' : 'Publier' }} {{ msg.published ? 'Dépublier' : 'Publier' }}
@@ -56,15 +90,18 @@
<div class="i-lucide-check h-3.5 w-3.5" /> <div class="i-lucide-check h-3.5 w-3.5" />
Valider Valider
</button> </button>
<button class="action-btn" @click="editing = null"> <button class="action-btn" @click="editing = null">Annuler</button>
Annuler
</button>
</template> </template>
<button v-else class="action-btn" @click="startEdit(msg)"> <button v-else class="action-btn" @click="startEdit(msg)">
<div class="i-lucide-pencil h-3.5 w-3.5" /> <div class="i-lucide-pencil h-3.5 w-3.5" />
Modifier Modifier
</button> </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)"> <button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
<div class="i-lucide-trash-2 h-3.5 w-3.5" /> <div class="i-lucide-trash-2 h-3.5 w-3.5" />
Supprimer Supprimer
@@ -86,18 +123,33 @@ definePageMeta({
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages') const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
const editing = ref<number | null>(null) 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) { function startEdit(msg: any) {
editing.value = msg.id editing.value = msg.id
editForm.author = msg.author editForm.author = msg.author
editForm.text = msg.text editForm.text = msg.text
editForm.type = TYPE_LABELS[msg.type] ? msg.type : 'question'
} }
async function saveEdit(msg: any) { async function saveEdit(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, { await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT', method: 'PUT',
body: { author: editForm.author, text: editForm.text }, body: { author: editForm.author, text: editForm.text, type: editForm.type },
}) })
editing.value = null editing.value = null
await refresh() await refresh()
@@ -111,6 +163,29 @@ async function togglePublished(msg: any) {
await refresh() 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) { async function remove(msg: any) {
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' }) await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
@@ -130,14 +205,14 @@ function formatDate(iso: string) {
<style scoped> <style scoped>
.msg-row { .msg-row {
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.msg-row--draft { .msg-row--draft {
border-left: 3px solid hsl(36 80% 52%); border-left: 3px solid hsl(var(--color-accent));
} }
.status-badge { .status-badge {
@@ -155,22 +230,61 @@ function formatDate(iso: string) {
} }
.status-badge--draft { .status-badge--draft {
background: hsl(36 80% 52% / 0.15); background: hsl(var(--color-accent) / 0.15);
color: hsl(36 80% 66%); 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 { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
.action-btn { .action-btn {
@@ -180,16 +294,16 @@ function formatDate(iso: string) {
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(20 8% 60%); color: hsl(var(--color-text-muted));
background: none; background: none;
border: 1px solid hsl(20 8% 16%); border: 1px solid hsl(var(--color-surface-light));
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.action-btn:hover { .action-btn:hover {
background: hsl(20 8% 10%); background: hsl(var(--color-surface));
color: white; color: hsl(var(--color-text));
} }
.action-btn--save { .action-btn--save {
@@ -201,6 +315,20 @@ function formatDate(iso: string) {
background: hsl(142 70% 40% / 0.1); 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 { .action-btn--danger:hover {
background: hsl(0 70% 40% / 0.1); background: hsl(0 70% 40% / 0.1);
border-color: hsl(0 70% 40% / 0.3); border-color: hsl(0 70% 40% / 0.3);
@@ -2,11 +2,11 @@
<div> <div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors"> <NuxtLink to="/admin/pages" class="text-sm text-white/40 hover:text-white/60 transition-colors">
← Dashboard ← Pages
</NuxtLink> </NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1"> <h1 class="font-display text-2xl font-bold text-white mt-1">
Page : {{ pageName }} Page : {{ pagePath }}
</h1> </h1>
</div> </div>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" /> <AdminSaveButton :saving="saving" :saved="saved" @save="save" />
@@ -40,9 +40,12 @@ definePageMeta({
}) })
const route = useRoute() 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('') const yamlContent = ref('')
@@ -60,7 +63,7 @@ async function save() {
saved.value = false saved.value = false
try { try {
const parsed = yaml.parse(yamlContent.value) const parsed = yaml.parse(yamlContent.value)
await $fetch(`/api/admin/content/pages/${pageName.value}`, { await $fetch(`/api/admin/content/pages/${pagePath.value}`, {
method: 'PUT', method: 'PUT',
body: parsed, body: parsed,
}) })
@@ -80,10 +83,10 @@ async function save() {
.yaml-textarea { .yaml-textarea {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
border-radius: 0.5rem; border-radius: 0.5rem;
background: hsl(20 8% 4%); background: hsl(var(--color-bg));
color: hsl(36 80% 76%); color: hsl(var(--color-accent));
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.7; line-height: 1.7;
@@ -93,6 +96,6 @@ async function save() {
.yaml-textarea:focus { .yaml-textarea:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+97
View File
@@ -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>
+5 -5
View File
@@ -65,7 +65,7 @@
</AdminFieldList> </AdminFieldList>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="GrateWizard"> <AdminFormSection title="grateWizard">
<AdminFieldText v-model="data.gratewizard.url" label="URL de l'application" /> <AdminFieldText v-model="data.gratewizard.url" label="URL de l'application" />
</AdminFormSection> </AdminFormSection>
</template> </template>
@@ -103,14 +103,14 @@ async function save() {
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
} }
</style> </style>
+175 -12
View File
@@ -6,14 +6,22 @@
</div> </div>
<template v-if="config"> <template v-if="config">
<AdminFormSection title="Métadonnées des chansons" open> <AdminFormSection title="Morceaux" open>
<div <div
v-for="(song, i) in config.songs" v-for="(song, i) in config.songs"
:key="i" :key="song.id"
class="song-row" class="song-row"
draggable="true"
@dragstart="onDragStart(i, $event)"
@dragover.prevent="onDragOver(i)"
@dragend="onDragEnd"
:class="{ 'song-row--dragging': dragIdx === i, 'song-row--over': dropIdx === i && dropIdx !== dragIdx }"
> >
<div class="drag-handle" aria-label="Réordonner">
<div class="i-lucide-grip-vertical h-4 w-4" />
</div>
<span class="song-num">{{ i + 1 }}</span> <span class="song-num">{{ i + 1 }}</span>
<div class="flex-1 grid gap-2 sm:grid-cols-2"> <div class="flex-1 grid gap-2 sm:grid-cols-3">
<input <input
v-model="song.title" v-model="song.title"
class="admin-input" class="admin-input"
@@ -24,8 +32,33 @@
class="admin-input" class="admin-input"
placeholder="/audio/fichier.mp3" placeholder="/audio/fichier.mp3"
/> />
<input
v-model="song.id"
class="admin-input font-mono text-xs"
placeholder="identifiant-slug"
/>
</div> </div>
<div class="flex-1">
<textarea
v-model="song.lyrics"
class="admin-input lyrics-textarea"
placeholder="Paroles..."
rows="2"
/>
</div> </div>
<button
class="delete-btn"
@click="removeSong(i)"
aria-label="Supprimer"
>
<div class="i-lucide-trash-2 h-4 w-4" />
</button>
</div>
<button class="add-btn" @click="addSong">
<div class="i-lucide-plus h-4 w-4" />
Ajouter un morceau
</button>
</AdminFormSection> </AdminFormSection>
</template> </template>
</div> </div>
@@ -37,14 +70,80 @@ definePageMeta({
middleware: 'admin', middleware: 'admin',
}) })
const { data: config } = await useFetch('/api/content/config') const { data: config } = await useFetch<any>('/api/content/config')
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
// Drag & drop state
const dragIdx = ref<number | null>(null)
const dropIdx = ref<number | null>(null)
function onDragStart(i: number, e: DragEvent) {
dragIdx.value = i
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(i: number) {
dropIdx.value = i
}
function onDragEnd() {
if (dragIdx.value !== null && dropIdx.value !== null && dragIdx.value !== dropIdx.value && config.value) {
const songs = config.value.songs
const [moved] = songs.splice(dragIdx.value, 1)
songs.splice(dropIdx.value, 0, moved)
// Sync defaultPlaylistOrder
config.value.defaultPlaylistOrder = songs.map((s: any) => s.id)
}
dragIdx.value = null
dropIdx.value = null
}
function addSong() {
if (!config.value) return
const newSong = {
id: `nouveau-morceau-${Date.now()}`,
title: '',
artist: 'Yvv',
file: '/audio/',
duration: 0,
lyrics: '',
tags: [],
}
config.value.songs.push(newSong)
config.value.defaultPlaylistOrder.push(newSong.id)
}
function removeSong(i: number) {
if (!config.value) return
const songId = config.value.songs[i].id
config.value.songs.splice(i, 1)
// Remove from defaultPlaylistOrder
const orderIdx = config.value.defaultPlaylistOrder.indexOf(songId)
if (orderIdx !== -1) config.value.defaultPlaylistOrder.splice(orderIdx, 1)
// Clean chapterSongs
config.value.chapterSongs = config.value.chapterSongs.filter((cs: any) => cs.songId !== songId)
}
async function save() { async function save() {
saving.value = true saving.value = true
saved.value = false saved.value = false
try { try {
// Regenerate IDs from titles for new songs
for (const song of config.value!.songs) {
if (song.id.startsWith('nouveau-morceau-')) {
song.id = song.title
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
}
// Sync playlist order
config.value!.defaultPlaylistOrder = config.value!.songs.map((s: any) => s.id)
await $fetch('/api/admin/content/config', { await $fetch('/api/admin/content/config', {
method: 'PUT', method: 'PUT',
body: config.value, body: config.value,
@@ -61,32 +160,96 @@ async function save() {
<style scoped> <style scoped>
.song-row { .song-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
padding: 0.5rem; 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;
}
.song-row--dragging {
opacity: 0.4;
}
.song-row--over {
border-top: 2px solid hsl(var(--color-primary));
}
.drag-handle {
cursor: grab;
padding: 0.25rem;
color: hsl(var(--color-text-muted));
flex-shrink: 0;
margin-top: 0.25rem;
}
.drag-handle:active {
cursor: grabbing;
} }
.song-num { .song-num {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.8rem; font-size: 0.8rem;
color: hsl(20 8% 40%); color: hsl(var(--color-text-muted));
width: 1.25rem; width: 1.25rem;
text-align: right; text-align: right;
flex-shrink: 0;
margin-top: 0.375rem;
} }
.admin-input { .admin-input {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-surface-light));
background: hsl(20 8% 6%); background: hsl(var(--color-bg));
color: white; color: hsl(var(--color-text));
font-size: 0.8rem; font-size: 0.8rem;
width: 100%; width: 100%;
} }
.admin-input:focus { .admin-input:focus {
outline: none; outline: none;
border-color: hsl(12 76% 48% / 0.5); border-color: hsl(var(--color-primary) / 0.5);
}
.lyrics-textarea {
resize: vertical;
min-height: 2.5rem;
}
.delete-btn {
flex-shrink: 0;
padding: 0.375rem;
border-radius: 0.375rem;
color: hsl(0 60% 50%);
background: none;
border: none;
cursor: pointer;
transition: background 0.15s;
margin-top: 0.25rem;
}
.delete-btn:hover {
background: hsl(0 60% 50% / 0.1);
}
.add-btn {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-surface-light));
background: none;
color: hsl(var(--color-text-muted));
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.add-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
} }
</style> </style>
+373
View File
@@ -0,0 +1,373 @@
<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="grid gap-4 sm:grid-cols-2 mb-12 max-w-2xl mx-auto section-anchor"
>
<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"
>
<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 mb-10 max-w-2xl mx-auto section-anchor"
>
<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">
const route = useRoute()
const slug = route.params.slug as string
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 {
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>
+639
View File
@@ -0,0 +1,639 @@
<template>
<div class="axis-scope axis-scope--cit relative overflow-hidden section-padding">
<!-- 1. Shadok Capitaine (top-left, profile — at ship's wheel, telescope) -->
<svg class="shadok shadok-capitaine" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- ship's wheel -->
<circle cx="50" cy="130" r="28" stroke="currentColor" stroke-width="3" fill="none" opacity="0.35"/>
<line x1="50" y1="102" x2="50" y2="158" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="22" y1="130" x2="78" y2="130" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="30" y1="110" x2="70" y2="150" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="70" y1="110" x2="30" y2="150" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<!-- wheel spokes knobs -->
<circle cx="50" cy="100" r="3" fill="currentColor" opacity="0.4"/>
<circle cx="50" cy="160" r="3" fill="currentColor" opacity="0.4"/>
<circle cx="20" cy="130" r="3" fill="currentColor" opacity="0.4"/>
<circle cx="80" cy="130" r="3" fill="currentColor" opacity="0.4"/>
<!-- body (small oval, profile facing right) -->
<ellipse cx="85" cy="110" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- head -->
<circle cx="92" cy="68" r="16" fill="currentColor" opacity="0.3"/>
<!-- captain hat -->
<rect x="76" y="52" width="32" height="8" rx="4" fill="currentColor" opacity="0.4"/>
<rect x="80" y="46" width="24" height="8" rx="3" fill="currentColor" opacity="0.3"/>
<!-- hat brim detail -->
<ellipse cx="92" cy="60" rx="18" ry="3" fill="currentColor" opacity="0.2"/>
<!-- eyes (looking right through telescope) -->
<circle cx="98" cy="65" r="2.5" fill="currentColor" opacity="0.6"/>
<circle cx="90" cy="66" r="2" fill="currentColor" opacity="0.5"/>
<!-- beak (pointy, profile right) -->
<polygon points="108,68 120,65 108,72" fill="currentColor" opacity="0.35"/>
<!-- left arm on wheel -->
<line x1="65" y1="100" x2="55" y2="115" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- right arm holding telescope -->
<line x1="105" y1="95" x2="130" y2="72" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- telescope -->
<rect x="128" y="66" width="30" height="7" rx="3.5" fill="currentColor" opacity="0.4"/>
<rect x="155" y="64" width="10" height="11" rx="3" fill="currentColor" opacity="0.3"/>
<!-- left leg (long, forward) -->
<line x1="75" y1="135" x2="60" y2="190" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- left foot -->
<ellipse cx="55" cy="193" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
<!-- right leg (long, back) -->
<line x1="92" y1="136" x2="100" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- right foot -->
<ellipse cx="103" cy="195" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 2. Shadok Avocate (top-right — robe, scroll, arm up) -->
<svg class="shadok shadok-avocate" viewBox="0 0 160 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- body (small oval) -->
<ellipse cx="80" cy="115" rx="22" ry="30" fill="currentColor" opacity="0.25"/>
<!-- robe/gown flowing -->
<path d="M58 100 Q55 145 48 175 L112 175 Q105 145 102 100 Z" fill="currentColor" opacity="0.18"/>
<!-- robe collar -->
<path d="M68 88 Q80 96 92 88" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.35"/>
<!-- head -->
<circle cx="80" cy="70" r="16" fill="currentColor" opacity="0.3"/>
<!-- eyes (looking up-left) -->
<circle cx="74" cy="67" r="2.5" fill="currentColor" opacity="0.6"/>
<circle cx="83" cy="68" r="2" fill="currentColor" opacity="0.5"/>
<!-- beak (3/4 view, slight left) -->
<polygon points="68,72 56,69 66,76" fill="currentColor" opacity="0.35"/>
<!-- left arm up dramatically -->
<line x1="60" y1="100" x2="30" y2="55" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- open hand at top -->
<line x1="30" y1="55" x2="24" y2="46" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<line x1="30" y1="55" x2="28" y2="44" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<line x1="30" y1="55" x2="34" y2="46" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<!-- right arm holding scroll -->
<line x1="100" y1="102" x2="125" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- scroll document (big) -->
<rect x="120" y="72" width="28" height="40" rx="3" fill="currentColor" opacity="0.25"/>
<!-- scroll curl top -->
<ellipse cx="134" cy="72" rx="14" ry="5" fill="currentColor" opacity="0.2"/>
<!-- scroll curl bottom -->
<ellipse cx="134" cy="112" rx="12" ry="4" fill="currentColor" opacity="0.2"/>
<!-- scroll text lines -->
<line x1="126" y1="82" x2="142" y2="82" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<line x1="126" y1="88" x2="140" y2="88" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<line x1="126" y1="94" x2="143" y2="94" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<line x1="126" y1="100" x2="138" y2="100" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<!-- left leg (long) -->
<line x1="72" y1="142" x2="62" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="58" cy="198" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
<!-- right leg -->
<line x1="88" y1="142" x2="95" y2="196" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="99" cy="199" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 3. Shadok Vigie (left — crow's nest, looking far) -->
<svg class="shadok shadok-vigie" viewBox="0 0 140 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- mast pole -->
<line x1="70" y1="90" x2="70" y2="218" stroke="currentColor" stroke-width="4" opacity="0.3"/>
<!-- crow's nest platform -->
<path d="M38 88 L42 100 L98 100 L102 88 Z" fill="currentColor" opacity="0.25"/>
<line x1="36" y1="88" x2="104" y2="88" stroke="currentColor" stroke-width="3" opacity="0.35"/>
<!-- nest railing -->
<line x1="40" y1="78" x2="40" y2="100" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="100" y1="78" x2="100" y2="100" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="40" y1="78" x2="100" y2="78" stroke="currentColor" stroke-width="2" opacity="0.25"/>
<!-- body (small, inside nest) -->
<ellipse cx="70" cy="72" rx="20" ry="22" fill="currentColor" opacity="0.25"/>
<!-- head -->
<circle cx="70" cy="38" r="15" fill="currentColor" opacity="0.3"/>
<!-- eyes (squinting, looking right far) -->
<circle cx="78" cy="35" r="2.5" fill="currentColor" opacity="0.6"/>
<circle cx="70" cy="36" r="2" fill="currentColor" opacity="0.4"/>
<!-- beak (profile right) -->
<polygon points="83,38 96,35 84,42" fill="currentColor" opacity="0.35"/>
<!-- right hand shielding eyes -->
<line x1="88" y1="55" x2="98" y2="35" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="96" y1="33" x2="108" y2="30" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.35"/>
<!-- left arm resting on railing -->
<line x1="52" y1="60" x2="42" y2="78" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- legs dangling below nest (long!) -->
<line x1="60" y1="98" x2="52" y2="160" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="48" cy="163" rx="8" ry="4" fill="currentColor" opacity="0.3"/>
<line x1="80" y1="98" x2="86" y2="158" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="90" cy="161" rx="8" ry="4" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 4. Shadok Comedien (right — theater masks, cape, leg lifted) -->
<svg class="shadok shadok-comedien" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- dramatic cape -->
<path d="M65 85 Q40 120 35 180 L115 180 Q110 120 95 85 Z" fill="currentColor" opacity="0.15"/>
<path d="M95 85 Q120 100 135 160 Q130 165 115 180" fill="currentColor" opacity="0.1"/>
<!-- body (small oval) -->
<ellipse cx="80" cy="105" rx="20" ry="26" fill="currentColor" opacity="0.25"/>
<!-- head -->
<circle cx="80" cy="62" r="16" fill="currentColor" opacity="0.3"/>
<!-- eyes (different directions — dramatic!) -->
<circle cx="74" cy="59" r="2.5" fill="currentColor" opacity="0.6"/>
<circle cx="86" cy="61" r="2.5" fill="currentColor" opacity="0.55"/>
<!-- beak -->
<polygon points="72,66 60,63 70,70" fill="currentColor" opacity="0.35"/>
<!-- left arm up holding comedy mask -->
<line x1="62" y1="92" x2="32" y2="55" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- comedy mask (big, smiling) -->
<circle cx="25" cy="42" r="16" fill="currentColor" opacity="0.2"/>
<path d="M17 46 Q25 56 33 46" stroke="currentColor" stroke-width="2" fill="none" opacity="0.4"/>
<circle cx="20" cy="38" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="30" cy="38" r="2" fill="currentColor" opacity="0.5"/>
<!-- right arm out holding tragedy mask -->
<line x1="98" y1="92" x2="135" y2="65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- tragedy mask (big, frowning) -->
<circle cx="145" cy="55" r="16" fill="currentColor" opacity="0.2"/>
<path d="M137 62 Q145 54 153 62" stroke="currentColor" stroke-width="2" fill="none" opacity="0.4"/>
<circle cx="140" cy="50" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="150" cy="50" r="2" fill="currentColor" opacity="0.5"/>
<!-- left leg (long, planted) -->
<line x1="72" y1="128" x2="62" y2="190" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="58" cy="193" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
<!-- right leg (lifted dramatically!) -->
<line x1="88" y1="126" x2="115" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="120" cy="167" rx="8" ry="4" transform="rotate(-25 120 167)" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 5. Shadok Cartographe (bottom-left — sitting, map, compass) -->
<svg class="shadok shadok-cartographe" viewBox="0 0 180 190" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- table -->
<rect x="20" y="125" width="140" height="6" rx="2" fill="currentColor" opacity="0.3"/>
<line x1="35" y1="131" x2="30" y2="185" stroke="currentColor" stroke-width="3" opacity="0.25"/>
<line x1="145" y1="131" x2="150" y2="185" stroke="currentColor" stroke-width="3" opacity="0.25"/>
<!-- big map spread on table -->
<rect x="30" y="108" width="110" height="18" rx="2" fill="currentColor" opacity="0.2"/>
<!-- map details -->
<path d="M45 112 Q65 108 85 115 Q105 110 125 114" stroke="currentColor" stroke-width="1" fill="none" opacity="0.25"/>
<circle cx="75" cy="115" r="3" stroke="currentColor" stroke-width="1" fill="none" opacity="0.2"/>
<line x1="95" y1="110" x2="95" y2="122" stroke="currentColor" stroke-width="0.8" opacity="0.2"/>
<!-- body (small, hunched over map) -->
<ellipse cx="90" cy="88" rx="22" ry="25" fill="currentColor" opacity="0.25" transform="rotate(10 90 88)"/>
<!-- head (bent down looking at map) -->
<circle cx="78" cy="60" r="15" fill="currentColor" opacity="0.3"/>
<!-- eyes (looking down at map) -->
<circle cx="74" cy="64" r="2" fill="currentColor" opacity="0.6"/>
<circle cx="83" cy="65" r="2" fill="currentColor" opacity="0.5"/>
<!-- beak (pointing down) -->
<polygon points="72,70 65,78 78,72" fill="currentColor" opacity="0.35"/>
<!-- right arm holding compass tool -->
<line x1="110" y1="92" x2="130" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- compass/divider tool (big V shape) -->
<line x1="130" y1="105" x2="125" y2="125" stroke="currentColor" stroke-width="2.5" opacity="0.4"/>
<line x1="130" y1="105" x2="140" y2="125" stroke="currentColor" stroke-width="2.5" opacity="0.4"/>
<circle cx="130" cy="103" r="3" fill="currentColor" opacity="0.3"/>
<!-- left arm holding magnifying glass -->
<line x1="70" y1="90" x2="48" y2="100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- magnifying glass (big) -->
<circle cx="38" cy="96" r="12" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.35"/>
<line x1="48" y1="103" x2="55" y2="112" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- legs (sitting, bent under table) -->
<line x1="78" y1="110" x2="65" y2="145" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="65" y1="145" x2="50" y2="148" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<ellipse cx="45" cy="150" rx="8" ry="3.5" fill="currentColor" opacity="0.3"/>
<line x1="100" y1="110" x2="110" y2="145" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="110" y1="145" x2="122" y2="148" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<ellipse cx="127" cy="150" rx="8" ry="3.5" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 6. Shadok Juge (bottom-right — on bench, gavel, wig) -->
<svg class="shadok shadok-juge" viewBox="0 0 160 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- elevated bench/podium -->
<rect x="30" y="145" width="100" height="55" rx="4" fill="currentColor" opacity="0.2"/>
<rect x="25" y="138" width="110" height="10" rx="3" fill="currentColor" opacity="0.28"/>
<!-- bench front panel detail -->
<rect x="55" y="155" width="50" height="35" rx="3" fill="currentColor" opacity="0.1"/>
<!-- body (small, seated) -->
<ellipse cx="80" cy="112" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- head -->
<circle cx="80" cy="68" r="16" fill="currentColor" opacity="0.3"/>
<!-- judge wig (curly puffs) -->
<circle cx="66" cy="60" r="6" fill="currentColor" opacity="0.2"/>
<circle cx="94" cy="60" r="6" fill="currentColor" opacity="0.2"/>
<circle cx="72" cy="54" r="5" fill="currentColor" opacity="0.18"/>
<circle cx="88" cy="54" r="5" fill="currentColor" opacity="0.18"/>
<circle cx="80" cy="52" r="5.5" fill="currentColor" opacity="0.2"/>
<!-- wig curls hanging -->
<circle cx="63" cy="70" r="4.5" fill="currentColor" opacity="0.15"/>
<circle cx="97" cy="70" r="4.5" fill="currentColor" opacity="0.15"/>
<!-- eyes (stern, both forward) -->
<circle cx="74" cy="66" r="2.5" fill="currentColor" opacity="0.65"/>
<circle cx="86" cy="66" r="2.5" fill="currentColor" opacity="0.65"/>
<!-- stern eyebrows -->
<line x1="71" y1="62" x2="77" y2="63" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="89" y1="63" x2="83" y2="62" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<!-- beak (small, stern) -->
<polygon points="80,74 72,78 80,80" fill="currentColor" opacity="0.35"/>
<!-- right arm raising gavel -->
<line x1="100" y1="98" x2="130" y2="60" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- gavel (big!) -->
<rect x="120" y="44" width="26" height="12" rx="4" fill="currentColor" opacity="0.4"/>
<line x1="133" y1="56" x2="133" y2="38" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- left arm on bench -->
<line x1="60" y1="100" x2="40" y2="130" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- legs (dangling from bench, long) -->
<line x1="72" y1="138" x2="62" y2="200" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="58" cy="203" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
<line x1="88" y1="138" x2="96" y2="198" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="100" cy="201" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
</g>
</svg>
<!-- 7. Shadok Matelot (center-bottom — pulling rope, leaning back, anchor) -->
<svg class="shadok shadok-matelot" viewBox="0 0 160 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- rope going up and off -->
<path d="M30 20 Q35 50 55 70 Q65 78 68 85" stroke="currentColor" stroke-width="3" fill="none" opacity="0.3"/>
<!-- body (leaning back, tilted) -->
<ellipse cx="85" cy="108" rx="20" ry="27" fill="currentColor" opacity="0.25" transform="rotate(-15 85 108)"/>
<!-- head -->
<circle cx="95" cy="68" r="15" fill="currentColor" opacity="0.3"/>
<!-- bandana -->
<path d="M80 62 Q95 55 110 62" stroke="currentColor" stroke-width="3" fill="currentColor" opacity="0.2"/>
<line x1="108" y1="60" x2="118" y2="55" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="108" y1="62" x2="116" y2="60" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.2"/>
<!-- eyes (straining, squinting) -->
<circle cx="90" cy="66" r="2" fill="currentColor" opacity="0.6"/>
<circle cx="100" cy="65" r="2.5" fill="currentColor" opacity="0.55"/>
<!-- beak -->
<polygon points="104,70 115,67 105,74" fill="currentColor" opacity="0.35"/>
<!-- both arms pulling rope -->
<line x1="68" y1="95" x2="55" y2="78" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="75" y1="98" x2="62" y2="82" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- left leg (long, bracing forward) -->
<line x1="72" y1="130" x2="48" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="43" cy="188" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<!-- right leg (long, back) -->
<line x1="92" y1="132" x2="110" y2="188" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="114" cy="191" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<!-- anchor nearby -->
<line x1="135" y1="140" x2="135" y2="185" stroke="currentColor" stroke-width="3" opacity="0.3"/>
<circle cx="135" cy="138" r="5" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"/>
<path d="M120 180 Q135 195 150 180" stroke="currentColor" stroke-width="3" fill="none" opacity="0.3"/>
<line x1="120" y1="180" x2="117" y2="174" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="150" y1="180" x2="153" y2="174" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
</g>
</svg>
<!-- 8. Shadok Mime (right — pushing invisible wall, striped shirt, beret) -->
<svg class="shadok shadok-mime" viewBox="0 0 150 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<!-- body (small oval) -->
<ellipse cx="70" cy="112" rx="22" ry="28" fill="currentColor" opacity="0.2"/>
<!-- striped shirt lines -->
<line x1="52" y1="96" x2="88" y2="96" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="50" y1="102" x2="90" y2="102" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="50" y1="108" x2="90" y2="108" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="50" y1="114" x2="90" y2="114" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="52" y1="120" x2="88" y2="120" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="54" y1="126" x2="86" y2="126" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<!-- head (white face = lighter) -->
<circle cx="75" cy="65" r="16" fill="currentColor" opacity="0.2"/>
<!-- white face highlight -->
<circle cx="75" cy="65" r="13" fill="currentColor" opacity="0.08"/>
<!-- beret -->
<ellipse cx="75" cy="50" rx="18" ry="6" fill="currentColor" opacity="0.3"/>
<circle cx="75" cy="46" r="3" fill="currentColor" opacity="0.25"/>
<!-- eyes (expressive, wide) -->
<circle cx="69" cy="62" r="3" fill="currentColor" opacity="0.55"/>
<circle cx="81" cy="62" r="3" fill="currentColor" opacity="0.55"/>
<!-- raised eyebrows (surprised) -->
<path d="M66 57 Q69 54 72 57" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.4"/>
<path d="M78 57 Q81 54 84 57" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.4"/>
<!-- beak (small) -->
<polygon points="75,72 68,76 75,78" fill="currentColor" opacity="0.3"/>
<!-- both arms pushing against invisible wall (to the right) -->
<line x1="90" y1="100" x2="125" y2="85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="90" y1="115" x2="125" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- flat hands pressed against wall -->
<rect x="124" y="78" width="12" height="15" rx="3" fill="currentColor" opacity="0.2"/>
<rect x="124" y="103" width="12" height="15" rx="3" fill="currentColor" opacity="0.2"/>
<!-- invisible wall hint (faint line) -->
<line x1="138" y1="50" x2="138" y2="180" stroke="currentColor" stroke-width="1" stroke-dasharray="4 6" opacity="0.12"/>
<!-- left leg (long, leaning forward) -->
<line x1="60" y1="138" x2="45" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="40" cy="198" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
<!-- right leg (long, back) -->
<line x1="80" y1="138" x2="95" y2="194" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="99" cy="197" rx="9" ry="4" fill="currentColor" opacity="0.3"/>
</g>
</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" 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-6">
<!-- Decision collective -->
<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>
<!-- Tarifs de l'eau -->
<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>
</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);
}
/* Shadok illustrations */
.shadok {
position: absolute;
pointer-events: none;
width: clamp(70px, 10vw, 140px);
z-index: 0;
}
.shadok-capitaine {
top: 2%;
left: 2%;
color: hsl(var(--color-primary));
opacity: 0.22;
animation: shadok-float-1 9s ease-in-out infinite;
}
.shadok-avocate {
top: 1%;
right: 2%;
color: hsl(var(--color-accent));
opacity: 0.2;
animation: shadok-float-2 11s ease-in-out infinite;
}
.shadok-vigie {
left: 2%;
top: 38%;
color: hsl(var(--color-primary));
opacity: 0.24;
animation: shadok-float-3 10s ease-in-out infinite;
}
.shadok-comedien {
right: 3%;
top: 35%;
color: hsl(var(--color-accent));
opacity: 0.2;
animation: shadok-float-4 8s ease-in-out infinite;
}
.shadok-cartographe {
bottom: 10%;
left: 1%;
color: hsl(var(--color-accent));
opacity: 0.22;
animation: shadok-float-5 12s ease-in-out infinite;
}
.shadok-juge {
bottom: 6%;
right: 1%;
color: hsl(var(--color-primary));
opacity: 0.24;
animation: shadok-float-6 9.5s ease-in-out infinite;
}
.shadok-matelot {
bottom: 2%;
left: 50%;
transform: translateX(-50%);
color: hsl(var(--color-primary));
opacity: 0.18;
animation: shadok-float-7 7s ease-in-out infinite;
}
.shadok-mime {
right: 2%;
top: 55%;
color: hsl(var(--color-accent));
opacity: 0.28;
animation: shadok-float-8 10.5s ease-in-out infinite;
}
@keyframes shadok-float-1 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-2 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-3 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
@keyframes shadok-float-4 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
@keyframes shadok-float-5 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes shadok-float-6 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-7 {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-7px); }
}
@keyframes shadok-float-8 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadok {
display: none;
}
}
</style>
+455
View File
@@ -0,0 +1,455 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok 1 : Libraire — behind counter, 3/4 view, glasses, recommending a book -->
<svg class="shadok-libraire" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Body (small oval) -->
<ellipse cx="75" cy="95" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Head -->
<circle cx="75" cy="58" r="16" fill="currentColor" opacity="0.3"/>
<!-- Eyes looking right (recommending) -->
<circle cx="80" cy="55" r="2" fill="currentColor" opacity="0.6"/>
<circle cx="86" cy="54" r="2" fill="currentColor" opacity="0.6"/>
<!-- Glasses on beak -->
<circle cx="80" cy="55" r="5" stroke="currentColor" stroke-width="1.2" fill="none" opacity="0.4"/>
<circle cx="86" cy="54" r="5" stroke="currentColor" stroke-width="1.2" fill="none" opacity="0.4"/>
<line x1="85" y1="55" x2="81" y2="54" stroke="currentColor" stroke-width="1" opacity="0.3"/>
<!-- Beak (pointy, triangular) -->
<polygon points="90,58 102,55 90,52" fill="currentColor" opacity="0.35"/>
<!-- Arm holding book out to customer -->
<line x1="97" y1="88" x2="125" y2="78" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Book in hand (big, detailed) -->
<rect x="120" y="70" width="18" height="24" rx="2" fill="currentColor" opacity="0.35"/>
<rect x="122" y="73" width="14" height="3" rx="1" fill="currentColor" opacity="0.2"/>
<line x1="129" y1="70" x2="129" y2="94" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<!-- Other arm resting on counter -->
<line x1="53" y1="90" x2="35" y2="108" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Counter (big) -->
<rect x="10" y="120" width="140" height="10" rx="2" fill="currentColor" opacity="0.3"/>
<rect x="15" y="130" width="130" height="6" rx="1" fill="currentColor" opacity="0.15"/>
<!-- Stack of books on counter -->
<rect x="25" y="105" width="22" height="15" rx="2" fill="currentColor" opacity="0.3"/>
<rect x="28" y="98" width="18" height="7" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="30" y="93" width="14" height="5" rx="1" fill="currentColor" opacity="0.2"/>
<!-- Legs (long!) -->
<line x1="65" y1="123" x2="55" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="85" y1="123" x2="95" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Big flat feet -->
<ellipse cx="50" cy="188" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<ellipse cx="100" cy="188" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok 2 : Factrice — running profile, letter carrier bag, letters flying, cap -->
<svg class="shadok-factrice" viewBox="0 0 180 200" fill="none" aria-hidden="true">
<!-- Body (small oval, leaning forward) -->
<ellipse cx="80" cy="80" rx="20" ry="26" fill="currentColor" opacity="0.25" transform="rotate(-15 80 80)"/>
<!-- Head -->
<circle cx="95" cy="48" r="15" fill="currentColor" opacity="0.3"/>
<!-- Cap -->
<rect x="82" y="34" width="28" height="7" rx="3" fill="currentColor" opacity="0.4"/>
<rect x="106" y="36" width="10" height="5" rx="2" fill="currentColor" opacity="0.3"/>
<!-- Eyes (determined, looking forward) -->
<circle cx="101" cy="46" r="1.8" fill="currentColor" opacity="0.6"/>
<circle cx="106" cy="45" r="1.8" fill="currentColor" opacity="0.6"/>
<!-- Beak (pointy, profile) -->
<polygon points="110,48 125,44 110,42" fill="currentColor" opacity="0.35"/>
<!-- Carrier bag (big, on shoulder) -->
<rect x="55" y="62" width="28" height="35" rx="4" fill="currentColor" opacity="0.3"/>
<line x1="60" y1="62" x2="75" y2="55" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<!-- Letters flying out of bag -->
<rect x="42" y="55" width="12" height="9" rx="1" fill="currentColor" opacity="0.2" transform="rotate(-20 48 59)"/>
<rect x="35" y="45" width="10" height="7" rx="1" fill="currentColor" opacity="0.15" transform="rotate(-35 40 48)"/>
<rect x="48" y="42" width="11" height="8" rx="1" fill="currentColor" opacity="0.18" transform="rotate(10 53 46)"/>
<!-- Arm swinging back -->
<line x1="65" y1="72" x2="45" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Running legs (long strides!) -->
<line x1="72" y1="106" x2="40" y2="170" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="88" y1="106" x2="130" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Big flat feet -->
<ellipse cx="35" cy="174" rx="11" ry="4" fill="currentColor" opacity="0.3"/>
<ellipse cx="135" cy="168" rx="11" ry="4" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok 3 : Cartonnier — assembling cardboard box, tape gun, flat boxes nearby -->
<svg class="shadok-cartonnier" viewBox="0 0 170 220" fill="none" aria-hidden="true">
<!-- Body (small, leaning over work) -->
<ellipse cx="70" cy="100" rx="22" ry="27" fill="currentColor" opacity="0.25" transform="rotate(8 70 100)"/>
<!-- Head (looking down at box) -->
<circle cx="78" cy="65" r="15" fill="currentColor" opacity="0.3"/>
<!-- Eyes (focused, looking down) -->
<circle cx="83" cy="67" r="1.8" fill="currentColor" opacity="0.6"/>
<circle cx="88" cy="68" r="1.8" fill="currentColor" opacity="0.6"/>
<!-- Beak pointing down -->
<polygon points="88,72 98,78 86,76" fill="currentColor" opacity="0.35"/>
<!-- Arm holding tape gun -->
<line x1="90" y1="95" x2="130" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Tape gun (big, detailed) -->
<circle cx="138" cy="102" r="8" fill="currentColor" opacity="0.25"/>
<rect x="132" y="99" width="18" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<polygon points="150,100 158,102 150,104" fill="currentColor" opacity="0.3"/>
<!-- Other arm holding box flap -->
<line x1="50" y1="92" x2="35" y2="118" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Box being assembled (big, 3D perspective) -->
<rect x="25" y="118" width="45" height="35" rx="2" fill="currentColor" opacity="0.2"/>
<polygon points="25,118 15,108 60,108 70,118" fill="currentColor" opacity="0.15"/>
<polygon points="70,118 60,108 60,143 70,153" fill="currentColor" opacity="0.12"/>
<!-- Flap open -->
<polygon points="25,118 15,108 15,98 25,108" fill="currentColor" opacity="0.18"/>
<!-- Flat boxes nearby -->
<rect x="90" y="145" width="35" height="4" rx="1" fill="currentColor" opacity="0.2"/>
<rect x="88" y="150" width="38" height="4" rx="1" fill="currentColor" opacity="0.15"/>
<rect x="92" y="155" width="32" height="4" rx="1" fill="currentColor" opacity="0.12"/>
<!-- Legs (long!) -->
<line x1="60" y1="127" x2="48" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="80" y1="127" x2="92" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Big flat feet -->
<ellipse cx="43" cy="196" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<ellipse cx="97" cy="196" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok 4 : Cycliste livreur — on bicycle, package on rack, profile leaning forward -->
<svg class="shadok-cycliste" viewBox="0 0 180 200" fill="none" aria-hidden="true">
<!-- Back wheel -->
<circle cx="35" cy="160" r="22" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.25"/>
<circle cx="35" cy="160" r="3" fill="currentColor" opacity="0.3"/>
<!-- Front wheel -->
<circle cx="145" cy="160" r="22" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.25"/>
<circle cx="145" cy="160" r="3" fill="currentColor" opacity="0.3"/>
<!-- Frame -->
<line x1="35" y1="160" x2="80" y2="120" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="80" y1="120" x2="120" y2="120" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="120" y1="120" x2="145" y2="160" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="35" y1="160" x2="80" y2="140" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.2"/>
<line x1="80" y1="140" x2="120" y2="120" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.2"/>
<!-- Handlebars -->
<line x1="120" y1="120" x2="130" y2="105" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="125" y1="105" x2="135" y2="105" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- Saddle -->
<rect x="72" y="116" width="16" height="5" rx="2" fill="currentColor" opacity="0.3"/>
<!-- Package on rear rack (big!) -->
<rect x="15" y="125" width="30" height="22" rx="3" fill="currentColor" opacity="0.3"/>
<line x1="30" y1="125" x2="30" y2="147" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<line x1="15" y1="136" x2="45" y2="136" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<!-- Rear rack -->
<line x1="35" y1="147" x2="35" y2="155" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="15" y1="147" x2="55" y2="147" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<!-- Body (small, leaning forward on bike) -->
<ellipse cx="90" cy="100" rx="18" ry="24" fill="currentColor" opacity="0.25" transform="rotate(-20 90 100)"/>
<!-- Head -->
<circle cx="108" cy="68" r="14" fill="currentColor" opacity="0.3"/>
<!-- Eyes (focused ahead) -->
<circle cx="115" cy="65" r="1.8" fill="currentColor" opacity="0.6"/>
<circle cx="120" cy="64" r="1.8" fill="currentColor" opacity="0.6"/>
<!-- Beak -->
<polygon points="122,68 134,64 122,62" fill="currentColor" opacity="0.35"/>
<!-- Arms to handlebars -->
<line x1="100" y1="90" x2="130" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Legs pedaling -->
<line x1="82" y1="118" x2="72" y2="145" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="88" y1="120" x2="95" y2="148" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Feet on pedals -->
<ellipse cx="70" cy="148" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="97" cy="150" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok 5 : Lectrice satisfaite — sitting in armchair, open book, wrapping paper on floor -->
<svg class="shadok-lectrice" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Armchair (big!) -->
<path d="M20 100 Q20 80 35 80 L115 80 Q130 80 130 100 L130 150 L20 150 Z" fill="currentColor" opacity="0.15"/>
<!-- Armrests -->
<rect x="10" y="90" width="16" height="55" rx="6" fill="currentColor" opacity="0.2"/>
<rect x="124" y="90" width="16" height="55" rx="6" fill="currentColor" opacity="0.2"/>
<!-- Chair legs -->
<line x1="25" y1="150" x2="22" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.2"/>
<line x1="125" y1="150" x2="128" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.2"/>
<!-- Body (sitting, small) -->
<ellipse cx="75" cy="120" rx="20" ry="25" fill="currentColor" opacity="0.25"/>
<!-- Head (tilted, reading happily) -->
<circle cx="75" cy="82" r="16" fill="currentColor" opacity="0.3"/>
<!-- Happy eyes (curved) -->
<path d="M66 79 Q69 76 72 79" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M80 78 Q83 75 86 78" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
<!-- Small smile -->
<path d="M70 88 Q75 93 80 88" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Beak (small, happy) -->
<polygon points="88,83 98,80 88,78" fill="currentColor" opacity="0.3"/>
<!-- Arms holding open book -->
<line x1="55" y1="112" x2="40" y2="125" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="95" y1="112" x2="110" y2="125" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Open book (big, in lap) -->
<rect x="38" y="125" width="28" height="20" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="66" y="125" width="28" height="20" rx="1" fill="currentColor" opacity="0.25"/>
<line x1="66" y1="125" x2="66" y2="145" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
<!-- Text lines on book pages -->
<line x1="42" y1="131" x2="62" y2="131" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<line x1="42" y1="135" x2="60" y2="135" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<line x1="70" y1="131" x2="90" y2="131" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<line x1="70" y1="135" x2="88" y2="135" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<!-- Legs (long, dangling from chair) -->
<line x1="62" y1="145" x2="50" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="88" y1="145" x2="100" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Big flat feet -->
<ellipse cx="45" cy="198" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<ellipse cx="105" cy="198" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<!-- Wrapping paper on floor -->
<path d="M30 175 Q45 170 55 178 Q60 182 50 185" stroke="currentColor" stroke-width="1.5" fill="currentColor" opacity="0.12"/>
<path d="M100 180 Q115 172 125 180 Q120 188 108 185" stroke="currentColor" stroke-width="1.5" fill="currentColor" opacity="0.1"/>
</svg>
<!-- Shadok 6 : Empileur — balancing tower of books on head, arms out, worried eyes -->
<svg class="shadok-empileur" viewBox="0 0 140 220" fill="none" aria-hidden="true">
<!-- Precarious tower of books on head (big, detailed!) -->
<rect x="48" y="8" width="24" height="7" rx="1" fill="currentColor" opacity="0.2" transform="rotate(3 60 11)"/>
<rect x="46" y="16" width="28" height="7" rx="1" fill="currentColor" opacity="0.25" transform="rotate(-2 60 19)"/>
<rect x="44" y="24" width="32" height="7" rx="1" fill="currentColor" opacity="0.2" transform="rotate(4 60 27)"/>
<rect x="47" y="32" width="26" height="7" rx="1" fill="currentColor" opacity="0.28" transform="rotate(-3 60 35)"/>
<rect x="50" y="40" width="22" height="6" rx="1" fill="currentColor" opacity="0.22" transform="rotate(2 61 43)"/>
<!-- Head -->
<circle cx="65" cy="58" r="14" fill="currentColor" opacity="0.3"/>
<!-- Worried eyes (looking up at books) -->
<circle cx="60" cy="54" r="2.2" fill="currentColor" opacity="0.6"/>
<circle cx="70" cy="54" r="2.2" fill="currentColor" opacity="0.6"/>
<!-- Tiny worried pupils (looking up) -->
<circle cx="60" cy="53" r="1" fill="currentColor" opacity="0.35"/>
<circle cx="70" cy="53" r="1" fill="currentColor" opacity="0.35"/>
<!-- Worried eyebrows -->
<line x1="56" y1="50" x2="63" y2="51" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.4"/>
<line x1="74" y1="51" x2="67" y2="50" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.4"/>
<!-- Beak (slightly open, worried) -->
<polygon points="74,60 86,57 74,55" fill="currentColor" opacity="0.3"/>
<line x1="76" y1="58" x2="83" y2="58" stroke="currentColor" stroke-width="0.8" opacity="0.2"/>
<!-- Body (small oval, upright, tense) -->
<ellipse cx="65" cy="95" rx="20" ry="26" fill="currentColor" opacity="0.25"/>
<!-- Arms out wide for balance -->
<line x1="45" y1="88" x2="10" y2="82" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="85" y1="88" x2="120" y2="82" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Hands (small circles at arm ends) -->
<circle cx="8" cy="82" r="3" fill="currentColor" opacity="0.25"/>
<circle cx="122" cy="82" r="3" fill="currentColor" opacity="0.25"/>
<!-- Legs (long!) -->
<line x1="55" y1="121" x2="45" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="75" y1="121" x2="85" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Big flat feet -->
<ellipse cx="40" cy="196" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
<ellipse cx="90" cy="196" rx="10" ry="4" fill="currentColor" opacity="0.3"/>
</svg>
<div class="container-content">
<div class="mx-auto max-w-2xl">
<div class="section-icon mx-auto mb-6">
<div class="i-lucide-shopping-bag 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 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">
<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">
<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 {
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);
}
.shadok-libraire {
position: absolute;
left: 2%;
top: 5%;
width: clamp(70px, 10vw, 140px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-1 9s ease-in-out infinite;
}
.shadok-factrice {
position: absolute;
right: 2%;
top: 4%;
width: clamp(70px, 10vw, 140px);
opacity: 0.24;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-2 11s ease-in-out infinite;
}
.shadok-cartonnier {
position: absolute;
left: 3%;
top: 45%;
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-3 10s ease-in-out infinite;
}
.shadok-cycliste {
position: absolute;
left: 3%;
bottom: 8%;
width: clamp(75px, 10vw, 140px);
opacity: 0.18;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-4 8s ease-in-out infinite;
}
.shadok-lectrice {
position: absolute;
right: 2%;
bottom: 10%;
width: clamp(70px, 10vw, 135px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-5 12s ease-in-out infinite;
}
.shadok-empileur {
position: absolute;
left: 50%;
bottom: 2%;
transform: translateX(-50%);
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-6 7s ease-in-out infinite;
}
@keyframes shadok-float-1 { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes shadok-float-2 { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
@keyframes shadok-float-3 { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-12px) rotate(-1deg); } }
@keyframes shadok-float-4 { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px) rotate(1deg); } }
@keyframes shadok-float-5 { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@keyframes shadok-float-6 { 0%, 100% { transform: translateX(-50%) translateY(0); } 50% { transform: translateX(-50%) translateY(-9px); } }
@media (max-width: 768px) {
.shadok-libraire,
.shadok-factrice,
.shadok-cartonnier,
.shadok-cycliste,
.shadok-lectrice,
.shadok-empileur { display: none; }
}
</style>
+689
View File
@@ -0,0 +1,689 @@
<template>
<div class="axis-scope axis-scope--eco relative overflow-hidden section-padding">
<!-- Shadok boulangère (top-left, walking profile, carrying bread tray) -->
<svg class="shadok shadok-boulangere" viewBox="0 0 160 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body tilted forward walking -->
<ellipse cx="85" cy="105" rx="22" ry="28" fill="currentColor" opacity="0.25" transform="rotate(-8 85 105)" />
<!-- head -->
<circle cx="80" cy="65" r="16" fill="currentColor" opacity="0.3" />
<!-- eyes looking forward -->
<circle cx="88" cy="62" r="2" fill="currentColor" />
<circle cx="88" cy="68" r="1.5" fill="currentColor" />
<!-- beak pointing right -->
<path d="M94 64 L108 62 L94 68" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arms up holding tray -->
<line x1="75" y1="88" x2="60" y2="42" stroke-width="3" />
<line x1="95" y1="88" x2="110" y2="42" stroke-width="3" />
<!-- big bread tray -->
<rect x="48" y="32" width="74" height="12" rx="3" fill="currentColor" opacity="0.25" />
<!-- bread loaves on tray -->
<ellipse cx="62" cy="30" rx="10" ry="6" fill="currentColor" opacity="0.3" />
<ellipse cx="85" cy="28" rx="11" ry="7" fill="currentColor" opacity="0.3" />
<ellipse cx="108" cy="30" rx="9" ry="6" fill="currentColor" opacity="0.3" />
<!-- score marks on loaves -->
<line x1="58" y1="28" x2="60" y2="32" opacity="0.4" />
<line x1="64" y1="27" x2="66" y2="31" opacity="0.4" />
<line x1="82" y1="26" x2="84" y2="30" opacity="0.4" />
<line x1="88" y1="25" x2="90" y2="29" opacity="0.4" />
<!-- flour apron -->
<path d="M70 95 Q85 92 100 95 L97 125 Q85 128 73 125 Z" fill="currentColor" opacity="0.1" />
<!-- flour dots -->
<circle cx="78" cy="100" r="1" fill="currentColor" opacity="0.3" />
<circle cx="92" cy="108" r="1.2" fill="currentColor" opacity="0.25" />
<circle cx="83" cy="115" r="0.8" fill="currentColor" opacity="0.3" />
<!-- long legs walking stride -->
<line x1="78" y1="132" x2="58" y2="185" stroke-width="3" />
<line x1="92" y1="132" x2="112" y2="180" stroke-width="3" />
<!-- big flat feet -->
<ellipse cx="52" cy="188" rx="12" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="118" cy="183" rx="12" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<!-- Shadok potier (top-right, sitting at wheel) -->
<svg class="shadok shadok-potier" viewBox="0 0 170 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body sitting, slightly hunched -->
<ellipse cx="70" cy="100" rx="23" ry="26" fill="currentColor" opacity="0.25" transform="rotate(5 70 100)" />
<!-- head looking down at wheel -->
<circle cx="65" cy="65" r="15" fill="currentColor" opacity="0.3" />
<!-- eyes looking down -->
<circle cx="60" cy="68" r="2" fill="currentColor" />
<circle cx="72" cy="70" r="1.8" fill="currentColor" />
<!-- beak pointing down-right -->
<path d="M68 76 L80 82 L66 82" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arms reaching to vase -->
<line x1="50" y1="92" x2="110" y2="115" stroke-width="3" />
<line x1="90" y1="90" x2="120" y2="110" stroke-width="3" />
<!-- hands on vase -->
<circle cx="112" cy="113" r="4" fill="currentColor" opacity="0.2" />
<circle cx="122" cy="108" r="4" fill="currentColor" opacity="0.2" />
<!-- vase being shaped -->
<path d="M108 95 Q102 108 106 125 Q115 135 124 125 Q128 108 122 95" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="2" />
<!-- vase opening -->
<ellipse cx="115" cy="95" rx="8" ry="3" fill="currentColor" opacity="0.15" />
<!-- pottery wheel -->
<ellipse cx="115" cy="140" rx="28" ry="8" fill="currentColor" opacity="0.2" />
<line x1="115" y1="148" x2="115" y2="170" stroke-width="3" />
<line x1="100" y1="170" x2="130" y2="170" stroke-width="3" />
<!-- spinning motion lines -->
<path d="M88 138 Q85 135 88 132" fill="none" opacity="0.35" />
<path d="M142 138 Q145 135 142 132" fill="none" opacity="0.35" />
<path d="M90 145 Q86 143 90 140" fill="none" opacity="0.25" />
<!-- legs folded sitting -->
<line x1="60" y1="124" x2="40" y2="165" stroke-width="3" />
<line x1="80" y1="124" x2="75" y2="170" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="34" cy="168" rx="11" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="70" cy="173" rx="11" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<!-- Shadok apicultrice (left, 40% down, with smoker and bees) -->
<svg class="shadok shadok-apicultrice" viewBox="0 0 160 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body upright -->
<ellipse cx="75" cy="110" rx="20" ry="27" fill="currentColor" opacity="0.25" />
<!-- head -->
<circle cx="75" cy="70" r="15" fill="currentColor" opacity="0.3" />
<!-- beekeeping hat brim -->
<ellipse cx="75" cy="56" rx="22" ry="5" fill="currentColor" opacity="0.2" />
<!-- hat top -->
<path d="M58 56 Q58 42 75 40 Q92 42 92 56" fill="currentColor" opacity="0.15" />
<!-- veil hanging down -->
<path d="M53 56 L53 82 Q75 88 97 82 L97 56" fill="none" stroke-dasharray="3 2" opacity="0.3" />
<!-- eyes behind veil -->
<circle cx="70" cy="68" r="2" fill="currentColor" />
<circle cx="82" cy="66" r="1.8" fill="currentColor" />
<!-- beak -->
<path d="M80 74 L94 72 L82 78" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arm left holding smoker -->
<line x1="56" y1="100" x2="28" y2="85" stroke-width="3" />
<!-- smoker device (big) -->
<rect x="10" y="72" width="22" height="20" rx="4" fill="currentColor" opacity="0.25" />
<path d="M14 72 L14 62 Q21 58 28 62 L28 72" fill="currentColor" opacity="0.15" />
<!-- smoke puffs -->
<circle cx="18" cy="55" r="5" fill="currentColor" opacity="0.12" />
<circle cx="24" cy="48" r="4" fill="currentColor" opacity="0.08" />
<circle cx="16" cy="42" r="3" fill="currentColor" opacity="0.06" />
<!-- arm right -->
<line x1="94" y1="100" x2="110" y2="90" stroke-width="3" />
<!-- buzzing bees (small detailed dots) -->
<circle cx="120" cy="60" r="2.5" fill="currentColor" opacity="0.35" />
<line x1="118" y1="58" x2="115" y2="56" opacity="0.3" />
<line x1="122" y1="58" x2="125" y2="55" opacity="0.3" />
<circle cx="135" cy="75" r="2" fill="currentColor" opacity="0.3" />
<line x1="133" y1="73" x2="131" y2="71" opacity="0.25" />
<line x1="137" y1="73" x2="139" y2="71" opacity="0.25" />
<circle cx="110" cy="48" r="2.2" fill="currentColor" opacity="0.25" />
<line x1="108" y1="46" x2="106" y2="44" opacity="0.2" />
<line x1="112" y1="46" x2="114" y2="43" opacity="0.2" />
<circle cx="140" cy="55" r="1.8" fill="currentColor" opacity="0.2" />
<circle cx="128" cy="42" r="2" fill="currentColor" opacity="0.22" />
<!-- long legs -->
<line x1="66" y1="136" x2="55" y2="192" stroke-width="3" />
<line x1="84" y1="136" x2="95" y2="192" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="49" cy="195" rx="11" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="101" cy="195" rx="11" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<!-- Shadok forgeron (right, 35%, swinging hammer on anvil) -->
<svg class="shadok shadok-forgeron" viewBox="0 0 180 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body leaning forward, muscular feel -->
<ellipse cx="90" cy="105" rx="24" ry="30" fill="currentColor" opacity="0.3" transform="rotate(-12 90 105)" />
<!-- head in profile -->
<circle cx="100" cy="65" r="16" fill="currentColor" opacity="0.3" />
<!-- eyes focused, intense -->
<circle cx="108" cy="62" r="2.5" fill="currentColor" />
<circle cx="108" cy="70" r="2" fill="currentColor" />
<!-- beak pointing right -->
<path d="M114 65 L130 62 L114 70" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- big arm swinging hammer UP -->
<line x1="72" y1="90" x2="40" y2="40" stroke-width="3.5" />
<!-- hammer head (big!) -->
<rect x="22" y="22" width="38" height="18" rx="3" fill="currentColor" opacity="0.3" />
<!-- hammer handle end -->
<line x1="40" y1="40" x2="42" y2="30" stroke-width="4" />
<!-- other arm resting on anvil -->
<line x1="110" y1="92" x2="140" y2="130" stroke-width="3" />
<!-- anvil (big detailed) -->
<path d="M120 135 L160 135 L168 145 L112 145 Z" fill="currentColor" opacity="0.25" />
<rect x="128" y="145" width="24" height="20" fill="currentColor" opacity="0.2" />
<rect x="122" y="165" width="36" height="6" rx="2" fill="currentColor" opacity="0.25" />
<!-- hot metal on anvil -->
<rect x="132" y="130" width="20" height="5" rx="1" fill="currentColor" opacity="0.35" />
<!-- sparks flying -->
<line x1="138" y1="128" x2="132" y2="118" opacity="0.4" />
<line x1="145" y1="126" x2="150" y2="116" opacity="0.35" />
<line x1="152" y1="128" x2="160" y2="120" opacity="0.3" />
<circle cx="130" cy="115" r="1.5" fill="currentColor" opacity="0.35" />
<circle cx="155" cy="112" r="1.2" fill="currentColor" opacity="0.3" />
<circle cx="162" cy="118" r="1" fill="currentColor" opacity="0.25" />
<!-- long legs wide stance -->
<line x1="78" y1="133" x2="55" y2="190" stroke-width="3" />
<line x1="100" y1="133" x2="120" y2="192" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="48" cy="193" rx="13" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="127" cy="195" rx="13" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<!-- Shadok maraîchère (bottom-left, pushing wheelbarrow) -->
<svg class="shadok shadok-maraichere" viewBox="0 0 180 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body leaning forward pushing -->
<ellipse cx="120" cy="95" rx="21" ry="26" fill="currentColor" opacity="0.25" transform="rotate(-20 120 95)" />
<!-- head tilted forward -->
<circle cx="130" cy="58" r="14" fill="currentColor" opacity="0.3" />
<!-- eyes looking down at path -->
<circle cx="136" cy="56" r="2" fill="currentColor" />
<circle cx="136" cy="63" r="1.5" fill="currentColor" />
<!-- beak pointing right-down -->
<path d="M140 60 L154 64 L140 66" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arms pushing wheelbarrow handles -->
<line x1="104" y1="86" x2="72" y2="110" stroke-width="3" />
<line x1="108" y1="80" x2="72" y2="100" stroke-width="3" />
<!-- wheelbarrow body (big!) -->
<path d="M10 80 L70 80 L75 120 L5 120 Z" fill="currentColor" opacity="0.2" />
<path d="M10 80 L70 80 L75 120 L5 120 Z" />
<!-- wheelbarrow handles -->
<line x1="70" y1="90" x2="72" y2="110" stroke-width="3" />
<line x1="70" y1="100" x2="72" y2="100" stroke-width="2" />
<!-- wheel -->
<circle cx="12" cy="128" r="12" fill="currentColor" opacity="0.15" />
<circle cx="12" cy="128" r="12" />
<circle cx="12" cy="128" r="3" fill="currentColor" opacity="0.3" />
<!-- vegetables in wheelbarrow -->
<circle cx="22" cy="72" r="7" fill="currentColor" opacity="0.3" />
<circle cx="38" cy="70" r="8" fill="currentColor" opacity="0.25" />
<circle cx="55" cy="73" r="6" fill="currentColor" opacity="0.3" />
<ellipse cx="30" cy="76" rx="5" ry="8" fill="currentColor" opacity="0.2" transform="rotate(15 30 76)" />
<!-- carrot tops -->
<line x1="22" y1="66" x2="18" y2="58" opacity="0.35" />
<line x1="22" y1="66" x2="25" y2="57" opacity="0.35" />
<line x1="55" y1="67" x2="52" y2="60" opacity="0.3" />
<line x1="55" y1="67" x2="58" y2="59" opacity="0.3" />
<!-- long legs striding -->
<line x1="112" y1="118" x2="100" y2="180" stroke-width="3" />
<line x1="128" y1="118" x2="150" y2="178" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="94" cy="183" rx="12" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="156" cy="181" rx="12" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<!-- Shadok tisserand (bottom-right, sitting at loom) -->
<svg class="shadok shadok-tisserand" viewBox="0 0 170 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body sitting upright -->
<ellipse cx="55" cy="100" rx="20" ry="25" fill="currentColor" opacity="0.25" />
<!-- head looking down at loom -->
<circle cx="55" cy="65" r="15" fill="currentColor" opacity="0.3" />
<!-- eyes focused downward -->
<circle cx="62" cy="66" r="2" fill="currentColor" />
<circle cx="60" cy="72" r="1.5" fill="currentColor" />
<!-- beak pointing right-down -->
<path d="M66 70 L78 74 L64 76" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arms reaching to loom -->
<line x1="72" y1="92" x2="100" y2="80" stroke-width="3" />
<line x1="74" y1="100" x2="105" y2="100" stroke-width="3" />
<!-- loom frame (big detailed) -->
<rect x="95" y="55" width="60" height="80" rx="3" fill="none" />
<!-- vertical loom posts -->
<line x1="95" y1="55" x2="95" y2="135" stroke-width="3" />
<line x1="155" y1="55" x2="155" y2="135" stroke-width="3" />
<!-- top beam -->
<line x1="95" y1="55" x2="155" y2="55" stroke-width="3" />
<!-- warp threads (vertical) -->
<line x1="105" y1="55" x2="105" y2="135" opacity="0.3" />
<line x1="115" y1="55" x2="115" y2="135" opacity="0.3" />
<line x1="125" y1="55" x2="125" y2="135" opacity="0.3" />
<line x1="135" y1="55" x2="135" y2="135" opacity="0.3" />
<line x1="145" y1="55" x2="145" y2="135" opacity="0.3" />
<!-- weft threads (horizontal, partial = work in progress) -->
<line x1="95" y1="70" x2="155" y2="70" opacity="0.25" />
<line x1="95" y1="80" x2="155" y2="80" opacity="0.25" />
<line x1="95" y1="90" x2="145" y2="90" opacity="0.25" />
<line x1="95" y1="100" x2="135" y2="100" opacity="0.2" />
<!-- shuttle in hand -->
<ellipse cx="105" cy="100" rx="8" ry="3" fill="currentColor" opacity="0.3" transform="rotate(-5 105 100)" />
<!-- woven fabric area -->
<rect x="97" y="62" width="56" height="28" fill="currentColor" opacity="0.08" />
<!-- legs folded sitting on stool -->
<line x1="45" y1="123" x2="30" y2="178" stroke-width="3" />
<line x1="65" y1="123" x2="70" y2="180" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="24" cy="181" rx="11" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="76" cy="183" rx="11" ry="4" fill="currentColor" opacity="0.3" />
<!-- stool -->
<rect x="38" y="124" width="30" height="6" rx="2" fill="currentColor" opacity="0.15" />
<line x1="42" y1="130" x2="40" y2="148" stroke-width="2" opacity="0.3" />
<line x1="64" y1="130" x2="66" y2="148" stroke-width="2" opacity="0.3" />
</g>
</svg>
<!-- Shadok berger (center-bottom, walking with sheep) -->
<svg class="shadok shadok-berger" viewBox="0 0 180 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body walking, slight lean -->
<ellipse cx="85" cy="90" rx="20" ry="26" fill="currentColor" opacity="0.25" transform="rotate(-5 85 90)" />
<!-- head 3/4 view -->
<circle cx="88" cy="52" r="16" fill="currentColor" opacity="0.3" />
<!-- eyes looking ahead, slightly different directions -->
<circle cx="94" cy="48" r="2" fill="currentColor" />
<circle cx="96" cy="56" r="1.8" fill="currentColor" />
<!-- beak -->
<path d="M100 52 L114 50 L100 56" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arm holding staff -->
<line x1="68" y1="82" x2="50" y2="60" stroke-width="3" />
<!-- shepherd's crook (big, detailed) -->
<line x1="50" y1="60" x2="44" y2="10" stroke-width="3" />
<path d="M44 10 Q44 0 54 0 Q62 0 62 10 Q62 18 54 18" fill="none" stroke-width="3" />
<!-- other arm relaxed -->
<line x1="104" y1="84" x2="118" y2="95" stroke-width="3" />
<!-- long legs walking -->
<line x1="76" y1="114" x2="60" y2="175" stroke-width="3" />
<line x1="94" y1="114" x2="110" y2="172" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="54" cy="178" rx="12" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="116" cy="175" rx="12" ry="4" fill="currentColor" opacity="0.3" />
<!-- sheep 1 (following) -->
<ellipse cx="140" cy="160" rx="14" ry="10" fill="currentColor" opacity="0.18" />
<circle cx="150" cy="152" r="6" fill="currentColor" opacity="0.15" />
<circle cx="153" cy="150" r="1" fill="currentColor" />
<line x1="132" y1="170" x2="132" y2="180" stroke-width="2" opacity="0.3" />
<line x1="148" y1="170" x2="148" y2="180" stroke-width="2" opacity="0.3" />
<!-- sheep 2 -->
<ellipse cx="158" cy="172" rx="12" ry="8" fill="currentColor" opacity="0.14" />
<circle cx="166" cy="166" r="5" fill="currentColor" opacity="0.12" />
<circle cx="168" cy="164" r="0.8" fill="currentColor" />
<line x1="152" y1="180" x2="152" y2="188" stroke-width="1.5" opacity="0.25" />
<line x1="164" y1="180" x2="164" y2="188" stroke-width="1.5" opacity="0.25" />
<!-- sheep 3 (smaller, behind) -->
<ellipse cx="170" cy="178" rx="9" ry="6" fill="currentColor" opacity="0.1" />
<circle cx="176" cy="174" r="4" fill="currentColor" opacity="0.08" />
<!-- dog at side -->
<ellipse cx="125" cy="170" rx="8" ry="5" fill="currentColor" opacity="0.2" />
<circle cx="130" cy="164" r="4" fill="currentColor" opacity="0.18" />
<circle cx="132" cy="163" r="1" fill="currentColor" />
<line x1="118" y1="168" x2="112" y2="165" stroke-width="1.5" opacity="0.3" />
<line x1="120" y1="175" x2="120" y2="182" stroke-width="1.5" opacity="0.25" />
<line x1="130" y1="175" x2="130" y2="182" stroke-width="1.5" opacity="0.25" />
</g>
</svg>
<!-- Shadok vigneronne (right, 55%, carrying grape basket) -->
<svg class="shadok shadok-vigneronne" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- body seen from 3/4 back, leaning under weight -->
<ellipse cx="80" cy="105" rx="22" ry="28" fill="currentColor" opacity="0.25" transform="rotate(10 80 105)" />
<!-- head turned slightly -->
<circle cx="72" cy="65" r="15" fill="currentColor" opacity="0.3" />
<!-- eyes looking to the side -->
<circle cx="64" cy="62" r="2" fill="currentColor" />
<circle cx="66" cy="69" r="1.5" fill="currentColor" />
<!-- beak pointing left -->
<path d="M58 65 L44 62 L58 69" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.2" />
<!-- arm back holding basket strap -->
<line x1="98" y1="92" x2="115" y2="75" stroke-width="3" />
<!-- arm front with pruning shears -->
<line x1="62" y1="95" x2="38" y2="80" stroke-width="3" />
<!-- pruning shears (big) -->
<line x1="38" y1="80" x2="26" y2="68" stroke-width="3" />
<line x1="38" y1="80" x2="28" y2="78" stroke-width="3" />
<circle cx="36" cy="78" r="3" fill="currentColor" opacity="0.2" />
<!-- blade shapes -->
<path d="M26 68 Q22 72 28 74" fill="none" stroke-width="2" />
<path d="M28 78 Q22 76 24 72" fill="none" stroke-width="2" />
<!-- big basket on back with grapes -->
<path d="M100 60 L130 60 L135 120 L95 120 Z" fill="currentColor" opacity="0.2" />
<path d="M100 60 L130 60 L135 120 L95 120 Z" />
<!-- basket weave texture -->
<line x1="100" y1="75" x2="133" y2="75" opacity="0.2" />
<line x1="98" y1="90" x2="134" y2="90" opacity="0.2" />
<line x1="97" y1="105" x2="135" y2="105" opacity="0.2" />
<!-- grapes overflowing -->
<circle cx="108" cy="55" r="4" fill="currentColor" opacity="0.3" />
<circle cx="116" cy="53" r="4.5" fill="currentColor" opacity="0.3" />
<circle cx="124" cy="55" r="4" fill="currentColor" opacity="0.25" />
<circle cx="112" cy="48" r="3.5" fill="currentColor" opacity="0.25" />
<circle cx="120" cy="47" r="3.5" fill="currentColor" opacity="0.2" />
<circle cx="105" cy="52" r="3" fill="currentColor" opacity="0.2" />
<!-- grape stems -->
<line x1="115" y1="43" x2="115" y2="36" opacity="0.3" />
<line x1="113" y1="36" x2="117" y2="36" opacity="0.3" />
<!-- grapevine nearby -->
<line x1="148" y1="30" x2="148" y2="130" stroke-width="2" opacity="0.2" />
<path d="M148 50 Q158 45 162 55" fill="none" opacity="0.2" />
<path d="M148 75 Q160 70 165 80" fill="none" opacity="0.2" />
<circle cx="160" cy="58" r="3" fill="currentColor" opacity="0.12" />
<circle cx="163" cy="83" r="3" fill="currentColor" opacity="0.1" />
<!-- grape leaf -->
<path d="M148 90 Q155 85 158 90 Q160 95 155 98 Q150 95 148 90" fill="currentColor" opacity="0.12" />
<!-- long legs -->
<line x1="70" y1="132" x2="55" y2="192" stroke-width="3" />
<line x1="90" y1="132" x2="105" y2="190" stroke-width="3" />
<!-- flat feet -->
<ellipse cx="49" cy="195" rx="12" ry="4" fill="currentColor" opacity="0.3" />
<ellipse cx="111" cy="193" rx="12" ry="4" fill="currentColor" opacity="0.3" />
</g>
</svg>
<div class="container-content">
<!-- Header -->
<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" 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 -->
<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>
<!-- Modèle économique — bloc livre -->
<div class="book-block">
<HomeBookSection
compact
@open-player="showBookPlayer = true"
@open-pdf="showPdfReader = true"
/>
</div>
<!-- Productions collectives -->
<NuxtLink to="/economique/productions-collectives" class="item-card group">
<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 {
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 {
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);
}
/* Shadok illustrations */
.shadok {
position: absolute;
pointer-events: none;
width: clamp(70px, 10vw, 140px);
z-index: 0;
}
.shadok-boulangere {
top: 1%;
left: 2%;
opacity: 0.22;
color: hsl(var(--color-primary));
animation: shadok-float-1 9s ease-in-out infinite;
}
.shadok-potier {
top: 1%;
right: 2%;
opacity: 0.2;
color: hsl(var(--color-accent));
animation: shadok-float-2 11s ease-in-out infinite;
}
.shadok-apicultrice {
left: 2%;
top: 40%;
opacity: 0.2;
color: hsl(var(--color-accent));
animation: shadok-float-3 10s ease-in-out infinite;
}
.shadok-forgeron {
right: 3%;
top: 35%;
opacity: 0.24;
color: hsl(var(--color-primary));
animation: shadok-float-4 8s ease-in-out infinite;
}
.shadok-maraichere {
bottom: 14%;
left: 1%;
opacity: 0.18;
color: hsl(var(--color-primary));
animation: shadok-float-5 12s ease-in-out infinite;
}
.shadok-tisserand {
bottom: 12%;
right: 1%;
opacity: 0.22;
color: hsl(var(--color-accent));
animation: shadok-float-6 9.5s ease-in-out infinite;
}
.shadok-berger {
bottom: 2%;
left: 50%;
transform: translateX(-50%);
opacity: 0.28;
color: hsl(var(--color-primary));
animation: shadok-float-7 11s ease-in-out infinite;
}
.shadok-vigneronne {
right: 2%;
top: 55%;
opacity: 0.2;
color: hsl(var(--color-accent));
animation: shadok-float-8 7.5s ease-in-out infinite;
}
@keyframes shadok-float-1 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-2 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-3 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes shadok-float-4 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
@keyframes shadok-float-5 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-11px); }
}
@keyframes shadok-float-6 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-7 {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-10px); }
}
@keyframes shadok-float-8 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
@media (max-width: 768px) {
.shadok {
display: none;
}
}
</style>
@@ -14,7 +14,7 @@
<nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8"> <nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8">
<NuxtLink <NuxtLink
v-if="prevChapter" v-if="prevChapter"
:to="`/lire/${prevChapter.stem}`" :to="`/economique/modele-eco/${prevChapter.stem?.split('/').pop()}`"
class="btn-ghost gap-2" class="btn-ghost gap-2"
> >
<div class="i-lucide-arrow-left h-4 w-4" /> <div class="i-lucide-arrow-left h-4 w-4" />
@@ -24,7 +24,7 @@
<NuxtLink <NuxtLink
v-if="nextChapter" v-if="nextChapter"
:to="`/lire/${nextChapter.stem}`" :to="`/economique/modele-eco/${nextChapter.stem?.split('/').pop()}`"
class="btn-ghost gap-2" class="btn-ghost gap-2"
> >
<span class="text-sm">{{ nextChapter.title }}</span> <span class="text-sm">{{ nextChapter.title }}</span>
@@ -43,6 +43,11 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const slug = route.params.slug as string 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 // Initialize guided mode
useGuidedMode() useGuidedMode()
@@ -54,8 +59,11 @@ if (!chapter.value) {
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' }) throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
} }
useHead({ useSeoPage({
title: chapter.value?.title, 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 // Get adjacent chapters for navigation
@@ -64,7 +72,7 @@ const { data: allChapters } = await useAsyncData('book-nav', () =>
) )
const currentIndex = computed(() => const currentIndex = computed(() =>
allChapters.value?.findIndex(c => c.stem === slug) ?? -1, allChapters.value?.findIndex(c => c.stem?.split('/').pop() === slug) ?? -1,
) )
const prevChapter = computed(() => { const prevChapter = computed(() => {
+586
View File
@@ -0,0 +1,586 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok illustrations -->
<!-- 1. Typographe — placing movable type in composing stick, profile view -->
<svg class="shadok shadok-typographe" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body (small oval, profile facing right) -->
<ellipse cx="70" cy="72" rx="22" ry="28" fill="currentColor" opacity="0.28"/>
<!-- head -->
<circle cx="78" cy="36" r="16" fill="currentColor" opacity="0.3"/>
<!-- beak (pointy, profile right) -->
<polygon points="94,34 110,38 94,42" fill="currentColor" opacity="0.35"/>
<!-- eyes (profile — one visible) -->
<circle cx="84" cy="33" r="2.5" fill="currentColor" opacity="0.6"/>
<!-- apron -->
<path d="M52 60 L88 60 L85 100 L55 100 Z" fill="currentColor" opacity="0.15"/>
<line x1="70" y1="60" x2="70" y2="100" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<!-- arm reaching to composing stick -->
<line x1="88" y1="65" x2="120" y2="55" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- other arm holding type block -->
<line x1="52" y1="68" x2="35" y2="58" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- composing stick (big, detailed) -->
<rect x="110" y="40" width="50" height="14" rx="2" fill="currentColor" opacity="0.2"/>
<rect x="110" y="42" width="50" height="10" rx="1" stroke="currentColor" stroke-width="1" fill="none" opacity="0.25"/>
<!-- type blocks in stick -->
<rect x="114" y="44" width="6" height="7" rx="0.5" fill="currentColor" opacity="0.3"/>
<rect x="122" y="44" width="5" height="7" rx="0.5" fill="currentColor" opacity="0.25"/>
<rect x="129" y="44" width="7" height="7" rx="0.5" fill="currentColor" opacity="0.3"/>
<rect x="138" y="44" width="5" height="7" rx="0.5" fill="currentColor" opacity="0.22"/>
<rect x="145" y="44" width="6" height="7" rx="0.5" fill="currentColor" opacity="0.28"/>
<!-- tiny letter on held block -->
<rect x="30" y="52" width="8" height="10" rx="1" fill="currentColor" opacity="0.25"/>
<text x="32" y="60" font-size="6" fill="currentColor" opacity="0.5" font-family="serif">A</text>
<!-- long legs -->
<line x1="62" y1="98" x2="50" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="78" y1="98" x2="90" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet -->
<ellipse cx="45" cy="168" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="95" cy="168" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 2. Lectrice — sitting in armchair, legs crossed, book on lap, reading glasses -->
<svg class="shadok shadok-lectrice" viewBox="0 0 180 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- armchair back -->
<path d="M30 55 Q25 50 28 30 L85 28 Q88 50 83 55" fill="currentColor" opacity="0.12"/>
<!-- armchair seat -->
<path d="M25 55 L88 55 L92 90 L20 90 Z" fill="currentColor" opacity="0.15"/>
<!-- armchair arms -->
<rect x="15" y="45" width="12" height="45" rx="5" fill="currentColor" opacity="0.15"/>
<rect x="85" y="45" width="12" height="45" rx="5" fill="currentColor" opacity="0.15"/>
<!-- body (seated, small) -->
<ellipse cx="58" cy="68" rx="20" ry="25" fill="currentColor" opacity="0.28"/>
<!-- head -->
<circle cx="58" cy="32" r="15" fill="currentColor" opacity="0.3"/>
<!-- beak (small, facing right-down toward book) -->
<polygon points="70,35 80,40 70,42" fill="currentColor" opacity="0.3"/>
<!-- reading glasses -->
<circle cx="52" cy="30" r="6" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.4"/>
<circle cx="65" cy="30" r="6" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.4"/>
<line x1="58" y1="30" x2="59" y2="30" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
<!-- eyes behind glasses (looking down) -->
<circle cx="53" cy="31" r="2" fill="currentColor" opacity="0.55"/>
<circle cx="64" cy="31" r="2" fill="currentColor" opacity="0.55"/>
<!-- arms holding open book on lap -->
<line x1="40" y1="60" x2="35" y2="82" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="76" y1="60" x2="80" y2="82" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- open book on lap -->
<path d="M32 80 L58 88 L84 80 L84 95 L58 103 L32 95 Z" fill="currentColor" opacity="0.18"/>
<line x1="58" y1="88" x2="58" y2="103" stroke="currentColor" stroke-width="1" opacity="0.25"/>
<!-- text lines on pages -->
<line x1="37" y1="86" x2="54" y2="91" stroke="currentColor" stroke-width="0.7" opacity="0.2"/>
<line x1="37" y1="89" x2="54" y2="94" stroke="currentColor" stroke-width="0.7" opacity="0.2"/>
<line x1="62" y1="91" x2="79" y2="86" stroke="currentColor" stroke-width="0.7" opacity="0.2"/>
<line x1="62" y1="94" x2="79" y2="89" stroke="currentColor" stroke-width="0.7" opacity="0.2"/>
<!-- crossed legs (long!) -->
<line x1="48" y1="90" x2="30" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="68" y1="90" x2="55" y2="150" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- the crossed leg goes over -->
<line x1="55" y1="150" x2="75" y2="140" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.22"/>
<!-- big flat feet -->
<ellipse cx="25" cy="168" rx="11" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="79" cy="142" rx="9" ry="3.5" fill="currentColor" opacity="0.22"/>
<!-- cup of tea nearby -->
<rect x="130" y="78" width="14" height="16" rx="3" fill="currentColor" opacity="0.2"/>
<ellipse cx="137" cy="78" rx="7" ry="2.5" fill="currentColor" opacity="0.25"/>
<!-- tea handle -->
<path d="M144 83 Q152 86 144 92" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.2"/>
<!-- steam -->
<path d="M134 73 Q132 68 135 64" stroke="currentColor" stroke-width="0.8" fill="none" opacity="0.15"/>
<path d="M139 74 Q141 69 138 65" stroke="currentColor" stroke-width="0.8" fill="none" opacity="0.15"/>
</svg>
<!-- 3. Calligraphe — standing at tilted drafting table, large quill, ink flourishes -->
<svg class="shadok shadok-calligraphe" viewBox="0 0 180 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- drafting table (tilted, big) -->
<rect x="75" y="50" width="65" height="85" rx="3" fill="currentColor" opacity="0.12" transform="rotate(-15 108 92)"/>
<!-- table legs -->
<line x1="85" y1="130" x2="78" y2="195" stroke="currentColor" stroke-width="2.5" opacity="0.18"/>
<line x1="140" y1="115" x2="150" y2="195" stroke="currentColor" stroke-width="2.5" opacity="0.18"/>
<!-- paper on table -->
<rect x="85" y="58" width="48" height="65" rx="1" fill="currentColor" opacity="0.08" transform="rotate(-15 109 90)"/>
<!-- ink flourishes on paper -->
<path d="M95 80 Q105 70 115 82 Q120 90 110 95" stroke="currentColor" stroke-width="1.2" fill="none" opacity="0.25" transform="rotate(-15 105 85)"/>
<path d="M100 95 Q108 88 118 98" stroke="currentColor" stroke-width="0.8" fill="none" opacity="0.2" transform="rotate(-15 109 93)"/>
<!-- body (3/4 view, facing table) -->
<ellipse cx="55" cy="95" rx="21" ry="27" fill="currentColor" opacity="0.28"/>
<!-- head (turned toward table) -->
<circle cx="62" cy="58" r="16" fill="currentColor" opacity="0.3"/>
<!-- beak pointing at paper -->
<polygon points="75,55 90,52 78,60" fill="currentColor" opacity="0.32"/>
<!-- eyes (looking at paper, slightly different directions) -->
<circle cx="66" cy="55" r="2.5" fill="currentColor" opacity="0.6"/>
<circle cx="58" cy="54" r="2" fill="currentColor" opacity="0.5"/>
<!-- arm holding large quill -->
<line x1="72" y1="85" x2="105" y2="65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- large quill -->
<line x1="105" y1="65" x2="100" y2="80" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
<path d="M105 65 L115 30 Q108 45 100 40 Q105 55 105 65" fill="currentColor" opacity="0.2"/>
<!-- other arm resting on table edge -->
<line x1="40" y1="88" x2="80" y2="85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- ink trailing from quill tip -->
<path d="M100 80 Q95 90 98 100 Q102 108 96 115" stroke="currentColor" stroke-width="0.8" fill="none" opacity="0.2"/>
<!-- long legs -->
<line x1="45" y1="120" x2="35" y2="190" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="65" y1="120" x2="72" y2="190" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet -->
<ellipse cx="30" cy="193" rx="11" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="77" cy="193" rx="11" ry="4" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 4. Relieur — sewing book spine with needle and thread, stack of signatures -->
<svg class="shadok shadok-relieur" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body (leaning forward over work) -->
<ellipse cx="85" cy="80" rx="22" ry="26" fill="currentColor" opacity="0.28" transform="rotate(10 85 80)"/>
<!-- head (tilted down, focused) -->
<circle cx="95" cy="46" r="15" fill="currentColor" opacity="0.3"/>
<!-- beak (pointing down at work) -->
<polygon points="105,52 115,62 103,58" fill="currentColor" opacity="0.32"/>
<!-- eyes (both looking down at different angles) -->
<circle cx="92" cy="44" r="2" fill="currentColor" opacity="0.55"/>
<circle cx="100" cy="46" r="2.5" fill="currentColor" opacity="0.6"/>
<!-- arm holding needle high -->
<line x1="100" y1="70" x2="130" y2="40" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- needle -->
<line x1="130" y1="40" x2="135" y2="32" stroke="currentColor" stroke-width="2" opacity="0.45"/>
<!-- thread from needle down to book -->
<path d="M132 38 Q140 55 125 75 Q115 90 120 100" stroke="currentColor" stroke-width="1" fill="none" stroke-dasharray="4 3" opacity="0.3"/>
<!-- other arm holding book spine -->
<line x1="68" y1="75" x2="50" y2="95" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- book being bound (open spine view, big) -->
<path d="M40 90 L60 85 L60 125 L40 130 Z" fill="currentColor" opacity="0.18"/>
<path d="M60 85 L80 90 L80 130 L60 125 Z" fill="currentColor" opacity="0.14"/>
<!-- stitching holes along spine -->
<circle cx="60" cy="92" r="1.2" fill="currentColor" opacity="0.35"/>
<circle cx="60" cy="100" r="1.2" fill="currentColor" opacity="0.35"/>
<circle cx="60" cy="108" r="1.2" fill="currentColor" opacity="0.35"/>
<circle cx="60" cy="116" r="1.2" fill="currentColor" opacity="0.35"/>
<!-- stack of folded signatures nearby -->
<rect x="10" y="125" width="30" height="5" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="12" y="119" width="28" height="5" rx="1" fill="currentColor" opacity="0.15"/>
<rect x="11" y="113" width="29" height="5" rx="1" fill="currentColor" opacity="0.2"/>
<rect x="13" y="107" width="27" height="5" rx="1" fill="currentColor" opacity="0.16"/>
<!-- long legs -->
<line x1="75" y1="104" x2="65" y2="172" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="95" y1="104" x2="105" y2="172" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet -->
<ellipse cx="60" cy="175" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="110" cy="175" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 5. Conteuse — on small stage, arms dramatically wide, 3 tiny shadoks below -->
<svg class="shadok shadok-conteuse" viewBox="0 0 180 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- small stage/box -->
<rect x="55" y="110" width="60" height="20" rx="3" fill="currentColor" opacity="0.18"/>
<rect x="58" y="108" width="54" height="4" rx="1.5" fill="currentColor" opacity="0.22"/>
<!-- body (on stage, upright, dramatic) -->
<ellipse cx="85" cy="85" rx="20" ry="26" fill="currentColor" opacity="0.28"/>
<!-- head (thrown back slightly) -->
<circle cx="85" cy="50" r="16" fill="currentColor" opacity="0.3"/>
<!-- beak (open, telling story) -->
<polygon points="98,46 112,42 100,52" fill="currentColor" opacity="0.3"/>
<polygon points="100,52 112,55 98,54" fill="currentColor" opacity="0.22"/>
<!-- wide expressive eyes -->
<circle cx="82" cy="46" r="3" fill="currentColor" opacity="0.6"/>
<circle cx="92" cy="45" r="2.5" fill="currentColor" opacity="0.55"/>
<!-- arms dramatically wide -->
<line x1="65" y1="75" x2="15" y2="50" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<line x1="105" y1="75" x2="160" y2="50" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- gesture sparkles -->
<circle cx="10" cy="45" r="2" fill="currentColor" opacity="0.15"/>
<circle cx="165" cy="45" r="2" fill="currentColor" opacity="0.15"/>
<circle cx="18" cy="38" r="1.5" fill="currentColor" opacity="0.12"/>
<circle cx="157" cy="38" r="1.5" fill="currentColor" opacity="0.12"/>
<!-- long legs (standing on stage) -->
<line x1="77" y1="108" x2="70" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="93" y1="108" x2="100" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet on stage -->
<ellipse cx="67" cy="112" rx="8" ry="3" fill="currentColor" opacity="0.2"/>
<ellipse cx="103" cy="112" rx="8" ry="3" fill="currentColor" opacity="0.2"/>
<!-- audience: 3 tiny shadoks below stage -->
<!-- tiny shadok 1 (left) -->
<ellipse cx="40" cy="165" rx="8" ry="10" fill="currentColor" opacity="0.18"/>
<circle cx="40" cy="152" r="6" fill="currentColor" opacity="0.22"/>
<circle cx="39" cy="151" r="1" fill="currentColor" opacity="0.45"/>
<circle cx="42" cy="151" r="1" fill="currentColor" opacity="0.45"/>
<polygon points="45,151 50,153 45,155" fill="currentColor" opacity="0.2"/>
<line x1="37" y1="175" x2="35" y2="195" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<line x1="43" y1="175" x2="45" y2="195" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<!-- tiny shadok 2 (center) -->
<ellipse cx="85" cy="168" rx="8" ry="10" fill="currentColor" opacity="0.18"/>
<circle cx="85" cy="155" r="6" fill="currentColor" opacity="0.22"/>
<circle cx="83" cy="154" r="1" fill="currentColor" opacity="0.45"/>
<circle cx="87" cy="154" r="1" fill="currentColor" opacity="0.45"/>
<polygon points="90,154 95,156 90,158" fill="currentColor" opacity="0.2"/>
<line x1="82" y1="178" x2="80" y2="198" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<line x1="88" y1="178" x2="90" y2="198" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<!-- tiny shadok 3 (right) -->
<ellipse cx="130" cy="163" rx="8" ry="10" fill="currentColor" opacity="0.18"/>
<circle cx="130" cy="150" r="6" fill="currentColor" opacity="0.22"/>
<circle cx="128" cy="149" r="1" fill="currentColor" opacity="0.45"/>
<circle cx="132" cy="149" r="1" fill="currentColor" opacity="0.45"/>
<polygon points="135,149 140,151 135,153" fill="currentColor" opacity="0.2"/>
<line x1="127" y1="173" x2="125" y2="193" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<line x1="133" y1="173" x2="135" y2="193" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
</svg>
<!-- 6. Correcteur — leaning forward, magnifying glass over manuscript, red pen -->
<svg class="shadok shadok-correcteur" viewBox="0 0 170 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body (leaning forward heavily) -->
<ellipse cx="75" cy="82" rx="21" ry="28" fill="currentColor" opacity="0.28" transform="rotate(20 75 82)"/>
<!-- head (craned forward) -->
<circle cx="95" cy="48" r="15" fill="currentColor" opacity="0.3"/>
<!-- beak (pursed, critical) -->
<polygon points="108,45 118,48 108,52" fill="currentColor" opacity="0.3"/>
<!-- eyes (squinting, one bigger — peering through glass) -->
<circle cx="92" cy="45" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="101" cy="46" r="3" fill="currentColor" opacity="0.6"/>
<!-- arm holding magnifying glass -->
<line x1="90" y1="72" x2="120" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- magnifying glass (big) -->
<circle cx="128" cy="98" r="18" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.3"/>
<circle cx="128" cy="98" r="16" fill="currentColor" opacity="0.06"/>
<!-- handle -->
<line x1="140" y1="112" x2="155" y2="135" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- manuscript under magnifying glass -->
<rect x="95" y="115" width="55" height="70" rx="2" fill="currentColor" opacity="0.1"/>
<!-- text lines on manuscript -->
<line x1="100" y1="125" x2="140" y2="125" stroke="currentColor" stroke-width="0.8" opacity="0.18"/>
<line x1="100" y1="132" x2="145" y2="132" stroke="currentColor" stroke-width="0.8" opacity="0.18"/>
<line x1="100" y1="139" x2="138" y2="139" stroke="currentColor" stroke-width="0.8" opacity="0.18"/>
<line x1="100" y1="146" x2="142" y2="146" stroke="currentColor" stroke-width="0.8" opacity="0.18"/>
<line x1="100" y1="153" x2="135" y2="153" stroke="currentColor" stroke-width="0.8" opacity="0.18"/>
<!-- crossed-out text (red corrections) -->
<line x1="100" y1="132" x2="130" y2="132" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
<line x1="105" y1="146" x2="125" y2="146" stroke="currentColor" stroke-width="1.5" opacity="0.35"/>
<!-- other arm holding red pen -->
<line x1="58" y1="78" x2="35" y2="100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- red pen -->
<line x1="35" y1="100" x2="25" y2="115" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.35"/>
<circle cx="24" cy="117" r="1.5" fill="currentColor" opacity="0.4"/>
<!-- long legs -->
<line x1="65" y1="108" x2="50" y2="178" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="82" y1="105" x2="95" y2="178" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet -->
<ellipse cx="45" cy="181" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="100" cy="181" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 7. Colporteur — walking profile, books in wooden crate on back, walking stick, hat -->
<svg class="shadok shadok-colporteur" viewBox="0 0 160 210" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body (profile, walking right, leaning forward under weight) -->
<ellipse cx="70" cy="72" rx="20" ry="25" fill="currentColor" opacity="0.28" transform="rotate(15 70 72)"/>
<!-- head -->
<circle cx="82" cy="38" r="14" fill="currentColor" opacity="0.3"/>
<!-- hat (brimmed) -->
<ellipse cx="82" cy="26" rx="18" ry="5" fill="currentColor" opacity="0.25"/>
<rect x="72" y="16" width="20" height="12" rx="3" fill="currentColor" opacity="0.2"/>
<!-- beak (profile right) -->
<polygon points="94,36 106,40 94,43" fill="currentColor" opacity="0.32"/>
<!-- eye (profile — one visible, determined) -->
<circle cx="88" cy="35" r="2.5" fill="currentColor" opacity="0.6"/>
<!-- wooden crate on back (big, loaded with books) -->
<rect x="25" y="35" width="40" height="50" rx="3" stroke="currentColor" stroke-width="2" fill="currentColor" opacity="0.12"/>
<!-- strap over shoulder -->
<line x1="45" y1="35" x2="78" y2="55" stroke="currentColor" stroke-width="2" opacity="0.25"/>
<line x1="65" y1="35" x2="85" y2="60" stroke="currentColor" stroke-width="2" opacity="0.25"/>
<!-- books in crate (visible spines) -->
<rect x="28" y="38" width="6" height="44" rx="1" fill="currentColor" opacity="0.22"/>
<rect x="36" y="40" width="5" height="42" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="43" y="37" width="7" height="45" rx="1" fill="currentColor" opacity="0.2"/>
<rect x="52" y="39" width="5" height="43" rx="1" fill="currentColor" opacity="0.16"/>
<rect x="58" y="38" width="4" height="44" rx="1" fill="currentColor" opacity="0.2"/>
<!-- arm forward with walking stick -->
<line x1="88" y1="65" x2="115" y2="80" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- walking stick (long) -->
<line x1="115" y1="80" x2="125" y2="195" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- other arm back holding strap -->
<line x1="55" y1="68" x2="45" y2="55" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- long legs (walking stride) — front leg forward, back leg behind -->
<line x1="78" y1="95" x2="100" y2="170" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="62" y1="95" x2="40" y2="170" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet (walking) -->
<ellipse cx="105" cy="173" rx="11" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="35" cy="173" rx="10" ry="4" fill="currentColor" opacity="0.22"/>
</svg>
<!-- 8. Illustratrice — at easel, brush in one hand, palette in other, canvas showing a shadok -->
<svg class="shadok shadok-illustratrice" viewBox="0 0 180 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- easel legs -->
<line x1="105" y1="30" x2="85" y2="210" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="145" y1="30" x2="165" y2="210" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<line x1="125" y1="60" x2="125" y2="210" stroke="currentColor" stroke-width="2" opacity="0.18"/>
<!-- canvas on easel -->
<rect x="95" y="25" width="60" height="75" rx="2" fill="currentColor" opacity="0.08"/>
<rect x="95" y="25" width="60" height="75" rx="2" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.2"/>
<!-- tiny shadok drawing on canvas! -->
<ellipse cx="125" cy="65" rx="8" ry="10" fill="currentColor" opacity="0.18"/>
<circle cx="125" cy="52" r="6" fill="currentColor" opacity="0.2"/>
<polygon points="130,51 136,53 130,55" fill="currentColor" opacity="0.18"/>
<circle cx="123" cy="51" r="1" fill="currentColor" opacity="0.3"/>
<circle cx="127" cy="51" r="1" fill="currentColor" opacity="0.3"/>
<line x1="121" y1="75" x2="118" y2="88" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="129" y1="75" x2="132" y2="88" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<!-- body (3/4 view, standing back from easel) -->
<ellipse cx="55" cy="90" rx="22" ry="28" fill="currentColor" opacity="0.28"/>
<!-- head (looking at canvas) -->
<circle cx="62" cy="52" r="16" fill="currentColor" opacity="0.3"/>
<!-- beak (profile right, toward canvas) -->
<polygon points="75,50 88,54 76,56" fill="currentColor" opacity="0.3"/>
<!-- eyes (artistic scrutiny, different sizes) -->
<circle cx="60" cy="49" r="2" fill="currentColor" opacity="0.55"/>
<circle cx="69" cy="50" r="2.8" fill="currentColor" opacity="0.6"/>
<!-- arm holding brush toward canvas -->
<line x1="73" y1="82" x2="100" y2="60" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<!-- paintbrush -->
<line x1="100" y1="60" x2="108" y2="52" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<circle cx="110" cy="50" r="2" fill="currentColor" opacity="0.3"/>
<!-- other arm holding palette -->
<line x1="38" y1="85" x2="18" y2="95" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- palette (big, with color dots) -->
<ellipse cx="12" cy="102" rx="16" ry="12" fill="currentColor" opacity="0.15"/>
<!-- thumb hole -->
<circle cx="12" cy="108" r="3" fill="currentColor" opacity="0.05"/>
<!-- paint dabs on palette -->
<circle cx="6" cy="97" r="2.5" fill="currentColor" opacity="0.3"/>
<circle cx="14" cy="94" r="2" fill="currentColor" opacity="0.25"/>
<circle cx="21" cy="98" r="2.5" fill="currentColor" opacity="0.28"/>
<circle cx="8" cy="104" r="2" fill="currentColor" opacity="0.22"/>
<circle cx="18" cy="103" r="2" fill="currentColor" opacity="0.3"/>
<!-- long legs -->
<line x1="45" y1="116" x2="35" y2="188" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<line x1="65" y1="116" x2="75" y2="188" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.25"/>
<!-- big flat feet -->
<ellipse cx="30" cy="191" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
<ellipse cx="80" cy="191" rx="10" ry="4" fill="currentColor" opacity="0.25"/>
</svg>
<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="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight" style="color: hsl(var(--color-text))">
{{ 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"
>
<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.&nbsp;{{ 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">
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;
}
/* Shadok illustrations — shared */
.shadok {
position: absolute;
pointer-events: none;
width: clamp(70px, 10vw, 140px);
z-index: 0;
}
/* 1. Typographe — top left */
.shadok-typographe {
top: 1%;
left: 1%;
opacity: 0.24;
color: hsl(var(--color-primary));
animation: shadok-float-1 9s ease-in-out infinite;
}
/* 2. Lectrice — top right, sitting */
.shadok-lectrice {
top: 2%;
right: 1%;
opacity: 0.22;
color: hsl(var(--color-accent));
animation: shadok-float-2 11s ease-in-out infinite;
}
/* 3. Calligraphe — left, 40% */
.shadok-calligraphe {
top: 40%;
left: 1%;
opacity: 0.2;
color: hsl(var(--color-primary));
animation: shadok-float-3 10s ease-in-out infinite;
}
/* 4. Relieur — right, 35% */
.shadok-relieur {
top: 35%;
right: 2%;
opacity: 0.24;
color: hsl(var(--color-accent));
animation: shadok-float-4 8s ease-in-out infinite;
}
/* 5. Conteuse — bottom left */
.shadok-conteuse {
bottom: 6%;
left: 1%;
opacity: 0.22;
color: hsl(var(--color-primary));
animation: shadok-float-5 12s ease-in-out infinite;
}
/* 6. Correcteur — bottom right, leaning */
.shadok-correcteur {
bottom: 5%;
right: 1%;
opacity: 0.2;
color: hsl(var(--color-accent));
animation: shadok-float-6 9.5s ease-in-out infinite;
}
/* 7. Colporteur — center bottom, walking */
.shadok-colporteur {
bottom: 2%;
left: 50%;
transform: translateX(-50%);
opacity: 0.18;
color: hsl(var(--color-primary));
animation: shadok-float-7 7.5s ease-in-out infinite;
}
/* 8. Illustratrice — right, 58% */
.shadok-illustratrice {
top: 58%;
right: 1%;
opacity: 0.26;
color: hsl(var(--color-accent));
animation: shadok-float-8 10.5s ease-in-out infinite;
}
/* Float animations — each unique duration and offset */
@keyframes shadok-float-1 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-2 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-3 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes shadok-float-4 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
@keyframes shadok-float-5 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-11px); }
}
@keyframes shadok-float-6 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
@keyframes shadok-float-7 {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-10px); }
}
@keyframes shadok-float-8 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
/* Hidden on mobile */
@media (max-width: 768px) {
.shadok {
display: none;
}
}
</style>
+328
View File
@@ -0,0 +1,328 @@
<template>
<div class="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 mb-8 max-w-2xl mx-auto section-anchor"
>
<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="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,85 @@
<template>
<div class="axis-scope axis-scope--eco 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>
<h1 class="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-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">
<p class="leading-relaxed whitespace-pre-line" style="color: hsl(var(--color-text-muted))">
{{ content.content }}
</p>
</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/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 {
padding: 1.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
}
</style>
-110
View File
@@ -1,110 +0,0 @@
<template>
<div class="section-padding">
<div class="container-content">
<header class="mb-12 text-center">
<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">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<!-- Search + view toggle -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="relative flex-1 max-w-md">
<div class="i-lucide-search absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
<input
v-model="search"
type="text"
:placeholder="content?.searchPlaceholder"
class="w-full rounded-lg bg-surface border border-white/8 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-primary/50 focus:outline-none"
>
</div>
<div class="flex items-center gap-1 rounded-lg bg-surface p-1">
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'list'"
>
<div class="i-lucide-list h-4 w-4" />
</button>
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'grid'"
>
<div class="i-lucide-grid-3x3 h-4 w-4" />
</button>
</div>
</div>
<!-- Song list -->
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<!-- Song grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<p v-if="filteredSongs.length === 0" class="text-center text-white/40 py-12">
{{ content?.noResults }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('ecouter')
useHead({
title: content.value?.meta?.title ?? 'Écouter',
})
const store = usePlayerStore()
const bookData = useBookData()
const { loadFullPlaylist } = usePlaylist()
await bookData.init()
// Switch to free mode
store.setMode('free')
await loadFullPlaylist()
const search = ref('')
const viewMode = ref<'list' | 'grid'>('list')
const filteredSongs = computed(() => {
const songs = bookData.getSongs()
if (!search.value.trim()) return songs
const q = search.value.toLowerCase()
return songs.filter(
s => s.title.toLowerCase().includes(q)
|| s.artist.toLowerCase().includes(q)
|| s.tags.some(t => t.toLowerCase().includes(q)),
)
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<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">&#9834;</text>
<text x="195" y="88" fill="currentColor" opacity="0.25" font-size="16">&#9835;</text>
<text x="60" y="65" fill="currentColor" opacity="0.2" font-size="14">&#9833;</text>
<text x="180" y="72" fill="currentColor" opacity="0.2" font-size="20">&#9834;</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-3d">
<img
:src="homeContent?.book.coverImage"
:alt="homeContent?.book.coverAlt"
class="book-cover-img"
/>
</div>
</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">
{{ content?.title }}
</h1>
<p class="mt-4 text-white/60">
{{ content?.description }}
</p>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:gap-4 justify-center md:justify-start">
<UiBaseButton @click="showBookPlayer = true">
<div class="i-lucide-play mr-2 h-5 w-5" />
Présentation musicale
</UiBaseButton>
<UiBaseButton variant="accent" @click="showPdfReader = true">
<div class="i-lucide-book-open mr-2 h-5 w-5" />
Lire le livre
</UiBaseButton>
</div>
</div>
</div>
<!-- Search + view toggle -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="relative flex-1 max-w-md">
<div class="i-lucide-search absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
<input
v-model="search"
type="text"
:placeholder="content?.searchPlaceholder"
class="w-full rounded-lg bg-surface border border-white/8 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-primary/50 focus:outline-none"
>
</div>
<div class="flex items-center gap-1 rounded-lg bg-surface p-1">
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'list'"
>
<div class="i-lucide-list h-4 w-4" />
</button>
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'grid'"
>
<div class="i-lucide-grid-3x3 h-4 w-4" />
</button>
</div>
</div>
<!-- Song list -->
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<!-- Song grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<p v-if="filteredSongs.length === 0" class="text-center text-white/40 py-12">
{{ content?.noResults }}
</p>
</div>
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('en-musique')
const { data: homeContent } = await usePageContent('home')
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()
const bookData = useBookData()
const { loadFullPlaylist } = usePlaylist()
await bookData.init()
// Switch to free mode
store.setMode('free')
await loadFullPlaylist()
const search = ref('')
const viewMode = ref<'list' | 'grid'>('list')
const showBookPlayer = ref(false)
const showPdfReader = ref(false)
const filteredSongs = computed(() => {
const songs = bookData.getSongs()
if (!search.value.trim()) return songs
const q = search.value.toLowerCase()
return songs.filter(
s => s.title.toLowerCase().includes(q)
|| s.artist.toLowerCase().includes(q)
|| s.tags.some(t => t.toLowerCase().includes(q)),
)
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.book-cover-wrapper {
perspective: 800px;
display: flex;
justify-content: center;
}
.book-cover-3d {
aspect-ratio: 3 / 4;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid hsl(var(--color-text) / 0.1);
box-shadow:
0 12px 40px hsl(var(--color-text) / 0.15),
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: 280px;
}
.book-cover-3d:hover {
transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
box-shadow:
12px 16px 48px hsl(var(--color-text) / 0.2),
0 0 0 1px hsl(var(--color-primary) / 0.2);
}
.book-cover-img {
width: 200%;
height: 100%;
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>
+838
View File
@@ -0,0 +1,838 @@
<template>
<div class="relative overflow-hidden section-padding min-h-[70vh] flex items-center justify-center">
<!-- 1. Shadok funambule: walking on tightrope (top-left) -->
<svg class="shadok-funambule" viewBox="0 0 170 200" fill="none" aria-hidden="true">
<!-- Tightrope -->
<line x1="5" y1="170" x2="165" y2="170" stroke="currentColor" stroke-width="2" opacity="0.3"/>
<!-- Body (small oval, leaning forward) -->
<ellipse cx="85" cy="110" rx="20" ry="28" fill="currentColor" opacity="0.25" transform="rotate(-5 85 110)"/>
<!-- Head -->
<circle cx="88" cy="72" r="16" fill="currentColor" opacity="0.3"/>
<!-- Eyes (focused, looking down-right) -->
<circle cx="93" cy="70" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="99" cy="71" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak (pointy, right side) -->
<polygon points="103,74 116,72 103,78" fill="currentColor" opacity="0.3"/>
<!-- Balancing pole (big, horizontal) -->
<line x1="10" y1="92" x2="160" y2="88" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Arms holding pole -->
<line x1="67" y1="100" x2="45" y2="92" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="103" y1="98" x2="125" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Left leg (forward, on rope) -->
<line x1="78" y1="136" x2="70" y2="170" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="70" cy="172" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<!-- Right leg (back, lifted) -->
<line x1="92" y1="136" x2="108" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="108" cy="167" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- 2. Shadok accordeoniste: playing accordion (top-right, profile) -->
<svg class="shadok-accordeoniste" viewBox="0 0 180 210" fill="none" aria-hidden="true">
<!-- Body (profile, small) -->
<ellipse cx="70" cy="115" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Head (profile) -->
<circle cx="72" cy="75" r="16" fill="currentColor" opacity="0.3"/>
<!-- Beret -->
<ellipse cx="72" cy="60" rx="18" ry="6" fill="currentColor" opacity="0.3"/>
<circle cx="72" cy="56" r="4" fill="currentColor" opacity="0.25"/>
<!-- Eye (profile, one visible) -->
<circle cx="80" cy="73" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak (profile right) -->
<polygon points="86,76 98,74 86,80" fill="currentColor" opacity="0.3"/>
<!-- Accordion bellows (big, extended right) -->
<rect x="92" y="95" width="12" height="45" rx="2" fill="currentColor" opacity="0.3"/>
<line x1="98" y1="98" x2="98" y2="137" stroke="currentColor" stroke-width="0.8" opacity="0.2"/>
<rect x="107" y="93" width="8" height="49" rx="2" fill="currentColor" opacity="0.25"/>
<rect x="118" y="91" width="8" height="53" rx="2" fill="currentColor" opacity="0.2"/>
<rect x="129" y="89" width="8" height="57" rx="2" fill="currentColor" opacity="0.25"/>
<rect x="140" y="87" width="12" height="61" rx="2" fill="currentColor" opacity="0.3"/>
<!-- Keyboard dots on right panel -->
<circle cx="146" cy="100" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="146" cy="110" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="146" cy="120" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="146" cy="130" r="1.5" fill="currentColor" opacity="0.2"/>
<!-- Left arm on bellows -->
<line x1="52" y1="105" x2="92" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Right arm stretched to far end -->
<line x1="90" y1="108" x2="140" y2="100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Foot tapping (right lifted) -->
<line x1="60" y1="141" x2="52" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="52" cy="197" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<line x1="80" y1="141" x2="90" y2="188" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="90" cy="190" rx="8" ry="3" fill="currentColor" opacity="0.3" transform="rotate(-15 90 190)"/>
<!-- Music notes -->
<text x="155" y="80" fill="currentColor" opacity="0.25" font-size="14">&#9834;</text>
<text x="145" y="68" fill="currentColor" opacity="0.2" font-size="11">&#9835;</text>
</svg>
<!-- 3. Shadok jongleur: 4 balls in the air (top-center) -->
<svg class="shadok-jongleur" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="80" cy="120" rx="21" ry="27" fill="currentColor" opacity="0.25"/>
<!-- Head -->
<circle cx="80" cy="82" r="15" fill="currentColor" opacity="0.3"/>
<!-- Eyes (looking up, wide) -->
<circle cx="74" cy="78" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="86" cy="78" r="2" fill="currentColor" opacity="0.5"/>
<!-- Mouth open (concentration) -->
<ellipse cx="80" cy="92" rx="4" ry="3" fill="currentColor" opacity="0.2"/>
<!-- Beak (small, front view) -->
<polygon points="80,86 87,89 80,92" fill="currentColor" opacity="0.3"/>
<!-- Arms up wide -->
<line x1="60" y1="110" x2="30" y2="75" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="110" x2="130" y2="75" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- 4 juggling balls in arc -->
<circle cx="40" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="70" cy="18" r="8" fill="currentColor" opacity="0.25"/>
<circle cx="100" cy="15" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="128" cy="30" r="8" fill="currentColor" opacity="0.25"/>
<!-- Long legs -->
<line x1="72" y1="145" x2="60" y2="200" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="60" cy="202" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<line x1="88" y1="145" x2="100" y2="200" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="100" cy="202" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- 4. Shadok cracheur de feu: head tilted back, flame from mouth -->
<svg class="shadok-cracheur" viewBox="0 0 170 220" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="75" cy="125" rx="22" ry="30" fill="currentColor" opacity="0.25"/>
<!-- Head (tilted back) -->
<circle cx="78" cy="82" r="16" fill="currentColor" opacity="0.3" />
<!-- Eyes (looking up) -->
<circle cx="73" cy="76" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="83" cy="76" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak (pointing up, open) -->
<polygon points="78,66 85,52 72,66" fill="currentColor" opacity="0.3"/>
<!-- Flame stream from beak (big, upward-right) -->
<path d="M82 54 Q100 25 95 10 Q110 30 120 8 Q115 35 135 15 Q120 42 140 30 Q118 50 130 45" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="currentColor" opacity="0.2"/>
<!-- Torch in left hand -->
<line x1="55" y1="115" x2="30" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<rect x="24" y="78" width="8" height="16" rx="2" fill="currentColor" opacity="0.35"/>
<path d="M28 78 Q28 68 32 72 Q28 65 28 78" fill="currentColor" opacity="0.25"/>
<!-- Right arm out for balance -->
<line x1="95" y1="112" x2="125" y2="100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Long legs -->
<line x1="66" y1="153" x2="55" y2="210" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="55" cy="212" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<line x1="84" y1="153" x2="95" y2="210" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="95" cy="212" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- 5. Shadok trapeziste: hanging from trapeze, body in arc -->
<svg class="shadok-trapeziste" viewBox="0 0 150 220" fill="none" aria-hidden="true">
<!-- Trapeze ropes -->
<line x1="40" y1="0" x2="50" y2="40" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="110" y1="0" x2="100" y2="40" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<!-- Trapeze bar -->
<line x1="48" y1="40" x2="102" y2="40" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.35"/>
<!-- Arms (hanging from bar) -->
<line x1="60" y1="42" x2="65" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="90" y1="42" x2="85" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Body (arched, graceful) -->
<ellipse cx="75" cy="95" rx="20" ry="25" fill="currentColor" opacity="0.25" transform="rotate(10 75 95)"/>
<!-- Head (below body, looking down) -->
<circle cx="78" cy="72" r="14" fill="currentColor" opacity="0.3"/>
<!-- Eyes (excited, looking down) -->
<circle cx="74" cy="74" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="84" cy="75" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak (pointing down-right) -->
<polygon points="86,78 96,84 86,82" fill="currentColor" opacity="0.3"/>
<!-- Legs (pointed, graceful, extending down-right) -->
<line x1="80" y1="118" x2="95" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="70" y1="118" x2="82" y2="180" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Pointed feet -->
<polygon points="95,175 105,180 95,182" fill="currentColor" opacity="0.3"/>
<polygon points="82,180 92,185 82,187" fill="currentColor" opacity="0.3"/>
</svg>
<!-- 6. Shadok batteur: behind drum kit, sticks raised -->
<svg class="shadok-batteur" viewBox="0 0 180 210" fill="none" aria-hidden="true">
<!-- Snare drum (center, big) -->
<ellipse cx="90" cy="165" rx="35" ry="12" fill="currentColor" opacity="0.2"/>
<rect x="55" y="155" width="70" height="20" rx="3" fill="currentColor" opacity="0.2"/>
<ellipse cx="90" cy="155" rx="35" ry="12" fill="currentColor" opacity="0.25"/>
<!-- Hi-hat (left) -->
<ellipse cx="30" cy="140" rx="18" ry="5" fill="currentColor" opacity="0.2"/>
<line x1="30" y1="140" x2="30" y2="185" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<!-- Cymbal (right) -->
<ellipse cx="155" cy="120" rx="20" ry="5" fill="currentColor" opacity="0.2"/>
<line x1="155" y1="120" x2="155" y2="185" stroke="currentColor" stroke-width="2" opacity="0.2"/>
<!-- Body (behind kit) -->
<ellipse cx="90" cy="110" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Head -->
<circle cx="90" cy="70" r="16" fill="currentColor" opacity="0.3"/>
<!-- Eyes (intense, looking at drums) -->
<circle cx="84" cy="68" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="96" cy="69" r="2" fill="currentColor" opacity="0.5"/>
<!-- Beak (front, small) -->
<polygon points="90,76 97,80 90,82" fill="currentColor" opacity="0.3"/>
<!-- Arms raised with drumsticks -->
<line x1="70" y1="100" x2="40" y2="65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="40" y1="65" x2="25" y2="50" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<line x1="110" y1="100" x2="140" y2="60" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="140" y1="60" x2="158" y2="48" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<!-- Motion lines on sticks -->
<path d="M22 52 Q18 48 20 44" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M160 50 Q164 46 162 42" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
<!-- Legs (tucked behind kit) -->
<line x1="80" y1="136" x2="70" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="70" cy="197" rx="7" ry="3" fill="currentColor" opacity="0.25"/>
<line x1="100" y1="136" x2="110" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<ellipse cx="110" cy="197" rx="7" ry="3" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 7. Shadok marionnettiste: holding puppet strings -->
<svg class="shadok-marionnettiste" viewBox="0 0 160 220" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="80" cy="75" rx="20" ry="26" fill="currentColor" opacity="0.25"/>
<!-- Head -->
<circle cx="80" cy="40" r="15" fill="currentColor" opacity="0.3"/>
<!-- Eyes (looking down at puppet) -->
<circle cx="75" cy="42" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="86" cy="43" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak -->
<polygon points="89,44 100,42 89,48" fill="currentColor" opacity="0.3"/>
<!-- Arms down holding control bar -->
<line x1="62" y1="68" x2="50" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="98" y1="68" x2="110" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Control bar (cross) -->
<line x1="40" y1="108" x2="120" y2="108" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.35"/>
<line x1="80" y1="98" x2="80" y2="118" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.35"/>
<!-- Puppet strings -->
<line x1="45" y1="108" x2="55" y2="155" stroke="currentColor" stroke-width="1" opacity="0.25"/>
<line x1="80" y1="118" x2="70" y2="158" stroke="currentColor" stroke-width="1" opacity="0.25"/>
<line x1="115" y1="108" x2="85" y2="155" stroke="currentColor" stroke-width="1" opacity="0.25"/>
<line x1="80" y1="98" x2="70" y2="148" stroke="currentColor" stroke-width="1" opacity="0.25"/>
<!-- Mini puppet shadok -->
<circle cx="70" cy="155" r="8" fill="currentColor" opacity="0.2"/>
<ellipse cx="70" cy="172" rx="10" ry="13" fill="currentColor" opacity="0.15"/>
<!-- Puppet eyes -->
<circle cx="67" cy="154" r="1" fill="currentColor" opacity="0.4"/>
<circle cx="74" cy="154" r="1" fill="currentColor" opacity="0.4"/>
<!-- Puppet beak -->
<polygon points="76,156 82,155 76,158" fill="currentColor" opacity="0.2"/>
<!-- Puppet legs -->
<line x1="64" y1="184" x2="58" y2="208" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="76" y1="184" x2="82" y2="208" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<!-- Master shadok legs -->
<line x1="72" y1="99" x2="62" y2="155" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<line x1="88" y1="99" x2="98" y2="155" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3"/>
<ellipse cx="62" cy="157" rx="7" ry="3" fill="currentColor" opacity="0.25"/>
<ellipse cx="98" cy="157" rx="7" ry="3" fill="currentColor" opacity="0.25"/>
</svg>
<!-- 8. Shadok clown: oversized shoes, red nose, squirting flower -->
<svg class="shadok-clown" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="80" cy="110" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Head -->
<circle cx="80" cy="70" r="16" fill="currentColor" opacity="0.3"/>
<!-- Party hat (tall cone) -->
<polygon points="80,38 68,62 92,62" fill="currentColor" opacity="0.25"/>
<circle cx="80" cy="38" r="3" fill="currentColor" opacity="0.35"/>
<!-- Big red nose (circle on beak) -->
<circle cx="92" cy="74" r="5" fill="currentColor" opacity="0.4"/>
<!-- Beak behind nose -->
<polygon points="88,72 100,74 88,78" fill="currentColor" opacity="0.2"/>
<!-- Eyes (different directions — goofy) -->
<circle cx="74" cy="66" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="85" cy="64" r="2" fill="currentColor" opacity="0.5"/>
<!-- Big goofy grin -->
<path d="M72 82 Q80 90 88 82" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Squirting flower on chest -->
<circle cx="80" cy="95" r="6" fill="currentColor" opacity="0.25"/>
<circle cx="80" cy="95" r="2.5" fill="currentColor" opacity="0.35"/>
<!-- Water squirt from flower -->
<path d="M86 93 Q100 85 105 90" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<circle cx="107" cy="90" r="2" fill="currentColor" opacity="0.2"/>
<circle cx="112" cy="88" r="1.5" fill="currentColor" opacity="0.15"/>
<!-- Arms -->
<line x1="60" y1="100" x2="35" y2="90" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="100" x2="125" y2="85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Long legs -->
<line x1="72" y1="136" x2="50" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="88" y1="136" x2="110" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- OVERSIZED shoes -->
<ellipse cx="42" cy="190" rx="18" ry="6" fill="currentColor" opacity="0.3"/>
<ellipse cx="118" cy="190" rx="18" ry="6" fill="currentColor" opacity="0.3"/>
</svg>
<!-- 9. Shadok acrobate: mid-cartwheel, body rotated 90deg -->
<svg class="shadok-acrobate" viewBox="0 0 160 200" fill="none" aria-hidden="true">
<!-- Body (rotated 90 degrees) -->
<ellipse cx="80" cy="100" rx="20" ry="26" fill="currentColor" opacity="0.25" transform="rotate(90 80 100)"/>
<!-- Head (at bottom-right, inverted) -->
<circle cx="112" cy="100" r="14" fill="currentColor" opacity="0.3"/>
<!-- Eyes (dizzy, spiral-ish) -->
<path d="M108 97 Q110 94 113 97" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M117 97 Q119 94 122 97" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Beak (pointing right) -->
<polygon points="124,102 136,100 124,106" fill="currentColor" opacity="0.3"/>
<!-- Limbs in pinwheel pattern -->
<!-- Arm up-right -->
<line x1="85" y1="80" x2="110" y2="35" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Arm down-left -->
<line x1="75" y1="120" x2="50" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Leg up-left (hand on ground) -->
<line x1="65" y1="88" x2="25" y2="55" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<circle cx="23" cy="53" r="4" fill="currentColor" opacity="0.25"/>
<!-- Leg down-right -->
<line x1="95" y1="112" x2="135" y2="150" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="110" cy="33" rx="6" ry="3" fill="currentColor" opacity="0.25" transform="rotate(-60 110 33)"/>
<ellipse cx="135" cy="152" rx="6" ry="3" fill="currentColor" opacity="0.25" transform="rotate(40 135 152)"/>
<!-- Motion arc -->
<path d="M30 40 Q20 50 25 60" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M140 140 Q148 148 145 158" stroke="currentColor" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
</svg>
<!-- 10. Shadok regisseur: headset, clipboard, running -->
<svg class="shadok-regisseur" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Body (leaning forward, running) -->
<ellipse cx="75" cy="105" rx="20" ry="27" fill="currentColor" opacity="0.25" transform="rotate(-12 75 105)"/>
<!-- Head -->
<circle cx="80" cy="68" r="15" fill="currentColor" opacity="0.3"/>
<!-- Headset arc -->
<path d="M66 60 Q80 48 94 60" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Headset earpiece -->
<ellipse cx="66" cy="64" rx="4" ry="6" fill="currentColor" opacity="0.3"/>
<!-- Headset mic -->
<path d="M66 70 Q62 78 70 80" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Eyes (stressed, one looking forward one sideways) -->
<circle cx="76" cy="65" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="87" cy="64" r="2" fill="currentColor" opacity="0.5"/>
<!-- Beak -->
<polygon points="91,68 104,66 91,72" fill="currentColor" opacity="0.3"/>
<!-- Lanyard -->
<line x1="80" y1="82" x2="80" y2="100" stroke="currentColor" stroke-width="1.5" opacity="0.25"/>
<!-- Badge -->
<rect x="74" y="100" width="12" height="15" rx="2" fill="currentColor" opacity="0.25"/>
<rect x="76" y="103" width="8" height="3" rx="1" fill="currentColor" opacity="0.15"/>
<!-- Right arm pointing forward (with authority) -->
<line x1="92" y1="95" x2="135" y2="75" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Pointing finger -->
<line x1="135" y1="75" x2="145" y2="70" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<!-- Left arm holding clipboard -->
<line x1="58" y1="98" x2="38" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Clipboard (big) -->
<rect x="22" y="105" width="22" height="30" rx="2" fill="currentColor" opacity="0.25"/>
<rect x="28" y="102" width="10" height="5" rx="1" fill="currentColor" opacity="0.3"/>
<!-- Clipboard lines -->
<line x1="26" y1="114" x2="40" y2="114" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="26" y1="120" x2="38" y2="120" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="26" y1="126" x2="40" y2="126" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<!-- Legs (running stride, long) -->
<line x1="68" y1="130" x2="40" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="38" cy="197" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
<line x1="82" y1="130" x2="115" y2="190" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<ellipse cx="117" cy="192" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<div class="container-content relative z-10 text-center">
<!-- 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" style="color: hsl(var(--color-text))">
{{ content?.title }}
</h1>
<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>
<p v-if="evtContent?.tagline" class="font-mono text-sm tracking-widest uppercase" style="color: hsl(var(--color-primary))">
{{ evtContent.tagline }}
</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 section-padding">
<div class="container-content flex flex-col gap-14">
<!-- 3 axes -->
<div v-if="evtContent?.axes">
<h2 class="evt-section-title">Trois axes d'émancipation</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">
<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">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">
<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">Format & lieu</h2>
<div class="config-grid">
<div v-for="(cfg, i) in evtContent.config" :key="i" class="config-card">
<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">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('evenement')
const evtContent = computed(() => content.value as Record<string, any> | null)
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);
}
/* 1. Funambule — top-left */
.shadok-funambule {
position: absolute;
left: 4%;
top: 5%;
width: clamp(70px, 10vw, 140px);
opacity: 0.24;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-sway-funambule 9s ease-in-out infinite;
}
/* 2. Accordeoniste — top-right */
.shadok-accordeoniste {
position: absolute;
right: 3%;
top: 4%;
width: clamp(70px, 10vw, 140px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-bounce-accordeon 8s ease-in-out infinite;
}
/* 3. Jongleur — top-center */
.shadok-jongleur {
position: absolute;
left: 50%;
top: 2%;
transform: translateX(-50%);
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-jongleur 7s ease-in-out infinite;
}
/* 4. Cracheur de feu — left 5%, 40% */
.shadok-cracheur {
position: absolute;
left: 5%;
top: 40%;
width: clamp(70px, 10vw, 140px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-flicker-cracheur 8s ease-in-out infinite;
}
/* 5. Trapeziste — right 3%, 30% */
.shadok-trapeziste {
position: absolute;
right: 3%;
top: 30%;
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-swing-trapeze 10s ease-in-out infinite;
}
/* 6. Batteur — left 4%, bottom 15% */
.shadok-batteur {
position: absolute;
left: 4%;
bottom: 15%;
width: clamp(70px, 10vw, 140px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-pulse-batteur 7s ease-in-out infinite;
}
/* 7. Marionnettiste — right 4%, bottom 20% */
.shadok-marionnettiste {
position: absolute;
right: 4%;
bottom: 20%;
width: clamp(70px, 10vw, 130px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-bob-marionnette 9s ease-in-out infinite;
}
/* 8. Clown — bottom-left */
.shadok-clown {
position: absolute;
left: 6%;
bottom: 3%;
width: clamp(70px, 10vw, 135px);
opacity: 0.24;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-wobble-clown 8s ease-in-out infinite;
}
/* 9. Acrobate — center-bottom */
.shadok-acrobate {
position: absolute;
left: 50%;
bottom: 2%;
transform: translateX(-50%);
width: clamp(70px, 10vw, 130px);
opacity: 0.18;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-spin-acrobate 12s ease-in-out infinite;
}
/* 10. Regisseur — bottom-right */
.shadok-regisseur {
position: absolute;
right: 3%;
bottom: 4%;
width: clamp(70px, 10vw, 140px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-rush-regisseur 7s ease-in-out infinite;
}
@keyframes shadok-sway-funambule {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-5px) rotate(2deg); }
75% { transform: translateY(-3px) rotate(-2deg); }
}
@keyframes shadok-bounce-accordeon {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-8px); }
70% { transform: translateY(-3px); }
}
@keyframes shadok-float-jongleur {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-10px); }
}
@keyframes shadok-flicker-cracheur {
0%, 100% { transform: translateY(0) scale(1); }
30% { transform: translateY(-4px) scale(1.02); }
60% { transform: translateY(-2px) scale(0.99); }
}
@keyframes shadok-swing-trapeze {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(4deg); }
75% { transform: rotate(-4deg); }
}
@keyframes shadok-pulse-batteur {
0%, 100% { transform: translateY(0); }
15% { transform: translateY(-4px); }
30% { transform: translateY(0); }
45% { transform: translateY(-6px); }
60% { transform: translateY(0); }
}
@keyframes shadok-bob-marionnette {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
@keyframes shadok-wobble-clown {
0%, 100% { transform: translateY(0) rotate(0deg); }
30% { transform: translateY(-5px) rotate(-3deg); }
70% { transform: translateY(-2px) rotate(3deg); }
}
@keyframes shadok-spin-acrobate {
0% { transform: translateX(-50%) rotate(0deg); }
50% { transform: translateX(-50%) rotate(10deg); }
100% { transform: translateX(-50%) rotate(0deg); }
}
@keyframes shadok-rush-regisseur {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(3px) translateY(-5px); }
50% { transform: translateX(-2px) translateY(-2px); }
75% { transform: translateX(2px) translateY(-6px); }
}
@media (max-width: 768px) {
.shadok-funambule,
.shadok-accordeoniste,
.shadok-jongleur,
.shadok-cracheur,
.shadok-trapeziste,
.shadok-batteur,
.shadok-marionnettiste,
.shadok-clown,
.shadok-acrobate,
.shadok-regisseur {
display: 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));
filter: drop-shadow(0 6px 14px hsl(var(--color-primary) / 0.25));
}
.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));
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
/* ── 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));
margin-bottom: 1.5rem;
}
/* ── 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 {
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 {
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));
flex-shrink: 0;
}
/* ── 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 {
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));
flex-shrink: 0;
}
</style>
+74 -9
View File
@@ -1,11 +1,54 @@
<template> <template>
<div class="section-padding"> <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"> <div class="container-content max-w-3xl mx-auto">
<!-- Back link --> <!-- Back link -->
<UiScrollReveal> <UiScrollReveal>
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-8 transition-colors"> <NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-8 transition-colors">
<div class="i-lucide-arrow-left h-4 w-4" /> <div class="i-lucide-arrow-left h-4 w-4" />
Retour à l'accueil Retour &agrave; l'accueil
</NuxtLink> </NuxtLink>
</UiScrollReveal> </UiScrollReveal>
@@ -47,7 +90,7 @@
<p class="text-sm text-white/40 mb-4"> <p class="text-sm text-white/40 mb-4">
{{ content?.cta.note }} {{ content?.cta.note }}
</p> </p>
<UiBaseButton @click="launch"> <UiBaseButton :href="url" target="_blank" @click="launch">
<div class="i-lucide-external-link mr-2 h-5 w-5" /> <div class="i-lucide-external-link mr-2 h-5 w-5" />
{{ content?.cta.label }} {{ content?.cta.label }}
</UiBaseButton> </UiBaseButton>
@@ -55,16 +98,18 @@
</UiScrollReveal> </UiScrollReveal>
</div> </div>
</div> </div>
</NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { data: content } = await usePageContent('gratewizard') const { data: content } = await usePageContent('gratewizard')
useHead({ useSeoPage({
title: content.value?.meta?.title ?? 'GrateWizard — Coefficients relatifs', 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 { launch } = useGrateWizard() const { url, launch } = useGrateWizard()
</script> </script>
<style scoped> <style scoped>
@@ -75,14 +120,14 @@ const { launch } = useGrateWizard()
.gw-feature-card { .gw-feature-card {
padding: 1.5rem; padding: 1.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid hsl(20 8% 18%); border: 1px solid hsl(var(--color-text) / 0.1);
background: hsl(20 8% 8% / 0.5); background: hsl(var(--color-surface) / 0.5);
transition: border-color 0.3s ease, background 0.3s ease; transition: border-color 0.3s ease, background 0.3s ease;
} }
.gw-feature-card:hover { .gw-feature-card:hover {
border-color: hsl(40 80% 50% / 0.25); border-color: hsl(40 80% 50% / 0.25);
background: hsl(20 8% 10% / 0.5); background: hsl(var(--color-surface-light) / 0.5);
} }
code { code {
@@ -92,4 +137,24 @@ code {
border-radius: 0.25em; border-radius: 0.25em;
background: hsl(40 80% 50% / 0.1); 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> </style>
+5 -5
View File
@@ -1,16 +1,16 @@
<template> <template>
<div> <div>
<HomeHeroSection /> <HomeLanding @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" /> <HomeMessages />
<HomeGrateWizardTeaser />
<BookPlayer v-model="showBookPlayer" /> <BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" /> <BookPdfReader v-model="showPdfReader" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useSeoPage({
title: 'Accueil', 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) const showBookPlayer = ref(false)
-71
View File
@@ -1,71 +0,0 @@
<template>
<div class="section-padding">
<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="`/lire/${chapter.stem}`"
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!" />
</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('lire')
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);
}
</style>
+85 -11
View File
@@ -2,20 +2,33 @@
<div class="section-padding"> <div class="section-padding">
<div class="container-content mx-auto max-w-3xl"> <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> <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-if="messages?.length" class="space-y-4">
<div v-for="msg in messages" :key="msg.id" class="message-card"> <div v-for="msg in messages" :key="msg.id" class="message-card">
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p> <!-- En-tête auteur -->
<div class="mt-3 flex items-center gap-2 text-xs text-white/40"> <div class="flex items-center gap-2 mb-2">
<span class="font-semibold text-white/60">{{ msg.author }}</span> <span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
<span>&middot;</span> <span class="type-pill">{{ typeLabel(msg.type) }}</span>
<span>{{ formatDate(msg.createdAt) }}</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> </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"> <div class="mt-8 text-center">
<NuxtLink to="/" class="btn-ghost text-sm"> <NuxtLink to="/" class="btn-ghost text-sm">
@@ -28,12 +41,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useSeoPage({
title: 'Messages', title: 'Messages — Le Librodrome',
description: 'Questions, retours et réactions des lecteurs et visiteurs du Librodrome.',
}) })
const { data: messages } = await useFetch('/api/messages') 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) { function formatDate(iso: string) {
const date = new Date(iso) const date = new Date(iso)
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
@@ -41,10 +66,59 @@ function formatDate(iso: string) {
</script> </script>
<style scoped> <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 { .message-card {
background: hsl(20 8% 6%); background: hsl(var(--color-surface));
border: 1px solid hsl(20 8% 14%); border: 1px solid hsl(var(--color-text) / 0.1);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.25rem 1.5rem; 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> </style>
File diff suppressed because it is too large Load Diff
+720
View File
@@ -0,0 +1,720 @@
<template>
<div class="axis-scope axis-scope--num relative overflow-hidden section-padding">
<!-- Shadok codeuse — sitting at desk, typing on laptop, glasses -->
<svg class="shadok-codeuse" viewBox="0 0 170 200" fill="none" aria-hidden="true">
<!-- Desk -->
<rect x="15" y="120" width="80" height="5" rx="2" fill="currentColor" opacity="0.3"/>
<line x1="25" y1="125" x2="25" y2="165" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.25"/>
<line x1="85" y1="125" x2="85" y2="165" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.25"/>
<!-- Laptop on desk -->
<rect x="30" y="108" width="50" height="12" rx="2" fill="currentColor" opacity="0.35"/>
<path d="M35 108 L40 80 L70 80 L75 108" fill="currentColor" opacity="0.28"/>
<rect x="42" y="84" width="26" height="18" rx="1" fill="currentColor" opacity="0.15"/>
<!-- Code on screen < /> -->
<text x="48" y="96" font-size="8" fill="currentColor" opacity="0.4" font-family="monospace">&lt;/&gt;</text>
<!-- Body — seated, tilted forward -->
<ellipse cx="110" cy="105" rx="20" ry="26" fill="currentColor" opacity="0.25" transform="rotate(-10 110 105)"/>
<!-- Head — looking at screen -->
<circle cx="105" cy="68" r="15" fill="currentColor" opacity="0.3"/>
<!-- Glasses -->
<circle cx="100" cy="66" r="5" stroke="currentColor" stroke-width="1.2" fill="none" opacity="0.4"/>
<circle cx="111" cy="66" r="5" stroke="currentColor" stroke-width="1.2" fill="none" opacity="0.4"/>
<line x1="105" y1="66" x2="106" y2="66" stroke="currentColor" stroke-width="1" opacity="0.35"/>
<!-- Eyes behind glasses -->
<circle cx="101" cy="66" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="110" cy="65" r="1.5" fill="currentColor" opacity="0.5"/>
<!-- Beak — pointy, looking left -->
<polygon points="88,68 82,65 88,72" fill="currentColor" opacity="0.35"/>
<!-- Arms reaching to laptop -->
<line x1="95" y1="95" x2="78" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="125" y1="98" x2="80" y2="115" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Legs — bent, seated -->
<line x1="100" y1="128" x2="90" y2="180" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="118" y1="128" x2="125" y2="180" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="88" cy="183" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="127" cy="183" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok electricien — profile view, climbing utility pole, tool belt -->
<svg class="shadok-electricien" viewBox="0 0 150 210" fill="none" aria-hidden="true">
<!-- Utility pole -->
<rect x="68" y="5" width="8" height="200" rx="2" fill="currentColor" opacity="0.2"/>
<line x1="50" y1="30" x2="94" y2="30" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.25"/>
<line x1="45" y1="55" x2="99" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.22"/>
<!-- Cables hanging -->
<path d="M50 30 Q35 45 30 35" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.2"/>
<path d="M94 30 Q110 45 115 35" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.2"/>
<path d="M45 55 Q30 68 25 60" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.18"/>
<!-- Body — climbing, profile, tilted -->
<ellipse cx="95" cy="100" rx="20" ry="25" fill="currentColor" opacity="0.25" transform="rotate(15 95 100)"/>
<!-- Head — looking up at cable -->
<circle cx="100" cy="65" r="14" fill="currentColor" opacity="0.3"/>
<!-- Eyes — looking up -->
<circle cx="96" cy="61" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="105" cy="60" r="1.5" fill="currentColor" opacity="0.45"/>
<!-- Beak — profile right -->
<polygon points="114,63 122,60 114,67" fill="currentColor" opacity="0.35"/>
<!-- Hard hat -->
<ellipse cx="100" cy="53" rx="16" ry="5" fill="currentColor" opacity="0.3"/>
<path d="M88 53 Q100 42 112 53" fill="currentColor" opacity="0.25"/>
<!-- Arms — one gripping pole, one holding cable -->
<line x1="82" y1="90" x2="74" y2="75" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="108" y1="88" x2="118" y2="60" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Tool belt -->
<ellipse cx="95" cy="118" rx="22" ry="4" fill="currentColor" opacity="0.25"/>
<rect x="82" y="118" width="6" height="8" rx="1" fill="currentColor" opacity="0.2"/>
<rect x="105" y="118" width="5" height="10" rx="1" fill="currentColor" opacity="0.2"/>
<!-- Legs — climbing, spread on pole -->
<line x1="85" y1="122" x2="72" y2="185" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="105" y1="122" x2="76" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet gripping pole -->
<ellipse cx="70" cy="188" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="75" cy="178" rx="8" ry="3" fill="currentColor" opacity="0.28"/>
</svg>
<!-- Shadok soudeuse — welding mask flipped up, torch with sparks, apron -->
<svg class="shadok-soudeuse" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Body — upright, slight lean forward -->
<ellipse cx="80" cy="110" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Apron -->
<path d="M62 95 L60 140 L100 140 L98 95" fill="currentColor" opacity="0.18"/>
<line x1="80" y1="95" x2="80" y2="140" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<!-- Head -->
<circle cx="80" cy="65" r="16" fill="currentColor" opacity="0.3"/>
<!-- Welding mask flipped up on head -->
<rect x="65" y="42" width="30" height="18" rx="4" fill="currentColor" opacity="0.2"/>
<rect x="70" y="46" width="20" height="10" rx="2" fill="currentColor" opacity="0.12"/>
<!-- Eyes — squinting from bright light -->
<line x1="73" y1="64" x2="77" y2="64" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity="0.5"/>
<line x1="84" y1="63" x2="88" y2="63" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" opacity="0.45"/>
<!-- Beak — small, front-facing -->
<polygon points="78,72 80,78 82,72" fill="currentColor" opacity="0.35"/>
<!-- Right arm holding torch -->
<line x1="100" y1="100" x2="135" y2="85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Welding torch -->
<rect x="132" y="78" width="22" height="6" rx="2" fill="currentColor" opacity="0.35"/>
<line x1="154" y1="81" x2="160" y2="78" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<!-- Sparks from torch -->
<circle cx="158" cy="75" r="1.5" fill="currentColor" opacity="0.45"/>
<circle cx="155" cy="70" r="1" fill="currentColor" opacity="0.35"/>
<circle cx="160" cy="72" r="1.2" fill="currentColor" opacity="0.4"/>
<line x1="157" y1="76" x2="160" y2="68" stroke="currentColor" stroke-width="0.8" opacity="0.3"/>
<line x1="155" y1="78" x2="152" y2="71" stroke="currentColor" stroke-width="0.8" opacity="0.25"/>
<!-- Left arm down -->
<line x1="60" y1="100" x2="45" y2="125" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Legs — standing, spread -->
<line x1="70" y1="136" x2="58" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="90" y1="136" x2="102" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Big flat feet -->
<ellipse cx="55" cy="198" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="105" cy="198" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok admin réseau — tangled in cables, router at feet, frustrated -->
<svg class="shadok-admin-reseau" viewBox="0 0 160 200" fill="none" aria-hidden="true">
<!-- Body — upright but struggling -->
<ellipse cx="80" cy="95" rx="21" ry="27" fill="currentColor" opacity="0.25"/>
<!-- Head — tilted, frustrated -->
<circle cx="82" cy="52" r="15" fill="currentColor" opacity="0.3" />
<!-- Eyes — looking in different directions (frustrated) -->
<circle cx="77" cy="50" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="88" cy="48" r="1.8" fill="currentColor" opacity="0.45"/>
<!-- Angry eyebrows -->
<line x1="74" y1="46" x2="79" y2="47" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.4"/>
<line x1="91" y1="44" x2="86" y2="46" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity="0.4"/>
<!-- Beak — open, yelling -->
<polygon points="73,56 65,55 73,60" fill="currentColor" opacity="0.35"/>
<line x1="67" y1="56" x2="66" y2="58" stroke="currentColor" stroke-width="1" opacity="0.3"/>
<!-- Cables tangling around body -->
<path d="M50 40 Q30 60 55 80 Q80 95 60 115 Q40 130 70 140" stroke="currentColor" stroke-width="2" fill="none" opacity="0.3"/>
<path d="M110 45 Q130 65 105 85 Q85 100 110 120 Q125 130 95 145" stroke="currentColor" stroke-width="2" fill="none" opacity="0.28"/>
<path d="M75 38 Q95 50 75 70" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.22"/>
<!-- Arms — trying to untangle -->
<line x1="60" y1="85" x2="40" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="85" x2="120" y2="68" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Legs -->
<line x1="70" y1="120" x2="60" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="90" y1="120" x2="100" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="57" cy="178" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="103" cy="178" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<!-- Router box at feet -->
<rect x="60" y="168" width="40" height="14" rx="3" fill="currentColor" opacity="0.25"/>
<circle cx="70" cy="175" r="1.5" fill="currentColor" opacity="0.35"/>
<circle cx="78" cy="175" r="1.5" fill="currentColor" opacity="0.3"/>
<circle cx="86" cy="175" r="1.5" fill="currentColor" opacity="0.35"/>
<!-- Antenna on router -->
<line x1="92" y1="168" x2="96" y2="155" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
</svg>
<!-- Shadok imprimeuse 3D — watching printer build object layer by layer -->
<svg class="shadok-imprimeuse3d" viewBox="0 0 170 210" fill="none" aria-hidden="true">
<!-- 3D printer frame -->
<rect x="10" y="90" width="65" height="60" rx="3" fill="currentColor" opacity="0.2"/>
<!-- Printer rails -->
<line x1="15" y1="95" x2="15" y2="145" stroke="currentColor" stroke-width="1.5" opacity="0.25"/>
<line x1="70" y1="95" x2="70" y2="145" stroke="currentColor" stroke-width="1.5" opacity="0.25"/>
<!-- Print bed -->
<rect x="18" y="135" width="49" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<!-- Object being printed (layered) -->
<rect x="32" y="128" width="20" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="34" y="122" width="16" height="6" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="36" y="117" width="12" height="5" rx="1" fill="currentColor" opacity="0.2"/>
<!-- Extruder head -->
<rect x="34" y="110" width="16" height="6" rx="2" fill="currentColor" opacity="0.35"/>
<line x1="42" y1="116" x2="42" y2="118" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>
<!-- Body — leaning forward, watching intently -->
<ellipse cx="120" cy="105" rx="20" ry="25" fill="currentColor" opacity="0.25" transform="rotate(-15 120 105)"/>
<!-- Head — looking down at printer -->
<circle cx="108" cy="70" r="15" fill="currentColor" opacity="0.3"/>
<!-- Eyes — both staring down at printer, fascinated -->
<circle cx="103" cy="72" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="112" cy="73" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Beak — pointing down -->
<polygon points="100,78 96,82 104,82" fill="currentColor" opacity="0.35"/>
<!-- Arms — one pointing at printer -->
<line x1="105" y1="95" x2="75" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="135" y1="98" x2="150" y2="115" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Legs — standing, leaning -->
<line x1="112" y1="128" x2="105" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="128" y1="128" x2="138" y2="192" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="103" cy="195" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="140" cy="195" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok cryptographe — 3/4 view, cloak, giant key + padlock -->
<svg class="shadok-cryptographe" viewBox="0 0 170 210" fill="none" aria-hidden="true">
<!-- Mysterious cloak -->
<path d="M55 70 Q50 110 45 160 L125 160 Q120 110 115 70" fill="currentColor" opacity="0.15"/>
<path d="M45 160 Q40 170 38 180 L132 180 Q130 170 125 160" fill="currentColor" opacity="0.12"/>
<!-- Body under cloak -->
<ellipse cx="85" cy="105" rx="22" ry="28" fill="currentColor" opacity="0.25"/>
<!-- Head — 3/4 view, slightly turned -->
<circle cx="85" cy="55" r="16" fill="currentColor" opacity="0.3"/>
<!-- Hood suggestion -->
<path d="M68 50 Q85 35 102 50" fill="currentColor" opacity="0.18"/>
<!-- Eyes — mysterious, one slightly larger -->
<circle cx="80" cy="53" r="2" fill="currentColor" opacity="0.5"/>
<circle cx="91" cy="52" r="1.5" fill="currentColor" opacity="0.4"/>
<!-- Beak — 3/4 profile -->
<polygon points="75,58 67,55 74,62" fill="currentColor" opacity="0.35"/>
<!-- Left arm holding giant key -->
<line x1="63" y1="95" x2="30" y2="110" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Giant key -->
<circle cx="18" cy="108" r="12" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.4"/>
<line x1="30" y1="108" x2="55" y2="108" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<line x1="48" y1="108" x2="48" y2="118" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="53" y1="108" x2="53" y2="115" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<!-- Right arm holding padlock -->
<line x1="107" y1="95" x2="140" y2="85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Padlock — big -->
<rect x="135" y="82" width="28" height="24" rx="4" fill="currentColor" opacity="0.3"/>
<path d="M141 82 L141 70 Q149 58 157 70 L157 82" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.35"/>
<circle cx="149" cy="95" r="3" fill="currentColor" opacity="0.2"/>
<line x1="149" y1="98" x2="149" y2="103" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<!-- Legs — long, cloak partly covering -->
<line x1="75" y1="130" x2="65" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="95" y1="130" x2="108" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="63" cy="198" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="110" cy="198" rx="8" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok droneuse — looking up at flying drone, remote in hands -->
<svg class="shadok-droneuse" viewBox="0 0 160 210" fill="none" aria-hidden="true">
<!-- Drone above -->
<rect x="60" y="12" width="30" height="8" rx="3" fill="currentColor" opacity="0.3"/>
<!-- Drone arms -->
<line x1="55" y1="16" x2="35" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="95" y1="16" x2="115" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<!-- Propellers spinning -->
<ellipse cx="35" cy="14" rx="12" ry="3" fill="currentColor" opacity="0.2"/>
<ellipse cx="115" cy="14" rx="12" ry="3" fill="currentColor" opacity="0.2"/>
<!-- Propeller blur lines -->
<line x1="25" y1="12" x2="45" y2="12" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<line x1="105" y1="12" x2="125" y2="12" stroke="currentColor" stroke-width="0.8" opacity="0.15"/>
<!-- Drone camera -->
<circle cx="75" cy="22" r="3" fill="currentColor" opacity="0.25"/>
<!-- Drone legs -->
<line x1="62" y1="20" x2="58" y2="28" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<line x1="88" y1="20" x2="92" y2="28" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<!-- Body — upright, looking up -->
<ellipse cx="78" cy="115" rx="20" ry="26" fill="currentColor" opacity="0.25"/>
<!-- Head — tilted back looking up -->
<circle cx="78" cy="72" r="15" fill="currentColor" opacity="0.3"/>
<!-- Eyes — looking up at drone -->
<circle cx="74" cy="68" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="83" cy="67" r="1.8" fill="currentColor" opacity="0.45"/>
<!-- Beak — pointing up -->
<polygon points="76,56 80,50 82,57" fill="currentColor" opacity="0.35"/>
<!-- Arms — holding remote control -->
<line x1="60" y1="108" x2="50" y2="128" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="96" y1="108" x2="106" y2="128" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Remote control in hands -->
<rect x="42" y="126" width="70" height="14" rx="4" fill="currentColor" opacity="0.3"/>
<circle cx="55" cy="133" r="3" fill="currentColor" opacity="0.2"/>
<circle cx="100" cy="133" r="3" fill="currentColor" opacity="0.2"/>
<!-- Joysticks -->
<line x1="55" y1="130" x2="55" y2="127" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="131" x2="102" y2="128" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.3"/>
<!-- Legs — standing -->
<line x1="68" y1="138" x2="58" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="88" y1="138" x2="100" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Feet -->
<ellipse cx="55" cy="198" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="103" cy="198" rx="9" ry="3" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok mécanicienne — lying under machine, legs sticking out, wrench, oil drops -->
<svg class="shadok-mecanicienne" viewBox="0 0 180 200" fill="none" aria-hidden="true">
<!-- Machine/server box above -->
<rect x="5" y="55" width="100" height="50" rx="5" fill="currentColor" opacity="0.2"/>
<rect x="12" y="62" width="35" height="12" rx="2" fill="currentColor" opacity="0.15"/>
<rect x="12" y="80" width="35" height="12" rx="2" fill="currentColor" opacity="0.15"/>
<!-- Machine details -->
<circle cx="20" cy="68" r="2" fill="currentColor" opacity="0.3"/>
<circle cx="20" cy="86" r="2" fill="currentColor" opacity="0.25"/>
<line x1="60" y1="65" x2="90" y2="65" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<line x1="60" y1="72" x2="85" y2="72" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
<!-- Body — lying flat under machine, only partial visible -->
<ellipse cx="80" cy="118" rx="25" ry="18" fill="currentColor" opacity="0.22" transform="rotate(90 80 118)"/>
<!-- Head under machine (barely visible) -->
<circle cx="55" cy="115" r="12" fill="currentColor" opacity="0.2"/>
<!-- Arm sticking out with wrench -->
<line x1="42" y1="115" x2="15" y2="105" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Wrench -->
<path d="M8 100 L15 105 L12 108 L5 103 Z" fill="currentColor" opacity="0.35"/>
<circle cx="5" cy="100" r="5" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.3"/>
<!-- Oil drops falling -->
<ellipse cx="70" cy="108" rx="2" ry="3" fill="currentColor" opacity="0.3"/>
<ellipse cx="85" cy="112" rx="1.5" ry="2.5" fill="currentColor" opacity="0.25"/>
<ellipse cx="62" cy="105" rx="1" ry="2" fill="currentColor" opacity="0.2"/>
<!-- Legs sticking out from under machine — the signature view -->
<line x1="90" y1="125" x2="130" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<line x1="95" y1="120" x2="145" y2="165" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4"/>
<!-- Big feet sticking up -->
<ellipse cx="133" cy="178" rx="10" ry="4" fill="currentColor" opacity="0.35" transform="rotate(-25 133 178)"/>
<ellipse cx="148" cy="168" rx="10" ry="4" fill="currentColor" opacity="0.35" transform="rotate(-25 148 168)"/>
<!-- Beak visible from side -->
<polygon points="42,118 35,116 42,122" fill="currentColor" opacity="0.3"/>
<!-- Eye visible -->
<circle cx="50" cy="113" r="1.5" fill="currentColor" opacity="0.4"/>
</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" 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-6">
<!-- LPK sert l'axe numérique -->
<NuxtLink v-if="lpk" :to="lpk.to" class="lpk-card">
<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"
>
<!-- 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">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('numerique')
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 {
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;
}
.shadok-codeuse {
position: absolute;
left: 1%;
top: 4%;
width: clamp(70px, 10vw, 140px);
opacity: 0.24;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-codeuse 9s ease-in-out infinite;
}
.shadok-electricien {
position: absolute;
right: 1%;
top: 3%;
width: clamp(70px, 10vw, 130px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-electricien 11s ease-in-out infinite;
}
.shadok-soudeuse {
position: absolute;
left: 3%;
top: 40%;
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-soudeuse 8s ease-in-out infinite;
}
.shadok-admin-reseau {
position: absolute;
right: 2%;
top: 35%;
width: clamp(70px, 10vw, 130px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-admin-reseau 10s ease-in-out infinite;
}
.shadok-imprimeuse3d {
position: absolute;
left: 2%;
bottom: 8%;
width: clamp(70px, 10vw, 135px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-imprimeuse3d 12s ease-in-out infinite;
}
.shadok-cryptographe {
position: absolute;
right: 1%;
bottom: 6%;
width: clamp(70px, 10vw, 130px);
opacity: 0.18;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-cryptographe 11s ease-in-out infinite;
}
.shadok-droneuse {
position: absolute;
left: 40%;
bottom: 2%;
width: clamp(70px, 10vw, 130px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-droneuse 7s ease-in-out infinite;
}
.shadok-mecanicienne {
position: absolute;
right: 2%;
top: 60%;
width: clamp(70px, 10vw, 140px);
opacity: 0.22;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-mecanicienne 9s ease-in-out infinite;
}
@keyframes shadok-float-codeuse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes shadok-float-electricien {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(1deg); }
}
@keyframes shadok-float-soudeuse {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-7px) rotate(-0.8deg); }
}
@keyframes shadok-float-admin-reseau {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
@keyframes shadok-float-imprimeuse3d {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-11px) rotate(0.5deg); }
}
@keyframes shadok-float-cryptographe {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(-0.6deg); }
}
@keyframes shadok-float-droneuse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes shadok-float-mecanicienne {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-7px) rotate(1.2deg); }
}
@media (max-width: 768px) {
.shadok-codeuse { display: none; }
.shadok-electricien { display: none; }
.shadok-soudeuse { display: none; }
.shadok-admin-reseau { display: none; }
.shadok-imprimeuse3d { display: none; }
.shadok-cryptographe { display: none; }
.shadok-droneuse { display: none; }
.shadok-mecanicienne { display: none; }
}
</style>
+9
View File
@@ -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' }
},
}
+145
View File
@@ -0,0 +1,145 @@
export type PaletteName = 'automne' | 'hiver' | 'printemps' | 'ete'
interface PaletteColors {
primary: string
accent: string
surface: string
bg: string
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
}
const palettes: Record<PaletteName, PaletteColors> = {
// ══════ DARK THEMES ══════
// Automne : cuivre chaud, feuilles mortes, terre brûlée
automne: {
primary: '18 80% 45%', // cuivre profond
accent: '32 85% 50%', // ambre doré
surface: '20 10% 12%', // écorce
bg: '20 10% 7%', // terre sombre
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',
},
// Hiver : bleu nuit, givre, argent lunaire
hiver: {
primary: '215 55% 52%', // bleu nuit étoilé
accent: '195 40% 65%', // givre argenté
surface: '222 14% 13%', // ciel de minuit
bg: '225 16% 8%', // nuit polaire
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',
},
// ══════ LIGHT THEMES ══════
// Printemps : vert soutenu, magenta chaud, lumière vivante
printemps: {
primary: '152 80% 24%', // vert émeraude sombre
accent: '338 88% 45%', // magenta profond
surface: '145 25% 85%', // prairie franche
bg: '140 28% 90%', // vert lumineux franc
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',
},
// Été : orange brûlant, corail profond, chaleur méditerranéenne
ete: {
primary: '18 90% 44%', // terre cuite brûlante
accent: '355 78% 50%', // corail ardent
surface: '32 40% 85%', // ocre clair
bg: '35 42% 90%', // chaleur dorée
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',
},
}
// 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)
// Init depuis localStorage (SSR-safe : no-op côté serveur).
const applyToDOM = () => initMood()
function setPalette(name: PaletteName) {
setMood(name)
}
return {
currentPalette,
colors,
palettes,
isLight,
setPalette,
applyToDOM,
}
})
+1 -8
View File
@@ -119,20 +119,13 @@ export const usePlayerStore = defineStore('player', () => {
function prevSong(): Song | null { function prevSong(): Song | null {
if (playlist.value.length === 0) return null if (playlist.value.length === 0) return null
// If more than 3 seconds in, restart current song
if (currentTime.value > 3) {
currentTime.value = 0
return currentSong.value
}
let prevIdx = currentIndex.value - 1 let prevIdx = currentIndex.value - 1
if (prevIdx < 0) { if (prevIdx < 0) {
if (repeatMode.value === 'all') { if (repeatMode.value === 'all') {
prevIdx = playlist.value.length - 1 prevIdx = playlist.value.length - 1
} }
else { else {
currentTime.value = 0 return null
return currentSong.value
} }
} }
+7
View File
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
primary: boolean primary: boolean
} }
export interface ChapterPageLink {
chapterSlug: string
page: number
}
export interface BookConfig { export interface BookConfig {
title: string title: string
author: string author: string
description: string description: string
coverImage?: string coverImage?: string
pdfFile?: string
chapters: ChapterMeta[] chapters: ChapterMeta[]
songs: import('./song').Song[] songs: import('./song').Song[]
chapterSongs: ChapterSongLink[] chapterSongs: ChapterSongLink[]
chapterPages: ChapterPageLink[]
defaultPlaylistOrder: string[] defaultPlaylistOrder: string[]
} }
+1
View File
@@ -9,6 +9,7 @@ export default defineContentConfig({
title: z.string(), title: z.string(),
description: z.string().optional(), description: z.string().optional(),
order: z.number(), order: z.number(),
page: z.number().optional(),
readingTime: z.string().optional(), readingTime: z.string().optional(),
}), }),
}), }),
+49
View File
@@ -0,0 +1,49 @@
---
title: "Introduction"
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"
---
## Introduction
[ *masc.* ]
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.
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 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.
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.
> *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.*
> *Il n'est ici question que d'une économie du don. Rien d'autre.*
---
## Monnaie-libristes
[ *fém.* ]
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.
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.
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 ».*

Some files were not shown because too many files have changed in this diff Show More