forked from LPK/librodrome
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f77da49b | ||
|
|
74ae8c2666 | ||
|
|
2144132c74 | ||
|
|
67cd869d83 | ||
|
|
201cfbbb50 | ||
|
|
4d8fe4895d | ||
|
|
e9fce4be8f | ||
|
|
c3ada04e52 | ||
|
|
38467bea10 | ||
|
|
da2a8aed49 | ||
|
|
e64d4cf003 | ||
|
|
483d226c8b | ||
|
|
069d206e94 | ||
|
|
5ec6474b37 | ||
|
|
98fab43fe5 | ||
|
|
a334c97434 | ||
|
|
84e843479d | ||
|
|
2f444ea7c7 | ||
|
|
8408fd6466 | ||
|
|
dcf64cc924 | ||
|
|
f6339400fa | ||
|
|
8fd000a153 | ||
|
|
95f82e4fee | ||
|
|
a038851895 | ||
|
|
efed0b9033 | ||
|
|
99a8b84164 | ||
|
|
b9e6b4a96c | ||
|
|
c52fa6007d | ||
|
|
d4ff840e13 | ||
|
|
7691cc4139 | ||
|
|
088333e4d4 | ||
|
|
07449de187 | ||
|
|
9d92c4a5b3 | ||
|
|
8f548afb17 | ||
|
|
9caf11c8ab | ||
|
|
c564e7be5f | ||
|
|
3a5c40a886 | ||
|
|
fbc2867163 | ||
|
|
082a17d09b | ||
|
|
97ba6dd04c | ||
|
|
f0338cca5e | ||
|
|
c6b9abf2f3 | ||
|
|
df0409fec3 | ||
|
|
1af00cc64c | ||
|
|
8a38c86794 | ||
|
|
17f39e735d | ||
|
|
7ea19e2247 | ||
|
|
9525ed3953 | ||
|
|
b02368a15b | ||
|
|
1f47533c77 | ||
|
|
07bf07a942 | ||
|
|
e6c91fea7d | ||
|
|
25bfc07b59 | ||
|
|
8803087e77 | ||
|
|
14d3a7b3e3 | ||
|
|
d8439cba0f | ||
|
|
0308785de9 | ||
|
|
52c0af4c83 | ||
|
|
8d9feed760 | ||
|
|
922afa2763 | ||
|
|
dd1d8baf4f | ||
|
|
2f438d9d7a | ||
|
|
4fce862df6 | ||
|
|
f5cf98ce15 | ||
|
|
acf66513af | ||
|
|
d412975a4c | ||
|
|
326ae0ca77 | ||
|
|
ac4aff4985 | ||
|
|
6f422a7369 | ||
|
|
b29fc77c60 | ||
|
|
ac2b8040b1 | ||
|
|
0e1e704319 | ||
|
|
3612cc17f8 | ||
|
|
837b5394fe | ||
|
|
554602ad52 | ||
|
|
2b5543791f | ||
|
|
524c7a0fc2 | ||
|
|
bf4b9e02f1 | ||
|
|
de0480de5b |
@@ -0,0 +1,2 @@
|
||||
.woodpecker.yml
|
||||
docker
|
||||
@@ -1,3 +1,9 @@
|
||||
# Admin authentication
|
||||
NUXT_ADMIN_PASSWORD=changeme
|
||||
NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars
|
||||
|
||||
# Umami analytics — hébergé sur stats.open.us.org
|
||||
# NUXT_UMAMI_API_KEY : Settings → API Keys dans l'interface Umami (pour /api/stats)
|
||||
NUXT_PUBLIC_UMAMI_URL=https://stats.open.us.org
|
||||
NUXT_PUBLIC_UMAMI_WEBSITE_ID=95ff616d-9ce1-47d9-bca2-f6ddc344a99a
|
||||
NUXT_UMAMI_API_KEY=
|
||||
|
||||
+10
-3
@@ -18,10 +18,17 @@ logs
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# PDF.js (copié depuis node_modules par postinstall)
|
||||
public/pdfjs/pdf.min.mjs
|
||||
public/pdfjs/pdf.worker.min.mjs
|
||||
|
||||
# Sources originales (PDF, JPG — pas servies par l'appli)
|
||||
sources/
|
||||
|
||||
# Runtime data (Docker volume — never committed)
|
||||
data/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# docs-syoul
|
||||
docs-syoul/
|
||||
+15
-52
@@ -1,56 +1,19 @@
|
||||
steps:
|
||||
- name: install-deps
|
||||
image: node:23-slim
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
- name: typecheck
|
||||
image: node:23-slim
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm run typecheck
|
||||
depends_on: [install-deps]
|
||||
|
||||
- name: build
|
||||
image: node:23-slim
|
||||
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:
|
||||
- corepack enable
|
||||
- pnpm run build
|
||||
depends_on: [typecheck]
|
||||
|
||||
- name: docker-build-push
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: git.open.us.org/syoul/librodrome
|
||||
dockerfile: docker/Dockerfile
|
||||
context: .
|
||||
target: production
|
||||
registry: git.open.us.org
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA}
|
||||
username:
|
||||
from_secret: REGISTRY_USER
|
||||
password:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
depends_on: [build]
|
||||
- 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
|
||||
|
||||
- name: deploy
|
||||
image: woodpeckerci/plugin-ssh
|
||||
settings:
|
||||
host:
|
||||
from_secret: DEPLOY_HOST
|
||||
user:
|
||||
from_secret: DEPLOY_USER
|
||||
key:
|
||||
from_secret: SSH_DEPLOY_KEY
|
||||
script:
|
||||
- cd /opt/librodrome
|
||||
- docker compose pull
|
||||
- docker compose up -d
|
||||
depends_on: [docker-build-push]
|
||||
when:
|
||||
branch: main
|
||||
- branch: main
|
||||
event: push
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -1,75 +1,72 @@
|
||||
# Nuxt Minimal Starter
|
||||
# Le Librodrome
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
|
||||
|
||||
## Setup
|
||||
## Navigation
|
||||
|
||||
Make sure to install dependencies:
|
||||
Le site est organisé en 3 grandes sections :
|
||||
|
||||
| Section | Route | Contenu |
|
||||
|---------|-------|---------|
|
||||
| **Numérique** | `/numerique` | Autonomie numérique : logiciel libre, authentification WoT, cloud libre |
|
||||
| **Économique** | `/economique` | Création monétaire, monnaie libre, modèle économique (livre + chapitres), productions collectives |
|
||||
| **Citoyenne** | `/citoyenne` | Décision collective (Glibredecision), tarifs de l'eau (SejeteralO) |
|
||||
|
||||
Autres pages : `/en-musique` (player audio), `/evenement`, `/a-propos`, `/messages`.
|
||||
|
||||
Chaque section a une page index et des sous-pages de détail (`/numerique/logiciel-libre`, `/economique/modele-eco`, etc.).
|
||||
|
||||
## Le livre
|
||||
|
||||
- **Lire** : lecteur PDF intégré, chapitres Markdown sous `/economique/modele-eco/[slug]`
|
||||
- **Écouter** : player audio avec 9 morceaux sous `/en-musique`
|
||||
- **Commander** : page `/economique/commande` (Bookelis + librairie)
|
||||
|
||||
## Stack
|
||||
|
||||
- **Nuxt 4** (Vue 3, TypeScript, Nitro)
|
||||
- **UnoCSS** (utility-first) + palettes saisonnières (été par défaut)
|
||||
- **Nuxt Content** pour les chapitres du livre
|
||||
- **Pinia** pour l'état (palette, player)
|
||||
- **pnpm** comme package manager
|
||||
|
||||
## Contenu administrable
|
||||
|
||||
Le contenu des pages est dans `site/pages/` en YAML, organisé par section :
|
||||
|
||||
```
|
||||
site/pages/
|
||||
home.yml # Page d'accueil
|
||||
numerique.yml # Index numérique
|
||||
numerique/*.yml # Sous-pages
|
||||
economique.yml # Index économique
|
||||
economique/*.yml # Sous-pages (modele-eco, monnaie-libre, commande...)
|
||||
citoyenne.yml # Index citoyenne
|
||||
citoyenne/*.yml # Sous-pages (decision, tarifs-eau)
|
||||
en-musique.yml, evenement.yml, gratewizard.yml
|
||||
```
|
||||
|
||||
Administration via `/admin/pages` (éditeur YAML, authentifié).
|
||||
|
||||
Le hero de la home (`home.yml`) supporte deux blocs dépliables :
|
||||
- `approach` + `axes` : approche par dimension (numérique → code source, etc.)
|
||||
- `audience` + `addressees` : à qui s'adresse le projet (collectifs, entreprises, collectivités)
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
pnpm dev # Dev server sur :3000
|
||||
pnpm build # Build production
|
||||
```
|
||||
|
||||
## Development Server
|
||||
Port réservé : **3000** (ne pas changer).
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
## Analytics
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
Umami hébergé sur `stats.open.us.org`. Module `nuxt-umami` configuré dans `nuxt.config.ts`.
|
||||
Stats publiques exposées via `/api/stats` (nécessite `NUXT_UMAMI_API_KEY`).
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
## Déploiement
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
Docker + Traefik, CI via Woodpecker. Domaine : `librodrome.org`.
|
||||
|
||||
@@ -0,0 +1,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
@@ -1,29 +1,29 @@
|
||||
export default defineAppConfig({
|
||||
site: {
|
||||
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',
|
||||
},
|
||||
header: {
|
||||
height: '4rem',
|
||||
nav: [
|
||||
{ label: 'Accueil', to: '/' },
|
||||
{ label: 'Lire', to: '/lire' },
|
||||
{ label: 'Écouter', to: '/ecouter' },
|
||||
{ label: 'À propos', to: '/a-propos' },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
credits: '© 2026 Le Librodrome — Production collective',
|
||||
credits: '© 2026 Le Librodrome — Productions collectives',
|
||||
links: [
|
||||
{ label: 'Mentions légales', to: '/mentions-legales' },
|
||||
],
|
||||
},
|
||||
gratewizard: {
|
||||
url: '/gratewizard-app/',
|
||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
|
||||
popup: {
|
||||
width: 420,
|
||||
height: 720,
|
||||
width: 480,
|
||||
height: 860,
|
||||
},
|
||||
},
|
||||
libredecision: {
|
||||
url: 'https://decision.librodrome.org',
|
||||
},
|
||||
sejeteral0: {
|
||||
url: 'https://collectivites.librodrome.org',
|
||||
},
|
||||
})
|
||||
|
||||
+55
-7
@@ -14,15 +14,63 @@
|
||||
</template>
|
||||
|
||||
<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({
|
||||
titleTemplate: (title) => {
|
||||
const siteName = site.value?.identity.name ?? 'Le Librodrome'
|
||||
return title ? `${title} — ${siteName}` : siteName
|
||||
},
|
||||
meta: [
|
||||
{ name: 'description', content: site.value?.identity.description ?? '' },
|
||||
titleTemplate: (title) => title ? `${title} — Le Librodrome` : 'Le Librodrome',
|
||||
})
|
||||
|
||||
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/',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -60,10 +60,10 @@
|
||||
|
||||
@keyframes glow-pulse {
|
||||
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% {
|
||||
box-shadow: 0 0 24px hsl(12 76% 48% / 0.6);
|
||||
box-shadow: 0 0 24px hsl(var(--color-primary) / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
/* This file provides fallback and utility classes */
|
||||
|
||||
.font-display {
|
||||
font-family: 'Syne', system-ui, sans-serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
+152
-10
@@ -3,26 +3,39 @@
|
||||
@import './typography.css';
|
||||
|
||||
:root {
|
||||
--color-primary: 12 76% 48%;
|
||||
--color-accent: 36 80% 52%;
|
||||
--color-bg: 20 8% 3.5%;
|
||||
--color-surface: 20 8% 8%;
|
||||
--color-surface-light: 20 8% 13%;
|
||||
--color-primary: 18 80% 45%;
|
||||
--color-accent: 32 85% 50%;
|
||||
--color-bg: 215 8% 22%;
|
||||
--color-surface: 213 7% 27%;
|
||||
--color-surface-light: 210 6% 32%;
|
||||
--color-text: 0 0% 100%;
|
||||
--color-text-muted: 0 0% 100% / 0.6;
|
||||
--color-text-muted: 0 0% 65%;
|
||||
|
||||
--header-height: 4rem;
|
||||
--player-height: 5rem;
|
||||
--player-height: 0rem;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
--font-display: 'Syne', sans-serif;
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-display: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
--font-sans: 'Public Sans', system-ui, sans-serif;
|
||||
--font-logo: 'Syne', sans-serif;
|
||||
|
||||
/* One hue per axis — overridden by each palette (stores/palette.ts) */
|
||||
--axis-num: 198 70% 30%;
|
||||
--axis-eco: 24 88% 42%;
|
||||
--axis-cit: 150 45% 28%;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
/* Fader level of the home console — registered so it can transition smoothly */
|
||||
@property --level {
|
||||
syntax: '<number>';
|
||||
inherits: true;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -43,9 +56,22 @@ body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--color-primary) / 0.3);
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -72,6 +98,122 @@ body {
|
||||
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-enter-active,
|
||||
.page-leave-active {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: hsl(0 0% 100% / 0.90);
|
||||
color: hsl(var(--color-text) / 0.90);
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
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 {
|
||||
@@ -26,11 +26,11 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
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 {
|
||||
@@ -38,7 +38,7 @@
|
||||
font-size: clamp(1.25rem, 3vw, 1.625rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: hsl(0 0% 100% / 0.92);
|
||||
color: hsl(var(--color-text) / 0.92);
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(36 80% 52%);
|
||||
background: hsl(var(--color-accent));
|
||||
margin-right: 0.625rem;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
@@ -61,7 +61,7 @@
|
||||
font-size: clamp(1.065rem, 2.5vw, 1.25rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: hsl(0 0% 100% / 0.85);
|
||||
color: hsl(var(--color-text) / 0.85);
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
@@ -69,7 +69,7 @@
|
||||
.prose h4::before {
|
||||
content: '//';
|
||||
font-family: var(--font-mono);
|
||||
color: hsl(36 80% 52%);
|
||||
color: hsl(var(--color-accent));
|
||||
margin-right: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
.prose h2 + p,
|
||||
.prose h3 + p {
|
||||
font-size: 1.175rem;
|
||||
color: hsl(0 0% 100% / 0.75);
|
||||
color: hsl(var(--color-text) / 0.75);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
@@ -88,25 +88,25 @@
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: hsl(12 76% 68%);
|
||||
color: hsl(var(--color-primary) / 0.85);
|
||||
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;
|
||||
transition: text-decoration-color 0.2s;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: hsl(12 76% 58%);
|
||||
text-decoration-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-left: 3px solid hsl(12 76% 58%);
|
||||
background: hsl(240 10% 8%);
|
||||
border-left: 3px solid hsl(var(--color-primary));
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
font-style: italic;
|
||||
color: hsl(0 0% 100% / 0.75);
|
||||
color: hsl(var(--color-text) / 0.75);
|
||||
}
|
||||
|
||||
.prose blockquote p:last-child {
|
||||
@@ -116,17 +116,17 @@
|
||||
.prose code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
background: hsl(240 10% 12%);
|
||||
background: hsl(var(--color-surface-light));
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(31 97% 66%);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: hsl(240 10% 6%);
|
||||
border: 1px solid hsl(0 0% 100% / 0.08);
|
||||
background: hsl(var(--color-bg));
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -134,7 +134,7 @@
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: hsl(0 0% 100% / 0.87);
|
||||
color: hsl(var(--color-text) / 0.87);
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
@@ -149,22 +149,22 @@
|
||||
}
|
||||
|
||||
.prose li::marker {
|
||||
color: hsl(12 76% 58%);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
margin: 3rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid hsl(0 0% 100% / 0.1);
|
||||
border-top: 1px solid hsl(var(--color-text) / 0.1);
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose em {
|
||||
color: hsl(0 0% 100% / 0.9);
|
||||
color: hsl(var(--color-text) / 0.9);
|
||||
}
|
||||
|
||||
.prose img {
|
||||
|
||||
@@ -58,7 +58,7 @@ function addItem() {
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@@ -66,9 +66,9 @@ function addItem() {
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.list-item > :deep(*:first-child) {
|
||||
@@ -101,9 +101,9 @@ function addItem() {
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(20 8% 22%);
|
||||
border: 1px dashed hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@@ -111,7 +111,7 @@ function addItem() {
|
||||
}
|
||||
|
||||
.list-add:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
color: hsl(12 76% 68%);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,21 +36,21 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,15 +37,15 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
@@ -55,6 +55,6 @@ const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-'
|
||||
|
||||
.field-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.admin-section {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
@@ -32,14 +32,14 @@ defineProps<{
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
cursor: pointer;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-section-header:hover {
|
||||
background: hsl(20 8% 8%);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.admin-section[open] .section-arrow {
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
<template>
|
||||
<div class="md-editor">
|
||||
<div class="md-tabs">
|
||||
<div class="md-toolbar">
|
||||
<div class="md-tabs">
|
||||
<button
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
>
|
||||
<div class="i-lucide-pencil h-3.5 w-3.5" />
|
||||
Édition
|
||||
</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
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'preview' }"
|
||||
@click="tab = 'preview'"
|
||||
>
|
||||
<div class="i-lucide-eye h-3.5 w-3.5" />
|
||||
Aperçu
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
class="md-fullscreen"
|
||||
:class="{ 'md-fullscreen--active': fullscreen }"
|
||||
@click="fullscreen = !fullscreen"
|
||||
>
|
||||
Édition
|
||||
</button>
|
||||
<button
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'preview' }"
|
||||
@click="tab = 'preview'"
|
||||
>
|
||||
Aperçu
|
||||
<div :class="fullscreen ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="tab === 'edit'"
|
||||
:value="modelValue"
|
||||
class="md-textarea"
|
||||
:rows="rows"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="md-preview prose"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
<div class="md-body" :class="{ 'md-body--split': tab === 'split', 'md-body--fullscreen': fullscreen }">
|
||||
<textarea
|
||||
v-if="tab === 'edit' || tab === 'split'"
|
||||
ref="textareaRef"
|
||||
:value="modelValue"
|
||||
class="md-textarea"
|
||||
:rows="rows"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
@keydown.tab.prevent="insertTab"
|
||||
/>
|
||||
<div
|
||||
v-if="tab === 'preview' || tab === 'split'"
|
||||
class="md-preview prose"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,64 +61,123 @@ const props = defineProps<{
|
||||
rows?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
'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(() => {
|
||||
// Simple markdown rendering for preview
|
||||
return props.modelValue
|
||||
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/^(?!<[hp])(.+)/gm, '<p>$1</p>')
|
||||
.replace(/^(?!<[hpulob])(.+)/gm, '<p>$1</p>')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-editor {
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
background: hsl(20 8% 6%);
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
}
|
||||
|
||||
.md-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.md-tab--active {
|
||||
color: white;
|
||||
background: hsl(20 8% 10%);
|
||||
color: hsl(var(--color-text));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.md-fullscreen {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: hsl(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 {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
min-height: 20rem;
|
||||
min-height: 24rem;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.md-textarea:focus {
|
||||
@@ -104,7 +186,9 @@ const renderedHtml = computed(() => {
|
||||
|
||||
.md-preview {
|
||||
padding: 1rem;
|
||||
min-height: 20rem;
|
||||
background: hsl(20 8% 4%);
|
||||
min-height: 24rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,18 +102,18 @@ function formatSize(bytes: number): string {
|
||||
.filter-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn--active {
|
||||
background: hsl(12 76% 48% / 0.15);
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
color: hsl(12 76% 68%);
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
@@ -123,7 +123,7 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
.media-card {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -131,18 +131,18 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
border-color: hsl(20 8% 22%);
|
||||
border-color: hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.media-card--selected {
|
||||
border-color: hsl(12 76% 48%);
|
||||
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3);
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.media-thumb img {
|
||||
@@ -156,8 +156,8 @@ function formatSize(bytes: number): string {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 6%);
|
||||
color: hsl(20 8% 40%);
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.media-info {
|
||||
@@ -169,7 +169,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
.media-name {
|
||||
font-size: 0.72rem;
|
||||
color: white;
|
||||
color: hsl(var(--color-text));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -177,7 +177,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
.media-size {
|
||||
font-size: 0.65rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
@@ -186,9 +186,9 @@ function formatSize(bytes: number): string {
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
|
||||
@@ -73,7 +73,7 @@ async function upload(files: FileList) {
|
||||
|
||||
<style scoped>
|
||||
.upload-zone {
|
||||
border: 2px dashed hsl(20 8% 22%);
|
||||
border: 2px dashed hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -82,12 +82,12 @@ async function upload(files: FileList) {
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.upload-zone--active {
|
||||
border-color: hsl(12 76% 48%);
|
||||
background: hsl(12 76% 48% / 0.05);
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
@@ -95,7 +95,7 @@ async function upload(files: FileList) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ defineEmits<{
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
@@ -40,7 +40,7 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
|
||||
@@ -23,21 +23,33 @@
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Pages</p>
|
||||
<NuxtLink to="/admin/pages" class="sidebar-link" exact-active-class="sidebar-link--active">
|
||||
<div class="i-lucide-file-text h-4 w-4" />
|
||||
Toutes les pages
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-home h-4 w-4" />
|
||||
Accueil
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-book-open h-4 w-4" />
|
||||
Lire
|
||||
<NuxtLink to="/admin/pages/numerique" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-monitor h-4 w-4" />
|
||||
Numérique
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/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" />
|
||||
Écouter
|
||||
En musique
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-sparkles h-4 w-4" />
|
||||
GrateWizard
|
||||
grateWizard
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Livre</p>
|
||||
@@ -79,8 +91,8 @@ async function logout() {
|
||||
|
||||
<style scoped>
|
||||
.admin-sidebar {
|
||||
background: hsl(20 8% 5%);
|
||||
border-right: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-bg));
|
||||
border-right: 1px solid hsl(var(--color-surface-light));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
@@ -90,7 +102,7 @@ async function logout() {
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
border-bottom: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@@ -104,7 +116,7 @@ async function logout() {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
padding: 1rem 0.75rem 0.375rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -116,7 +128,7 @@ async function logout() {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
@@ -126,18 +138,18 @@ async function logout() {
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.sidebar-link--active {
|
||||
background: hsl(12 76% 48% / 0.12);
|
||||
color: hsl(12 76% 68%);
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid hsl(20 8% 14%);
|
||||
border-top: 1px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:gap-4">
|
||||
<UiBaseButton @click="$emit('open-player')">
|
||||
<div class="i-lucide-play mr-2 h-5 w-5" />
|
||||
Présentation musicale
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
|
||||
<div class="i-lucide-book-open mr-2 h-5 w-5" />
|
||||
Lire le livre
|
||||
</UiBaseButton>
|
||||
<UiBaseButton v-if="showChapters" variant="ghost" to="/economique/modele-eco">
|
||||
<div class="i-lucide-list mr-2 h-5 w-5" />
|
||||
Présentation des chapitres
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" to="/economique/commande">
|
||||
<div class="i-lucide-shopping-bag mr-2 h-5 w-5" />
|
||||
Commandez le livre
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
showChapters?: boolean
|
||||
}>(), {
|
||||
showChapters: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="pdf-overlay">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="pdf-reader"
|
||||
@keydown.escape="close"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="pdf-reader"
|
||||
@keydown.escape="close"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Top bar -->
|
||||
<div class="pdf-bar">
|
||||
<div class="pdf-bar-title">
|
||||
@@ -22,38 +21,58 @@
|
||||
<!-- PDF embed -->
|
||||
<div class="pdf-viewport">
|
||||
<iframe
|
||||
:key="pdfUrl"
|
||||
:src="pdfUrl"
|
||||
class="pdf-frame"
|
||||
:title="bpContent?.pdf.iframeTitle"
|
||||
ref="iframeRef"
|
||||
@load="iframeRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const props = defineProps<{ modelValue: boolean; page?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
const { trackPdfOpen, trackPdfClose } = useTracking()
|
||||
|
||||
const overlayRef = ref<HTMLElement>()
|
||||
const iframeRef = ref<HTMLIFrameElement>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const pdfUrl = '/pdf/une-economie-du-don.pdf'
|
||||
const pdfUrl = computed(() => {
|
||||
const base = bookData.getPdfUrl()
|
||||
const viewerBase = `/pdfjs/viewer.html?file=${encodeURIComponent(base)}`
|
||||
if (props.page) return `${viewerBase}#page=${props.page}`
|
||||
return viewerBase
|
||||
})
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Tracking state
|
||||
let pdfOpenedAt = 0
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
trackPdfOpen(props.page ? `chapter-p${props.page}` : 'direct')
|
||||
pdfOpenedAt = Date.now()
|
||||
}
|
||||
else if (pdfOpenedAt > 0) {
|
||||
trackPdfClose(0, Date.now() - pdfOpenedAt)
|
||||
pdfOpenedAt = 0
|
||||
}
|
||||
if (import.meta.client) {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
@@ -91,7 +110,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
@@ -130,15 +149,4 @@ onUnmounted(() => {
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Overlay transitions */
|
||||
.pdf-overlay-enter-active {
|
||||
animation: pdf-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.pdf-overlay-leave-active {
|
||||
animation: pdf-enter 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
|
||||
}
|
||||
@keyframes pdf-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
+296
-372
@@ -23,45 +23,8 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Close -->
|
||||
<button class="bp-close" @click="close" aria-label="Fermer">
|
||||
<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">
|
||||
<!-- ═══ READER ═══ -->
|
||||
<div class="bp-phase bp-reader">
|
||||
<!-- Top bar -->
|
||||
<div class="reader-bar">
|
||||
<button
|
||||
@@ -72,12 +35,13 @@
|
||||
<div class="i-lucide-list h-5 w-5" />
|
||||
</button>
|
||||
<div class="reader-bar-title">
|
||||
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span>
|
||||
{{ chapters[chapterIdx].title }}
|
||||
<span class="reader-bar-num">{{ trackIdx + 1 }}.</span>
|
||||
{{ currentTrack?.title ?? '' }}
|
||||
</div>
|
||||
<span class="reader-bar-pages">
|
||||
{{ currentPage + 1 }}<span class="op-40">/</span>{{ totalPages }}
|
||||
</span>
|
||||
<span class="reader-bar-pages">{{ scrollPercent }}%</span>
|
||||
<button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
|
||||
<div class="i-lucide-x h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sommaire sidebar -->
|
||||
@@ -86,73 +50,85 @@
|
||||
<div class="sommaire-panel">
|
||||
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
|
||||
<button
|
||||
v-for="(ch, i) in chapters"
|
||||
:key="i"
|
||||
v-for="(track, i) in tracks"
|
||||
:key="track.id"
|
||||
class="sommaire-item"
|
||||
:class="{ 'sommaire-item--active': chapterIdx === i }"
|
||||
@click="goToChapter(i)"
|
||||
:class="{ 'sommaire-item--active': trackIdx === i }"
|
||||
@click="goToTrack(i)"
|
||||
>
|
||||
<span class="sommaire-num">{{ i + 1 }}</span>
|
||||
{{ ch.title }}
|
||||
{{ track.title }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
|
||||
<!-- Content viewport -->
|
||||
<div class="reader-viewport" ref="viewportEl">
|
||||
<div
|
||||
class="reader-viewport"
|
||||
:class="{ 'reader-viewport--scroll': isScrollMode }"
|
||||
ref="viewportEl"
|
||||
@scroll="onViewportScroll"
|
||||
>
|
||||
<div
|
||||
class="reader-columns prose"
|
||||
:class="{ 'reader-columns--scroll': isScrollMode }"
|
||||
ref="contentEl"
|
||||
: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 v-else class="lyrics-empty">
|
||||
<p class="op-40 italic">Aucun morceau sélectionné</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Page turn shadow overlay -->
|
||||
<div class="reader-shadow" :class="{ visible: isTurning }" />
|
||||
<!-- Page turn shadow overlay (paginated only) -->
|
||||
<div v-if="!isScrollMode" class="reader-shadow" :class="{ visible: isTurning }" />
|
||||
</div>
|
||||
|
||||
<!-- Bottom navigation -->
|
||||
<div class="reader-nav">
|
||||
<button
|
||||
class="reader-nav-btn"
|
||||
:class="{ 'reader-nav-btn--hidden': currentPage <= 0 && chapterIdx <= 0 }"
|
||||
@click="prevPage"
|
||||
aria-label="Page précédente"
|
||||
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx <= 0 : (currentPage <= 0 && trackIdx <= 0) }"
|
||||
@click="isScrollMode ? prevTrack() : prevPage()"
|
||||
:aria-label="isScrollMode ? 'Morceau précédent' : 'Page précédente'"
|
||||
>
|
||||
<div class="i-lucide-chevron-left h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Song disc (if chapter has a song) -->
|
||||
<div v-if="chapterSong" class="reader-song">
|
||||
<!-- Song disc -->
|
||||
<div v-if="currentSong" class="reader-song">
|
||||
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
|
||||
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
|
||||
<img src="/images/Couv-Economie-du-don.jpg" alt="" class="reader-disc-img" />
|
||||
<div class="reader-disc-hole" />
|
||||
</div>
|
||||
<span class="reader-song-name">{{ chapterSong.title }}</span>
|
||||
<span class="reader-song-name">{{ currentSong.title }}</span>
|
||||
</div>
|
||||
<div v-else class="reader-song" />
|
||||
|
||||
<button
|
||||
class="reader-nav-btn"
|
||||
:class="{ 'reader-nav-btn--hidden': currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1 }"
|
||||
@click="nextPage"
|
||||
aria-label="Page suivante"
|
||||
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx >= tracks.length - 1 : (currentPage >= totalPages - 1 && trackIdx >= tracks.length - 1) }"
|
||||
@click="isScrollMode ? nextTrack() : nextPage()"
|
||||
:aria-label="isScrollMode ? 'Morceau suivant' : 'Page suivante'"
|
||||
>
|
||||
<div class="i-lucide-chevron-right h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Hint -->
|
||||
<p class="bp-hint">
|
||||
<template v-if="phase === 'reading'">
|
||||
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktop }}</span>
|
||||
<span class="md:hidden">{{ bpContent?.reader.hints.mobile }}</span>
|
||||
<template v-if="isScrollMode">
|
||||
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktopScroll ?? '← → morceaux · Défilement libre · Esc fermer' }}</span>
|
||||
<span class="md:hidden">{{ bpContent?.reader.hints.mobileScroll ?? 'Défilez pour lire' }}</span>
|
||||
</template>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
@@ -162,6 +138,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
const { trackPlayerOpen, trackPlayerChapter, trackPlayerMode } = useTracking()
|
||||
|
||||
const COL_GAP = 80
|
||||
|
||||
@@ -177,90 +154,98 @@ const overlayRef = ref<HTMLElement>()
|
||||
const viewportEl = ref<HTMLElement>()
|
||||
const contentEl = ref<HTMLElement>()
|
||||
|
||||
// ── Phase state ──
|
||||
const phase = ref<'intro' | 'cover' | 'reading'>('intro')
|
||||
const chapterIdx = ref(0)
|
||||
const trackIdx = ref(0)
|
||||
const currentPage = ref(0)
|
||||
const totalPages = ref(1)
|
||||
const colWidth = ref(500)
|
||||
const showSommaire = 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 playerStore = usePlayerStore()
|
||||
|
||||
// ── Content from Nuxt Content ──
|
||||
const chaptersContent = ref<any[]>([])
|
||||
const contentLoaded = ref(false)
|
||||
|
||||
async function loadContent() {
|
||||
if (contentLoaded.value) return
|
||||
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
|
||||
// ── Tracks: built from playlist order (songs), not chapters ──
|
||||
const tracks = computed(() => {
|
||||
return playerStore.playlist.map(song => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
song,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Chapter metadata ──
|
||||
const chapters = [
|
||||
{ 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' },
|
||||
]
|
||||
const currentTrack = computed(() => tracks.value[trackIdx.value] ?? null)
|
||||
const currentSong = computed(() => currentTrack.value?.song ?? null)
|
||||
|
||||
// ── Per-chapter color hues ──
|
||||
const chapterHues: [number, number][] = [
|
||||
[12, 36], // cover (intro + cover phases)
|
||||
[15, 35], // 1
|
||||
[350, 15], // 2
|
||||
[36, 50], // 3
|
||||
[170, 200], // 4
|
||||
[220, 250], // 5
|
||||
[270, 300], // 6
|
||||
[320, 345], // 7
|
||||
[150, 170], // 8
|
||||
[190, 220], // 9
|
||||
[40, 20], // 10
|
||||
[210, 240], // 11
|
||||
const currentLyrics = computed(() => {
|
||||
return currentSong.value?.lyrics?.trim() || ''
|
||||
})
|
||||
|
||||
const currentLyricsHtml = computed(() => {
|
||||
if (!currentLyrics.value) return ''
|
||||
return currentLyrics.value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\[([^\]]+)\]/g, '<span class="lyrics-tag">[$1]</span>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.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 idx = phase.value === 'reading' ? chapterIdx.value + 1 : 0
|
||||
const [h1, h2] = chapterHues[idx] ?? chapterHues[0]
|
||||
const [h1, h2] = trackHues[trackIdx.value] ?? trackHues[0]
|
||||
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 ──
|
||||
const contentStyle = computed(() => {
|
||||
if (isScrollMode.value) return {}
|
||||
return {
|
||||
columnWidth: colWidth.value + 'px',
|
||||
columnGap: COL_GAP + 'px',
|
||||
transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`,
|
||||
}
|
||||
})
|
||||
|
||||
// ── CSS columns pagination ──
|
||||
const contentStyle = computed(() => ({
|
||||
columnWidth: colWidth.value + 'px',
|
||||
columnGap: COL_GAP + 'px',
|
||||
transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`,
|
||||
}))
|
||||
|
||||
function recalcPages() {
|
||||
if (isScrollMode.value) return
|
||||
if (!contentEl.value || !viewportEl.value) return
|
||||
colWidth.value = viewportEl.value.offsetWidth
|
||||
const sw = contentEl.value.scrollWidth
|
||||
@@ -269,25 +254,17 @@ function recalcPages() {
|
||||
|
||||
let resizeObs: ResizeObserver | null = null
|
||||
|
||||
// Recalc when chapter content changes
|
||||
watch(activeChapter, async () => {
|
||||
// Recalc when track changes
|
||||
watch(trackIdx, async () => {
|
||||
currentPage.value = 0
|
||||
// Wait for ContentRenderer to update DOM
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
setTimeout(recalcPages, 100)
|
||||
})
|
||||
|
||||
// ── Phase transitions ──
|
||||
function onSpinEnd() {
|
||||
phase.value = 'cover'
|
||||
}
|
||||
|
||||
async function startReading() {
|
||||
await loadContent()
|
||||
chapterIdx.value = 0
|
||||
async function initReading() {
|
||||
trackIdx.value = 0
|
||||
currentPage.value = 0
|
||||
phase.value = 'reading'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
// Set up ResizeObserver
|
||||
@@ -298,14 +275,37 @@ async function startReading() {
|
||||
setTimeout(recalcPages, 200)
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function goToChapter(idx: number) {
|
||||
chapterIdx.value = idx
|
||||
// ── Navigation by tracks (songs) ──
|
||||
let _skipSongWatch = false
|
||||
|
||||
function goToTrack(idx: number) {
|
||||
if (idx < 0 || idx >= tracks.value.length) return
|
||||
trackIdx.value = idx
|
||||
currentPage.value = 0
|
||||
showSommaire.value = false
|
||||
// Play chapter song
|
||||
const song = getPrimarySong(chapters[idx].slug)
|
||||
if (song) audioPlayer.loadAndPlay(song)
|
||||
// Scroll to top in scroll mode
|
||||
if (isScrollMode.value && viewportEl.value) {
|
||||
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() {
|
||||
@@ -313,9 +313,8 @@ function nextPage() {
|
||||
triggerTurn()
|
||||
currentPage.value++
|
||||
}
|
||||
else if (chapterIdx.value < chapters.length - 1) {
|
||||
// Next chapter
|
||||
goToChapter(chapterIdx.value + 1)
|
||||
else if (trackIdx.value < tracks.value.length - 1) {
|
||||
goToTrack(trackIdx.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,22 +323,8 @@ function prevPage() {
|
||||
triggerTurn()
|
||||
currentPage.value--
|
||||
}
|
||||
else if (chapterIdx.value > 0) {
|
||||
// Previous chapter, go to last page
|
||||
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 })
|
||||
else if (trackIdx.value > 0) {
|
||||
goToTrack(trackIdx.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,15 +338,26 @@ function close() {
|
||||
}
|
||||
|
||||
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() }
|
||||
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 === 'ArrowUp') { e.preventDefault(); if (chapterIdx.value > 0) goToChapter(chapterIdx.value - 1) }
|
||||
else if (e.key === 'Escape') close()
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
close()
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); nextTrack() }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); prevTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +369,7 @@ function onTouchStart(e: TouchEvent) {
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (isScrollMode.value) return
|
||||
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0) nextPage()
|
||||
@@ -381,13 +378,19 @@ function onTouchEnd(e: TouchEvent) {
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
// While the reader is open, the persistent mini player (above it) lifts up
|
||||
// so it does not cover the reader navigation.
|
||||
if (import.meta.client) {
|
||||
watch(isOpen, (open) => {
|
||||
document.documentElement.classList.toggle('book-player-open', open)
|
||||
}, { immediate: true })
|
||||
onUnmounted(() => document.documentElement.classList.remove('book-player-open'))
|
||||
}
|
||||
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
phase.value = 'intro'
|
||||
chapterIdx.value = 0
|
||||
currentPage.value = 0
|
||||
showSommaire.value = false
|
||||
contentLoaded.value = false
|
||||
trackPlayerOpen()
|
||||
await initBookData()
|
||||
await nextTick()
|
||||
overlayRef.value?.focus()
|
||||
@@ -396,8 +399,11 @@ watch(isOpen, async (open) => {
|
||||
// Load playlist & play first song
|
||||
const playlist = getPlaylistOrder()
|
||||
if (playlist.length) playerStore.setPlaylist(playlist)
|
||||
const first = getSongs().find(s => s.id === 'chanson-01')
|
||||
if (first) audioPlayer.loadAndPlay(first)
|
||||
if (playlist.length) {
|
||||
_skipSongWatch = true
|
||||
audioPlayer.loadAndPlay(playlist[0])
|
||||
}
|
||||
await initReading()
|
||||
}
|
||||
else {
|
||||
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) => {
|
||||
if (import.meta.client) document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
@@ -436,13 +460,13 @@ onUnmounted(() => {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: hsl(20 8% 3%);
|
||||
background: hsl(var(--color-bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding-bottom: 4.5rem;
|
||||
padding-bottom: 1rem;
|
||||
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); }
|
||||
}
|
||||
|
||||
/* ─── 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 ─── */
|
||||
.bp-hint {
|
||||
position: absolute;
|
||||
bottom: 5rem;
|
||||
bottom: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
font-size: 0.68rem;
|
||||
color: hsl(20 8% 28%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -565,135 +566,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
@@ -704,14 +577,15 @@ onUnmounted(() => {
|
||||
max-width: 52rem;
|
||||
padding: 0 1rem;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Top bar ─── */
|
||||
.reader-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
.reader-bar-btn {
|
||||
@@ -720,22 +594,28 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
width: 2.25rem; height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 8% / 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
color: hsl(20 8% 55%);
|
||||
border: 1px solid hsl(20 8% 16% / 0.5);
|
||||
background: transparent;
|
||||
color: hsl(var(--color-text-muted));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reader-bar-btn:hover {
|
||||
color: white;
|
||||
background: hsl(var(--scene-h1) 50% 40% / 0.2);
|
||||
border-color: hsl(var(--scene-h1) 50% 50% / 0.3);
|
||||
background: hsl(0 0% 100% / 0.06);
|
||||
}
|
||||
.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 {
|
||||
flex: 1;
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(0.85rem, 2.5vw, 1.1rem);
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
@@ -750,7 +630,7 @@ onUnmounted(() => {
|
||||
.reader-bar-pages {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -759,16 +639,16 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 72;
|
||||
background: hsl(20 8% 3% / 0.6);
|
||||
background: hsl(var(--color-bg) / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
}
|
||||
.sommaire-panel {
|
||||
width: min(300px, 80vw);
|
||||
height: 100%;
|
||||
background: hsl(20 8% 6% / 0.95);
|
||||
background: hsl(var(--color-surface) / 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
border-right: 1px solid hsl(20 8% 14%);
|
||||
border-right: 1px solid hsl(var(--color-surface-light));
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -776,10 +656,10 @@ onUnmounted(() => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sommaire-title {
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: hsl(20 8% 45%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -791,7 +671,7 @@ onUnmounted(() => {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(20 8% 55%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -800,7 +680,7 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
.sommaire-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
background: hsl(var(--color-surface));
|
||||
color: white;
|
||||
}
|
||||
.sommaire-item--active {
|
||||
@@ -816,8 +696,8 @@ onUnmounted(() => {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: hsl(20 8% 40%);
|
||||
background: hsl(20 8% 12%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: hsl(var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sommaire-item--active .sommaire-num {
|
||||
@@ -844,12 +724,11 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: hidden auto;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(20 8% 5% / 0.4);
|
||||
background: hsl(var(--color-bg) / 0.4);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid hsl(20 8% 14% / 0.4);
|
||||
}
|
||||
|
||||
.reader-columns {
|
||||
@@ -860,6 +739,23 @@ onUnmounted(() => {
|
||||
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 */
|
||||
.reader-columns :deep(h1) {
|
||||
font-size: clamp(1.5rem, 3.5vw, 2rem);
|
||||
@@ -880,12 +776,42 @@ onUnmounted(() => {
|
||||
.reader-columns :deep(h3) {
|
||||
break-after: avoid;
|
||||
}
|
||||
.reader-columns :deep(p),
|
||||
.reader-columns :deep(blockquote),
|
||||
.reader-columns :deep(ul),
|
||||
.reader-columns :deep(ol) {
|
||||
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 */
|
||||
.reader-shadow {
|
||||
@@ -896,7 +822,7 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: linear-gradient(to left, hsl(20 8% 3% / 0.4), transparent);
|
||||
background: linear-gradient(to left, hsl(var(--color-bg) / 0.4), transparent);
|
||||
transition: opacity 0.15s;
|
||||
border-radius: 0 0.75rem 0.75rem 0;
|
||||
}
|
||||
@@ -910,7 +836,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
padding: 0.25rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
.reader-nav-btn {
|
||||
@@ -919,18 +845,16 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
width: 2.5rem; height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(20 8% 8% / 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
color: hsl(20 8% 55%);
|
||||
border: 1px solid hsl(20 8% 16% / 0.5);
|
||||
background: transparent;
|
||||
color: hsl(var(--color-text-muted));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reader-nav-btn:hover {
|
||||
background: hsl(var(--scene-h1) 60% 42% / 0.2);
|
||||
background: hsl(0 0% 100% / 0.06);
|
||||
color: white;
|
||||
border-color: hsl(var(--scene-h1) 60% 50% / 0.35);
|
||||
}
|
||||
.reader-nav-btn--hidden {
|
||||
opacity: 0;
|
||||
@@ -950,7 +874,7 @@ onUnmounted(() => {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid hsl(20 8% 22%);
|
||||
border: 2px solid hsl(var(--color-surface-light));
|
||||
box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
|
||||
}
|
||||
.reader-disc.spinning {
|
||||
@@ -967,12 +891,12 @@ onUnmounted(() => {
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.5rem; height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(20 8% 5%);
|
||||
border: 1.5px solid hsl(20 8% 18%);
|
||||
background: hsl(var(--color-bg));
|
||||
border: 1.5px solid hsl(var(--color-surface-light));
|
||||
}
|
||||
.reader-song-name {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<article class="prose">
|
||||
<article class="prose prose-lyrics">
|
||||
<ContentRenderer :value="content" />
|
||||
</article>
|
||||
</template>
|
||||
@@ -9,3 +9,9 @@ defineProps<{
|
||||
content: any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prose-lyrics :deep(p) {
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,18 +15,28 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Songs + PDF actions row -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<!-- Associated songs badges -->
|
||||
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
class="song-badge-btn"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="i-lucide-music h-3 w-3" />
|
||||
{{ song.title }}
|
||||
</button>
|
||||
|
||||
<!-- PDF button -->
|
||||
<button v-if="pdfPage" class="pdf-btn" @click="showPdf = true">
|
||||
<div class="i-lucide-book-open h-3.5 w-3.5" />
|
||||
<span>Lire dans le PDF</span>
|
||||
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +54,12 @@ const props = defineProps<{
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
|
||||
|
||||
const showPdf = ref(false)
|
||||
|
||||
function playSong(song: Song) {
|
||||
loadAndPlay(song)
|
||||
@@ -57,6 +70,59 @@ function playSong(song: Song) {
|
||||
.chapter-title {
|
||||
font-size: clamp(2rem, 5vw, 2.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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="chapter in chapters" :key="chapter.path">
|
||||
<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"
|
||||
active-class="bg-primary/10 text-primary font-medium"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="chapter-toc" ref="tocRoot">
|
||||
<button class="toc-toggle" @click="open = !open" :aria-expanded="open">
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
<span>Sommaire</span>
|
||||
<div class="i-lucide-chevron-down h-3.5 w-3.5 toc-chevron" :class="{ 'toc-chevron--open': open }" />
|
||||
</button>
|
||||
|
||||
<Transition name="toc-dropdown">
|
||||
<div v-if="open" class="toc-panel">
|
||||
<nav>
|
||||
<a
|
||||
v-for="item in headings"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="toc-item"
|
||||
:class="{ 'toc-item--h2': item.level === 2 }"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TocHeading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const headings = ref<TocHeading[]>([])
|
||||
const tocRoot = ref<HTMLElement>()
|
||||
|
||||
function extractHeadings() {
|
||||
const article = document.querySelector('.prose-lyrics')
|
||||
if (!article) return
|
||||
|
||||
const nodes = article.querySelectorAll('h1, h2')
|
||||
headings.value = Array.from(nodes)
|
||||
.filter(el => el.id)
|
||||
.map(el => ({
|
||||
id: el.id,
|
||||
text: el.textContent?.trim() ?? '',
|
||||
level: parseInt(el.tagName[1]),
|
||||
}))
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (tocRoot.value && !tocRoot.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Wait for content to render
|
||||
nextTick(() => {
|
||||
extractHeadings()
|
||||
// Retry after a short delay in case content loads async
|
||||
setTimeout(extractHeadings, 500)
|
||||
})
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-toc {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toc-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(20 8% 10%);
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
color: hsl(20 8% 60%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toc-toggle:hover {
|
||||
border-color: hsl(20 8% 30%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toc-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 8%);
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 65%);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-item--h2 {
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.toc-dropdown-enter-active {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toc-dropdown-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
.toc-dropdown-enter-from,
|
||||
.toc-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,56 @@
|
||||
<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="grid items-center gap-12 md:grid-cols-2">
|
||||
<!-- Book cover -->
|
||||
@@ -63,10 +114,10 @@ const { data: content } = await usePageContent('home')
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
box-shadow:
|
||||
0 12px 40px hsl(0 0% 0% / 0.5),
|
||||
0 0 0 1px hsl(20 8% 15%);
|
||||
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: 360px;
|
||||
@@ -75,18 +126,54 @@ const { data: content } = await usePageContent('home')
|
||||
.book-cover-3d:hover {
|
||||
transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
|
||||
box-shadow:
|
||||
12px 16px 48px hsl(0 0% 0% / 0.6),
|
||||
0 0 0 1px hsl(12 76% 48% / 0.2);
|
||||
12px 16px 48px hsl(var(--color-text) / 0.2),
|
||||
0 0 0 1px hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.book-cover-img {
|
||||
width: 200%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
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>
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<section :class="compact ? 'section-book-compact' : 'section-padding'">
|
||||
<div class="container-content">
|
||||
<div class="grid items-center gap-12 md:grid-cols-2">
|
||||
<div :class="['grid items-center', compact ? 'gap-5 md:grid-cols-[auto_1fr]' : 'gap-12 md:grid-cols-2']">
|
||||
<!-- Book cover -->
|
||||
<UiScrollReveal>
|
||||
<div class="book-cover-wrapper">
|
||||
<div class="book-cover-3d">
|
||||
<img
|
||||
:src="content?.book.coverImage"
|
||||
:alt="content?.book.coverAlt"
|
||||
class="book-cover-img"
|
||||
/>
|
||||
</div>
|
||||
<div class="book-cover-wrapper relative">
|
||||
<div
|
||||
:class="['book-cover-3d', compact && 'book-cover-3d--compact']"
|
||||
:style="{ backgroundImage: `url(${content?.book.coverImage})` }"
|
||||
role="img"
|
||||
:aria-label="content?.book.coverAlt"
|
||||
/>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Content + CTAs -->
|
||||
<div>
|
||||
<UiScrollReveal>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.book.title }}
|
||||
<p :class="['font-mono tracking-widest text-accent uppercase', compact ? 'text-xs mb-1' : 'text-sm mb-2']">
|
||||
{{ content?.book.kicker }}
|
||||
</p>
|
||||
<h2 :class="['book-heading font-display font-bold tracking-tight text-white', compact && 'book-heading--compact']">
|
||||
<template v-if="compact">
|
||||
<span class="block">{{ titleLine1 }}</span>
|
||||
<span class="block" style="color: hsl(var(--color-text) / 0.55)">{{ titleLine2 }}</span>
|
||||
</template>
|
||||
<template v-else>{{ content?.book.title }}</template>
|
||||
</h2>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<p class="mt-4 text-lg leading-relaxed text-white/60">
|
||||
<p :class="['leading-relaxed', compact ? 'mt-2 text-sm' : 'mt-4 text-lg text-white/60']"
|
||||
:style="compact ? 'color: hsl(var(--color-text-muted)); font-size: 0.85rem' : ''">
|
||||
{{ content?.book.description }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<UiBaseButton @click="$emit('open-player')">
|
||||
<div class="i-lucide-play mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.player }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
|
||||
<div class="i-lucide-book-open mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.pdf }}
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
<BookActions
|
||||
:show-chapters="showChapters"
|
||||
@open-player="$emit('open-player')"
|
||||
@open-pdf="$emit('open-pdf')"
|
||||
/>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,15 +50,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
showChapters?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
showChapters: true,
|
||||
compact: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const { data: content } = await usePageContent('home')
|
||||
|
||||
const titleParts = computed(() => {
|
||||
const title = content.value?.book.title ?? ''
|
||||
const idx = title.indexOf('—')
|
||||
if (idx === -1) return [title, '']
|
||||
return [title.slice(0, idx).trim(), '— ' + title.slice(idx + 1).trim()]
|
||||
})
|
||||
const titleLine1 = computed(() => titleParts.value[0])
|
||||
const titleLine2 = computed(() => titleParts.value[1])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-book-compact {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.book-cover-wrapper {
|
||||
perspective: 800px;
|
||||
display: flex;
|
||||
@@ -65,33 +87,40 @@ const { data: content } = await usePageContent('home')
|
||||
}
|
||||
|
||||
.book-cover-3d {
|
||||
aspect-ratio: 3 / 4;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 1756 / 2480;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
box-shadow:
|
||||
0 12px 40px hsl(0 0% 0% / 0.5),
|
||||
0 0 0 1px hsl(20 8% 15%);
|
||||
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: 360px;
|
||||
}
|
||||
|
||||
.book-cover-3d--compact {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.book-cover-3d:hover {
|
||||
transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
|
||||
box-shadow:
|
||||
12px 16px 48px hsl(0 0% 0% / 0.6),
|
||||
0 0 0 1px hsl(12 76% 48% / 0.2);
|
||||
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%);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
.book-heading {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
<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="mx-auto max-w-3xl text-center">
|
||||
<UiScrollReveal>
|
||||
@@ -41,4 +73,24 @@ const { data: content } = await usePageContent('home')
|
||||
.heading-section {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<blockquote v-if="lines?.length" class="cites">
|
||||
<p v-for="(line, i) in lines" :key="i" class="cite" :class="{ 'cite--lead': i === 0 }">{{ line }}</p>
|
||||
</blockquote>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
lines: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cites {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.cite {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: clamp(1.15rem, 2.4vw, 1.55rem);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: hsl(var(--color-text) / 0.62);
|
||||
}
|
||||
|
||||
.cite + .cite {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.cite--lead {
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.6rem, 3.6vw, 2.3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(var(--color-text));
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,62 +1,78 @@
|
||||
<template>
|
||||
<div class="mt-16">
|
||||
<section class="messages-section">
|
||||
<div class="container-content messages-grid">
|
||||
|
||||
<!-- Formulaire -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<UiScrollReveal>
|
||||
<div class="message-form-card">
|
||||
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3>
|
||||
<h3 class="font-display text-lg font-bold form-title mb-4">Laisser un message</h3>
|
||||
|
||||
<form v-if="!submitted" class="space-y-3" @submit.prevent="send">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
v-model="form.author"
|
||||
type="text"
|
||||
placeholder="Votre nom *"
|
||||
required
|
||||
class="msg-input"
|
||||
/>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="Email (optionnel)"
|
||||
class="msg-input"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.author"
|
||||
type="text"
|
||||
placeholder="Votre nom"
|
||||
class="msg-input"
|
||||
/>
|
||||
<p class="hint-text text-xs -mt-1 px-1">Pour recevoir une réponse, laissez votre e-mail dans le message.</p>
|
||||
<select v-model="form.type" class="msg-input msg-input--select">
|
||||
<option value="question">Question</option>
|
||||
<option value="suggestion">Suggestion</option>
|
||||
<option value="retour">Retour d'expérience</option>
|
||||
</select>
|
||||
<textarea
|
||||
v-model="form.text"
|
||||
placeholder="Votre message *"
|
||||
required
|
||||
placeholder="Votre message"
|
||||
rows="3"
|
||||
class="msg-input resize-none"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary text-sm" :disabled="sending">
|
||||
<button type="submit" class="btn-primary text-sm" :disabled="sending || !canSend">
|
||||
<div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-else class="text-center py-4">
|
||||
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" />
|
||||
<p class="text-white/80">Merci pour votre message !</p>
|
||||
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</p>
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="i-lucide-heart-handshake h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<p class="msg-confirm-title font-display text-lg font-semibold">Merci pour votre message !</p>
|
||||
<p class="msg-confirm-sub text-sm mt-2 max-w-md mx-auto leading-relaxed">
|
||||
Il sera lu et traité dans un délai... humainement raisonnable.
|
||||
</p>
|
||||
<p class="msg-confirm-note text-xs mt-4 italic">
|
||||
Chaque message est un premier pas dans l'aventure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- 2 derniers messages publiés -->
|
||||
<UiScrollReveal v-if="messages?.length" :delay="600">
|
||||
<div class="mt-8 space-y-4">
|
||||
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3>
|
||||
<UiScrollReveal v-if="messages?.length" :delay="100">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-display text-lg font-bold section-title">Derniers messages</h3>
|
||||
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
<!-- En-tête auteur -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
|
||||
<span class="type-pill">{{ typeLabel(msg.type) }}</span>
|
||||
<span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
<!-- Texte du message -->
|
||||
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<!-- Réponse -->
|
||||
<div v-if="msg.reply?.text" class="reply-thread">
|
||||
<div class="reply-connector" aria-hidden="true" />
|
||||
<div class="reply-block">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
|
||||
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
|
||||
</div>
|
||||
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div>
|
||||
<NuxtLink to="/messages" class="btn-ghost text-sm">
|
||||
Voir tous les messages
|
||||
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
|
||||
@@ -64,17 +80,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
const form = reactive({ author: '', email: '', text: '' })
|
||||
const form = reactive({ author: '', text: '', type: 'question' })
|
||||
const sending = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const canSend = computed(() => form.text.trim().length > 0)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
await $fetch('/api/messages', { method: 'POST', body: form })
|
||||
@@ -105,37 +136,115 @@ function formatDate(iso: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Couleurs adaptatives (dark + light) ── */
|
||||
.form-title { color: hsl(var(--color-text)); }
|
||||
.hint-text { color: hsl(var(--color-text) / 0.35); }
|
||||
.section-title { color: hsl(var(--color-text) / 0.85); }
|
||||
.msg-author { color: hsl(var(--color-text) / 0.75); }
|
||||
.msg-date { color: hsl(var(--color-text) / 0.38); }
|
||||
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
|
||||
.msg-confirm-title { color: hsl(var(--color-text) / 0.95); }
|
||||
.msg-confirm-sub { color: hsl(var(--color-text) / 0.6); }
|
||||
.msg-confirm-note { color: hsl(var(--color-primary) / 0.65); }
|
||||
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
|
||||
.reply-author { color: hsl(var(--color-primary) / 0.8); }
|
||||
.reply-text { color: hsl(var(--color-text) / 0.62); }
|
||||
|
||||
/* ── Mise en page ── */
|
||||
.messages-section {
|
||||
padding: clamp(3rem, 7vw, 5.5rem) 1rem 2rem;
|
||||
}
|
||||
|
||||
.messages-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.messages-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
|
||||
}
|
||||
|
||||
/* ── Carte formulaire ── */
|
||||
.message-form-card {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.msg-input--select {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msg-input::placeholder {
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text) / 0.35);
|
||||
}
|
||||
|
||||
.msg-input:focus {
|
||||
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 {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
/* ── Réponse avec liaison graphique ── */
|
||||
.reply-thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.reply-connector {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: -0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--color-primary) / 0.35),
|
||||
hsl(var(--color-primary) / 0.15)
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.25);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.6rem 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="tool" :class="{ 'tool--gestation': tool.gestation }">
|
||||
<div class="tool-main">
|
||||
<span class="tool-icon" aria-hidden="true">
|
||||
<span v-if="tool.icon === 'g1'" class="tool-g1">Ğ1</span>
|
||||
<span v-else :class="`i-lucide-${tool.icon}`" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="tool-text">
|
||||
<component :is="tool.to ? NuxtLink : 'span'" :to="tool.to" class="tool-name">
|
||||
{{ tool.label }}
|
||||
</component>
|
||||
<UiStatusBadge class="tool-status" :status="tool.status" :gestation="tool.gestation" :since="tool.since" />
|
||||
<p class="tool-desc">{{ tool.description }}</p>
|
||||
<p v-if="tool.presentation" class="tool-app">
|
||||
<strong>{{ tool.presentation.title }}</strong> — {{ tool.presentation.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tool.actions?.length" class="tool-actions">
|
||||
<template v-for="action in tool.actions" :key="action.label">
|
||||
<a
|
||||
v-if="action.href"
|
||||
:href="action.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--main': action.highlight }"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else-if="action.to"
|
||||
:to="action.to"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--quiet': action.secondary }"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
:class="{ 'tool-btn--main': action.highlight }"
|
||||
@click="$emit('action', action.id)"
|
||||
>
|
||||
<span :class="`i-lucide-${action.icon}`" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-for="action in tool.actions.filter(a => a.note)" :key="`note-${action.label}`" class="tool-note">
|
||||
{{ action.note }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HomeTool } from '~/composables/useHomeData'
|
||||
|
||||
defineProps<{
|
||||
tool: HomeTool
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
action: [id: string]
|
||||
}>()
|
||||
|
||||
const NuxtLink = resolveComponent('NuxtLink')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--color-bg) / 0.55);
|
||||
}
|
||||
|
||||
.tool-main {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--hue) / 0.12);
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-g1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tool-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
a.tool-name:hover {
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.tool-note {
|
||||
align-self: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
color: hsl(var(--axis-cit));
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--color-text) / 0.68);
|
||||
}
|
||||
|
||||
.tool-app {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--color-text) / 0.6);
|
||||
}
|
||||
|
||||
.tool-app strong {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.7rem;
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-text) / 0.8);
|
||||
background: hsl(var(--color-text) / 0.06);
|
||||
transition: transform 0.1s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
background: hsl(var(--hue) / 0.14);
|
||||
}
|
||||
|
||||
.tool-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tool-btn--main {
|
||||
color: hsl(var(--color-bg));
|
||||
background: hsl(var(--hue));
|
||||
}
|
||||
|
||||
.tool-btn--main:hover {
|
||||
background: hsl(var(--hue) / 0.88);
|
||||
}
|
||||
|
||||
.tool-btn--quiet {
|
||||
font-weight: 400;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-btn { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -1,5 +1,37 @@
|
||||
<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">
|
||||
<UiScrollReveal>
|
||||
<div class="text-center mb-12">
|
||||
@@ -48,4 +80,24 @@ const featuredSongs = computed(() => bookData.getSongs().slice(0, 6))
|
||||
.heading-section {
|
||||
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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Transition name="slide-menu">
|
||||
<nav
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
@@ -29,7 +29,7 @@
|
||||
<li v-for="item in nav" :key="item.to">
|
||||
<NuxtLink
|
||||
: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"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
<template>
|
||||
<footer class="border-t border-white/8 bg-surface-600 pb-[var(--player-height)]">
|
||||
<div class="container-content px-4 py-8">
|
||||
<footer class="footer-wrap border-t border-[hsl(var(--color-text)/0.1)] bg-[hsl(var(--color-surface))]">
|
||||
<!-- Shadok pattern -->
|
||||
<svg class="footer-shadok-pattern" viewBox="0 0 400 80" fill="none" aria-hidden="true">
|
||||
<g transform="translate(20,10)">
|
||||
<ellipse cx="15" cy="25" rx="12" ry="14" fill="currentColor" opacity="0.2"/>
|
||||
<circle cx="15" cy="10" r="7" fill="currentColor" opacity="0.16"/>
|
||||
<line x1="10" y1="38" x2="8" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
|
||||
<line x1="20" y1="38" x2="22" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.15"/>
|
||||
</g>
|
||||
<g transform="translate(80,15)">
|
||||
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
|
||||
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
|
||||
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
</g>
|
||||
<g transform="translate(140,8)">
|
||||
<ellipse cx="15" cy="25" rx="11" ry="13" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="15" cy="10" r="6.5" fill="currentColor" opacity="0.15"/>
|
||||
<line x1="10" y1="37" x2="7" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
<line x1="20" y1="37" x2="23" y2="54" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
</g>
|
||||
<g transform="translate(210,18)">
|
||||
<ellipse cx="15" cy="20" rx="10" ry="11" fill="currentColor" opacity="0.14"/>
|
||||
<circle cx="15" cy="7" r="5.5" fill="currentColor" opacity="0.11"/>
|
||||
<line x1="10" y1="30" x2="9" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
|
||||
<line x1="20" y1="30" x2="21" y2="44" stroke="currentColor" stroke-width="1.5" opacity="0.1"/>
|
||||
</g>
|
||||
<g transform="translate(270,12)">
|
||||
<ellipse cx="15" cy="24" rx="12" ry="14" fill="currentColor" opacity="0.18"/>
|
||||
<circle cx="15" cy="9" r="7" fill="currentColor" opacity="0.15"/>
|
||||
<line x1="10" y1="37" x2="7" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
<line x1="20" y1="37" x2="23" y2="55" stroke="currentColor" stroke-width="1.5" opacity="0.14"/>
|
||||
</g>
|
||||
<g transform="translate(340,16)">
|
||||
<ellipse cx="15" cy="22" rx="10" ry="12" fill="currentColor" opacity="0.16"/>
|
||||
<circle cx="15" cy="8" r="6" fill="currentColor" opacity="0.13"/>
|
||||
<line x1="10" y1="33" x2="8" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
<line x1="20" y1="33" x2="22" y2="48" stroke="currentColor" stroke-width="1.5" opacity="0.12"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="container-content px-4 py-8 relative z-1">
|
||||
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
|
||||
<!-- Credits -->
|
||||
<p class="text-sm text-white/40">
|
||||
<p class="text-sm text-[hsl(var(--color-text)/0.4)]">
|
||||
{{ site?.footer.credits }}
|
||||
</p>
|
||||
|
||||
@@ -13,7 +52,7 @@
|
||||
v-for="link in site?.footer.links"
|
||||
:key="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 }}
|
||||
</NuxtLink>
|
||||
@@ -26,3 +65,21 @@
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
</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>
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
<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">
|
||||
<!-- Logo -->
|
||||
<NuxtLink to="/" class="flex items-center gap-2 font-display text-lg font-bold tracking-tight">
|
||||
<div class="i-lucide-book-open h-6 w-6 text-primary" />
|
||||
<span class="text-gradient">{{ site?.identity.name }}</span>
|
||||
<NuxtLink to="/" class="logo-link flex items-center gap-2.5">
|
||||
<UiSectionSign class="logo-icon" />
|
||||
<span class="logo-text">{{ site?.identity.name }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
|
||||
<!-- Autonomie : prefix + axis buttons -->
|
||||
<span class="nav-prefix">Autonomie :</span>
|
||||
<NuxtLink
|
||||
v-for="item in site?.navigation"
|
||||
v-for="(item, i) in axes"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost text-sm"
|
||||
active-class="text-white! bg-white/5"
|
||||
class="btn-ghost text-sm nav-axis"
|
||||
:style="{ '--hue': `var(${AXIS_HUES[i] ?? '--color-primary'})` }"
|
||||
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Separator + extra nav -->
|
||||
<span class="nav-sep" />
|
||||
<NuxtLink
|
||||
v-for="item in extra"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost btn-ghost--muted text-sm"
|
||||
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<UiPaletteSelector />
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
@@ -31,11 +48,85 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" />
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="allNav" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
const axes = computed(() => (site.value as any)?.navigation?.axes ?? [])
|
||||
const extra = computed(() => (site.value as any)?.navigation?.extra ?? [])
|
||||
const allNav = computed(() => [...axes.value, ...extra.value])
|
||||
|
||||
// Same order as navigation.axes in site.yml: numérique, économique, citoyenne
|
||||
const AXIS_HUES = ['--axis-num', '--axis-eco', '--axis-cit']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
|
||||
@@ -2,41 +2,43 @@
|
||||
<Transition name="player-slide">
|
||||
<div
|
||||
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">
|
||||
<div v-if="store.isExpanded" class="border-b border-white/8">
|
||||
<div class="container-content grid gap-4 p-4 md:grid-cols-2">
|
||||
<PlayerVisualizer />
|
||||
<PlayerPlaylist />
|
||||
<div v-if="isExpanded" class="mini-panel">
|
||||
<!-- 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 />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Progress bar (top of player) -->
|
||||
<PlayerProgress />
|
||||
<!-- Progress -->
|
||||
<div class="panel-progress">
|
||||
<PlayerProgress />
|
||||
<div class="panel-times">
|
||||
<span>{{ store.formattedCurrentTime }}</span>
|
||||
<span>{{ store.formattedDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main player bar -->
|
||||
<div class="container-content flex items-center gap-4 px-4 py-2">
|
||||
<!-- Track info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<PlayerTrackInfo />
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
|
||||
<!-- Right section: mode + volume + expand -->
|
||||
<div class="hidden md:flex items-center gap-3 flex-shrink-0">
|
||||
<PlayerModeToggle />
|
||||
<!-- Controls -->
|
||||
<div class="panel-controls">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn-ghost !p-1" @click="toggleMute">
|
||||
<div :class="volumeIcon" class="h-4 w-4" />
|
||||
<div class="panel-volume-row">
|
||||
<button class="panel-vol-btn" @click="toggleMute">
|
||||
<div :class="volumeIcon" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
@@ -44,39 +46,101 @@
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="store.volume"
|
||||
class="volume-slider w-20"
|
||||
class="volume-slider"
|
||||
@input="handleVolumeChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Time display -->
|
||||
<span class="font-mono text-xs text-white/40 w-24 text-center">
|
||||
{{ store.formattedCurrentTime }} / {{ store.formattedDuration }}
|
||||
</span>
|
||||
|
||||
<!-- Expand toggle -->
|
||||
<!-- Lyrics (collapsed by default, available for standalone use) -->
|
||||
<div v-if="store.currentSong.lyrics && showLyrics" class="panel-lyrics">
|
||||
<pre class="panel-lyrics-text">{{ store.currentSong.lyrics }}</pre>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:aria-label="store.isExpanded ? 'Réduire' : 'Développer'"
|
||||
@click="store.toggleExpanded()"
|
||||
v-if="store.currentSong.lyrics"
|
||||
class="panel-lyrics-toggle"
|
||||
@click="showLyrics = !showLyrics"
|
||||
>
|
||||
<div :class="store.isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" />
|
||||
<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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const store = usePlayerStore()
|
||||
const { setVolume } = useAudioPlayer()
|
||||
const { setVolume, togglePlayPause, playNext } = useAudioPlayer()
|
||||
|
||||
// Initialize media session
|
||||
useMediaSession()
|
||||
useKeyboardShortcuts()
|
||||
|
||||
const widgetRef = ref<HTMLElement>()
|
||||
const isExpanded = ref(false)
|
||||
const showLyrics = ref(false)
|
||||
let previousVolume = 0.8
|
||||
|
||||
const circumference = 2 * Math.PI * 16
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (store.volume === 0) return 'i-lucide-volume-x'
|
||||
if (store.volume < 0.3) return 'i-lucide-volume'
|
||||
@@ -98,62 +162,328 @@ function toggleMute() {
|
||||
setVolume(previousVolume)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
onClickOutside(widgetRef, () => {
|
||||
if (isExpanded.value) isExpanded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-slide-enter-active,
|
||||
.player-slide-leave-active {
|
||||
transition: transform 0.3s var(--ease-out-expo);
|
||||
/* ═══════════════════════════════════════
|
||||
POSITION
|
||||
═══════════════════════════════════════ */
|
||||
.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,
|
||||
.player-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
/* Book player open: stay above its navigation bar */
|
||||
:global(html.book-player-open .mini-player) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
.panel-expand-enter-from,
|
||||
.panel-expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
/* ─── Track + visualizer ─── */
|
||||
.panel-top {
|
||||
padding: 1rem 1.25rem 0.5rem;
|
||||
}
|
||||
.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,
|
||||
.panel-expand-leave-from {
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
/* ─── Progress ─── */
|
||||
.panel-progress {
|
||||
padding: 0.5rem 1.25rem 0;
|
||||
}
|
||||
.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 {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: hsl(0 0% 100% / 0.15);
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: hsl(0 0% 100% / 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: hsl(0 0% 100% / 0.7);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover { transform: scale(1.3); }
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: hsl(0 0% 100% / 0.7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary/80"
|
||||
>
|
||||
<div class="i-lucide-music h-2.5 w-2.5" />
|
||||
{{ song.title }}
|
||||
@@ -17,7 +17,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
<template>
|
||||
<div
|
||||
class="card-surface flex cursor-pointer items-center gap-4"
|
||||
:class="{ 'border-primary/40! shadow-primary/10!': isCurrent }"
|
||||
@click="handlePlay"
|
||||
>
|
||||
<!-- Play indicator / cover -->
|
||||
<div class="song-item-wrapper">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-surface-200"
|
||||
:class="{ 'animate-glow-pulse': isCurrent && store.isPlaying }"
|
||||
class="card-surface flex cursor-pointer items-center gap-4"
|
||||
:class="{ 'border-primary/40! shadow-primary/10!': isCurrent }"
|
||||
@click="handlePlay"
|
||||
>
|
||||
<!-- Play indicator / cover -->
|
||||
<div
|
||||
v-if="isCurrent && store.isPlaying"
|
||||
class="i-lucide-volume-2 h-5 w-5 text-primary"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="i-lucide-play h-5 w-5 text-white/40 transition-colors group-hover:text-primary"
|
||||
/>
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-surface-200"
|
||||
:class="{ 'animate-glow-pulse': isCurrent && store.isPlaying }"
|
||||
>
|
||||
<div
|
||||
v-if="isCurrent && store.isPlaying"
|
||||
class="i-lucide-volume-2 h-5 w-5 text-primary"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="i-lucide-play h-5 w-5 text-white/40 transition-colors group-hover:text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium" :class="isCurrent ? 'text-primary' : 'text-white'">
|
||||
{{ song.title }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-white/40">
|
||||
{{ song.artist }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="font-mono text-xs text-white/30 flex-shrink-0">
|
||||
{{ formatDuration(song.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium" :class="isCurrent ? 'text-primary' : 'text-white'">
|
||||
{{ song.title }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-white/40">
|
||||
{{ song.artist }}
|
||||
</p>
|
||||
<!-- Lyrics panel (always visible) -->
|
||||
<div v-if="song.lyrics" class="lyrics-panel">
|
||||
<pre class="lyrics-text">{{ song.lyrics }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="font-mono text-xs text-white/30 flex-shrink-0">
|
||||
{{ formatDuration(song.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,3 +70,23 @@ function formatDuration(seconds: number): string {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</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,21 +1,9 @@
|
||||
<template>
|
||||
<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>
|
||||
<div
|
||||
: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>
|
||||
</div>
|
||||
</Transition>
|
||||
<span class="font-display text-sm font-semibold text-white/70">Paroles</span>
|
||||
<div class="mt-4 max-h-96 overflow-y-auto">
|
||||
<pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-white/60">{{ song.lyrics }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,26 +13,4 @@ import type { Song } from '~/types/song'
|
||||
defineProps<{
|
||||
song: Song
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<span v-if="label" class="status" :class="`status--${tone}`">
|
||||
<span class="status-dot" aria-hidden="true" />
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="since" class="status-since">· {{ since }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Project maturity, as written in YAML: `status: En production` (+ `since`),
|
||||
// `status: En développement`, or the legacy `gestation: true`.
|
||||
const props = defineProps<{
|
||||
status?: string
|
||||
gestation?: boolean
|
||||
since?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.status || (props.gestation ? 'En gestation' : ''))
|
||||
|
||||
const tone = computed(() => {
|
||||
const s = label.value.toLowerCase()
|
||||
if (s.includes('production')) return 'prod'
|
||||
if (s.includes('développement') || s.includes('developpement')) return 'dev'
|
||||
return 'gest'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
color: hsl(var(--color-text) / 0.75);
|
||||
background: hsl(var(--color-text) / 0.06);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* En production : plein */
|
||||
.status--prod .status-dot { background: hsl(var(--axis-cit)); }
|
||||
.status--prod { color: hsl(var(--axis-cit)); background: hsl(var(--axis-cit) / 0.1); }
|
||||
|
||||
/* En développement : demi-plein */
|
||||
.status--dev .status-dot {
|
||||
background: linear-gradient(to top, hsl(var(--axis-eco)) 50%, transparent 50%);
|
||||
box-shadow: inset 0 0 0 1.5px hsl(var(--axis-eco));
|
||||
}
|
||||
.status--dev { color: hsl(var(--axis-eco)); background: hsl(var(--axis-eco) / 0.1); }
|
||||
|
||||
/* En gestation : cercle */
|
||||
.status--gest .status-dot { box-shadow: inset 0 0 0 1.5px hsl(var(--color-text) / 0.45); }
|
||||
|
||||
.status-since {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,12 @@ import type { Song } from '~/types/song'
|
||||
|
||||
let audio: HTMLAudioElement | null = null
|
||||
let animationFrameId: number | null = null
|
||||
// Track which milestones have been fired for the current song
|
||||
const firedMilestones = new Set<number>()
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const store = usePlayerStore()
|
||||
const { trackAudioPlay, trackAudioComplete, trackAudioProgress } = useTracking()
|
||||
|
||||
function getAudio(): HTMLAudioElement {
|
||||
if (!audio) {
|
||||
@@ -17,6 +20,10 @@ export function useAudioPlayer() {
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
if (store.currentSong) {
|
||||
trackAudioComplete(store.currentSong.id, store.currentSong.title)
|
||||
}
|
||||
firedMilestones.clear()
|
||||
const next = store.nextSong()
|
||||
if (next) {
|
||||
loadAndPlay(next)
|
||||
@@ -36,6 +43,16 @@ export function useAudioPlayer() {
|
||||
const update = () => {
|
||||
if (audio && !audio.paused) {
|
||||
store.setCurrentTime(audio.currentTime)
|
||||
// Track progress milestones (25 / 50 / 75 %)
|
||||
if (audio.duration > 0 && store.currentSong) {
|
||||
const pct = (audio.currentTime / audio.duration) * 100
|
||||
for (const milestone of [25, 50, 75] as const) {
|
||||
if (pct >= milestone && !firedMilestones.has(milestone)) {
|
||||
firedMilestones.add(milestone)
|
||||
trackAudioProgress(store.currentSong.id, milestone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
}
|
||||
@@ -52,32 +69,21 @@ export function useAudioPlayer() {
|
||||
async function loadAndPlay(song: Song) {
|
||||
const el = getAudio()
|
||||
store.setSong(song)
|
||||
firedMilestones.clear()
|
||||
|
||||
// Try OGG first, fall back to MP3
|
||||
const oggPath = song.file.replace(/\.mp3$/, '.ogg')
|
||||
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis')
|
||||
|
||||
el.src = canOgg ? oggPath : song.file
|
||||
// Only MP3 files are published (no .ogg on disk nor in production):
|
||||
// asking for OGG first cost a 404 and a delay on every track.
|
||||
el.src = song.file
|
||||
el.volume = store.volume
|
||||
|
||||
try {
|
||||
await el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
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) {
|
||||
console.error('Playback failed:', err)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Playback failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,13 +130,7 @@ export function useAudioPlayer() {
|
||||
function playPrev() {
|
||||
const song = store.prevSong()
|
||||
if (song) {
|
||||
if (song === store.currentSong && store.currentTime <= 3) {
|
||||
// prevSong already reset time
|
||||
seek(0)
|
||||
}
|
||||
else {
|
||||
loadAndPlay(song)
|
||||
}
|
||||
loadAndPlay(song)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import yaml from 'yaml'
|
||||
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
|
||||
|
||||
async function loadConfig(): Promise<BookConfig> {
|
||||
if (_configCache) return _configCache
|
||||
|
||||
const raw = await import('~/data/librodrome.config.yml?raw').then(m => m.default)
|
||||
const parsed = yaml.parse(raw)
|
||||
const parsed = await $fetch<any>('/api/content/config')
|
||||
|
||||
_configCache = {
|
||||
title: parsed.book.title,
|
||||
author: parsed.book.author,
|
||||
description: parsed.book.description,
|
||||
coverImage: parsed.book.coverImage,
|
||||
pdfFile: parsed.book.pdfFile,
|
||||
chapters: [],
|
||||
songs: parsed.songs as Song[],
|
||||
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
||||
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
|
||||
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
||||
}
|
||||
|
||||
@@ -70,6 +70,22 @@ export function useBookData() {
|
||||
.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() {
|
||||
if (!config.value) return null
|
||||
return {
|
||||
@@ -89,7 +105,10 @@ export function useBookData() {
|
||||
getChapterSongs,
|
||||
getPrimarySong,
|
||||
getChapterSongLinks,
|
||||
getChapterForSong,
|
||||
getPlaylistOrder,
|
||||
getBookMeta,
|
||||
getChapterPage,
|
||||
getPdfUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
export function useGrateWizard() {
|
||||
const appConfig = useAppConfig()
|
||||
const { url } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
|
||||
function launch() {
|
||||
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
const left = Math.round((window.screen.width - popup.width) / 2)
|
||||
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`,
|
||||
)
|
||||
function launch(e?: Event) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
e?.preventDefault()
|
||||
}
|
||||
|
||||
return { launch }
|
||||
return { url, launch }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Normalised view of the home content, shared by the three home variants.
|
||||
// Everything comes from YAML (site/pages/home.yml, site/site.yml,
|
||||
// site/pages/evenement.yml, site/pages/numerique/cloud-libre.yml) — no copy here.
|
||||
|
||||
export type AxisKey = 'numerique' | 'economie' | 'politique'
|
||||
export type StackStatus = 'service' | 'catalogue' | 'avenir'
|
||||
|
||||
export interface HomeAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
highlight?: boolean
|
||||
secondary?: boolean
|
||||
to?: string
|
||||
href?: string
|
||||
/** Short status line shown under the button (e.g. the v1 in production) */
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface HomeTool {
|
||||
label: string
|
||||
description: string
|
||||
to?: string
|
||||
href?: string
|
||||
gestation?: boolean
|
||||
/** « En production », « En développement »… — wins over `gestation` */
|
||||
status?: string
|
||||
since?: string
|
||||
icon?: string
|
||||
presentation?: { title: string, text: string }
|
||||
actions?: HomeAction[]
|
||||
}
|
||||
|
||||
export interface HomeStack {
|
||||
role: string
|
||||
name?: string
|
||||
status: StackStatus
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface HomeAxis {
|
||||
key: AxisKey
|
||||
/** Short label as used in the navigation ("numérique", "économique", "citoyenne") */
|
||||
short: string
|
||||
title: string
|
||||
icon: string
|
||||
to: string
|
||||
/** What is "le plus en amont" on this axis (hero.axes) */
|
||||
lever: string
|
||||
promise: string
|
||||
/** CSS custom property holding the axis hue */
|
||||
hue: string
|
||||
items: HomeTool[]
|
||||
}
|
||||
|
||||
const AXIS_META: Record<AxisKey, { heroLabel: string, to: string, hue: string, short: string }> = {
|
||||
numerique: { heroLabel: 'numérique', to: '/numerique', hue: '--axis-num', short: 'numérique' },
|
||||
economie: { heroLabel: 'économie', to: '/economique', hue: '--axis-eco', short: 'économique' },
|
||||
politique: { heroLabel: 'citoyenne', to: '/citoyenne', hue: '--axis-cit', short: 'citoyenne' },
|
||||
}
|
||||
|
||||
export const AXIS_ORDER: AxisKey[] = ['numerique', 'economie', 'politique']
|
||||
|
||||
export async function useHomeData() {
|
||||
const [{ data: home }, { data: site }, { data: event }, { data: cloud }] = await Promise.all([
|
||||
usePageContent('home'),
|
||||
useSiteContent(),
|
||||
usePageContent('evenement'),
|
||||
usePageContent('numerique/cloud-libre'),
|
||||
])
|
||||
|
||||
const hero = computed(() => (home.value as any)?.hero ?? {})
|
||||
|
||||
const axes = computed<HomeAxis[]>(() => {
|
||||
const raw = (home.value as any)?.axes ?? {}
|
||||
const levers: { label: string, value: string }[] = hero.value.axes ?? []
|
||||
return AXIS_ORDER.filter(k => raw[k]).map((key) => {
|
||||
const meta = AXIS_META[key]
|
||||
return {
|
||||
key,
|
||||
short: meta.short,
|
||||
title: raw[key].title,
|
||||
icon: raw[key].icon,
|
||||
to: meta.to,
|
||||
lever: levers.find(l => l.label === meta.heroLabel)?.value ?? '',
|
||||
promise: raw[key].promise ?? '',
|
||||
hue: meta.hue,
|
||||
items: raw[key].items ?? [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const lpk = computed(() => (site.value as any)?.lpk as
|
||||
{ name: string, text: string, role: string, to: string } | undefined)
|
||||
|
||||
/** Bouquet sweetHome, flattened — the stacks served by LaPlanK */
|
||||
const bouquet = computed(() => {
|
||||
const section = ((cloud.value as any)?.sections ?? []).find((s: any) => s.type === 'territoire')
|
||||
const b = section?.bouquet
|
||||
if (!b) return null
|
||||
const stacks: (HomeStack & { category: string })[] = []
|
||||
for (const cat of b.categories ?? []) {
|
||||
for (const item of cat.items ?? []) {
|
||||
if (typeof item === 'string') continue
|
||||
stacks.push({ ...item, category: cat.label })
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: b.title as string,
|
||||
statuses: (b.statuses ?? {}) as Record<StackStatus, string>,
|
||||
categories: (b.categories ?? []) as { label: string, icon: string, items: HomeStack[] }[],
|
||||
stacks,
|
||||
inService: stacks.filter(s => s.status === 'service' && s.name),
|
||||
}
|
||||
})
|
||||
|
||||
const evenement = computed(() => {
|
||||
const e = event.value as any
|
||||
const teaser = (home.value as any)?.evenement
|
||||
if (!e && !teaser) return null
|
||||
const politique = (e?.axes ?? []).find((a: any) => a.label === 'Politique')
|
||||
return {
|
||||
to: teaser?.to ?? '/evenement',
|
||||
title: e?.title ?? teaser?.title,
|
||||
teaser: teaser ? `${teaser.title} ${teaser.subtitle}` : '',
|
||||
subtitle: e?.subtitle ?? '',
|
||||
tagline: e?.tagline ?? '',
|
||||
leitmotiv: e?.leitmotiv ?? '',
|
||||
description: e?.description ?? '',
|
||||
gestation: e?.gestation ?? teaser?.gestation,
|
||||
config: (e?.config ?? []) as { icon: string, label: string, detail: string }[],
|
||||
espaces: (e?.espaces ?? []) as { icon: string, label: string, desc: string }[],
|
||||
/** "Autonomie d'un bassin de vie — accessible et reproductible" */
|
||||
reproduction: (politique?.items?.[0] as string) ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
home,
|
||||
hero,
|
||||
axes,
|
||||
lpk,
|
||||
bouquet,
|
||||
evenement,
|
||||
book: computed(() => (home.value as any)?.book),
|
||||
format: computed(() => (home.value as any)?.format as { title: string, text: string } | undefined),
|
||||
toolboxTitle: computed(() => (home.value as any)?.toolbox?.title as string | undefined),
|
||||
consoleLabels: computed(() => (home.value as any)?.console as { prefix: string, top: string, bottom: string } | undefined),
|
||||
gratewizard: computed(() => (home.value as any)?.gratewizard),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Tracks scroll depth milestones (25 / 50 / 75 / 100 %) on each page.
|
||||
* Also intercepts external link clicks and tracks them.
|
||||
*
|
||||
* Usage: call once in default.vue layout.
|
||||
*/
|
||||
export function useScrollTracking() {
|
||||
const route = useRoute()
|
||||
const { trackScrollDepth, trackExternalLink } = useTracking()
|
||||
|
||||
// ── Scroll depth ────────────────────────────────────────────────────────
|
||||
const fired = new Set<number>()
|
||||
|
||||
function onScroll() {
|
||||
const el = document.documentElement
|
||||
const scrolled = el.scrollTop + el.clientHeight
|
||||
const total = el.scrollHeight
|
||||
if (total <= el.clientHeight) return
|
||||
const pct = (scrolled / total) * 100
|
||||
|
||||
for (const milestone of [25, 50, 75, 100] as const) {
|
||||
if (pct >= milestone && !fired.has(milestone)) {
|
||||
fired.add(milestone)
|
||||
trackScrollDepth(route.path, milestone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset fired milestones on route change
|
||||
watch(() => route.path, () => fired.clear())
|
||||
|
||||
// ── External link tracking ───────────────────────────────────────────────
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
const target = (e.target as HTMLElement).closest('a')
|
||||
if (!target) return
|
||||
const href = target.getAttribute('href') || ''
|
||||
if (!href.startsWith('http') && !href.startsWith('//')) return
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.hostname === 'librodrome.org') return
|
||||
trackExternalLink(href, target.textContent?.trim() || '')
|
||||
}
|
||||
catch { /* invalid URL, ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.addEventListener('click', onLinkClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.removeEventListener('click', onLinkClick)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Applique toutes les balises SEO (og:*, Twitter Cards, canonical, description)
|
||||
* à partir du contenu YAML d'une page.
|
||||
*
|
||||
* Usage dans les pages :
|
||||
* useSeoPage({ title: content.value?.meta?.title, description: content.value?.description })
|
||||
*
|
||||
* L'og:image par défaut est /og-default.png (logo §).
|
||||
* Chaque section peut surcharger avec son propre image via le champ seo.image du YAML.
|
||||
*/
|
||||
export function useSeoPage(opts: {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
image?: string | null
|
||||
type?: 'website' | 'article' | 'book'
|
||||
}) {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const siteUrl = (config.public.siteUrl as string) || 'https://librodrome.org'
|
||||
|
||||
const title = opts.title || 'Le Librodrome'
|
||||
const description = opts.description
|
||||
|| 'Autonomie numérique, économique et citoyenne. Un livre et des chansons sur l\'économie du don.'
|
||||
const rawImage = opts.image || '/og-default.png?v=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 }],
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,12 @@ interface SiteContent {
|
||||
credits: string
|
||||
links: NavItem[]
|
||||
}
|
||||
lpk?: {
|
||||
name: string
|
||||
text: string
|
||||
role: string
|
||||
to: string
|
||||
}
|
||||
gratewizard: {
|
||||
url: string
|
||||
popup: {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Umami analytics wrapper — safe server-side, no-op when not configured.
|
||||
*
|
||||
* Events convention:
|
||||
* audio:play | audio:pause | audio:complete | audio:progress
|
||||
* pdf:open | pdf:close
|
||||
* player:open | player:chapter | player:mode
|
||||
* scroll:depth
|
||||
* link:external
|
||||
* cta:click
|
||||
*/
|
||||
export function useTracking() {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const enabled = !!runtimeConfig.public.umamiWebsiteId
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────────
|
||||
function track(event: string, data?: Record<string, unknown>) {
|
||||
if (!import.meta.client || !enabled) return
|
||||
const umami = (window as Record<string, unknown>).umami as
|
||||
| { track: (event: string, data?: unknown) => void }
|
||||
| undefined
|
||||
umami?.track(event, data)
|
||||
}
|
||||
|
||||
// ── Audio ────────────────────────────────────────────────────────────────
|
||||
function trackAudioPlay(songId: string, songTitle: string, context?: string) {
|
||||
track('audio:play', { song_id: songId, song_title: songTitle, context })
|
||||
}
|
||||
|
||||
function trackAudioPause(songId: string, progressPct: number) {
|
||||
track('audio:pause', { song_id: songId, progress_pct: progressPct })
|
||||
}
|
||||
|
||||
function trackAudioComplete(songId: string, songTitle: string) {
|
||||
track('audio:complete', { song_id: songId, song_title: songTitle })
|
||||
}
|
||||
|
||||
/** Fired at 25 / 50 / 75 % milestones */
|
||||
function trackAudioProgress(songId: string, milestone: 25 | 50 | 75) {
|
||||
track('audio:progress', { song_id: songId, milestone })
|
||||
}
|
||||
|
||||
// ── PDF ──────────────────────────────────────────────────────────────────
|
||||
function trackPdfOpen(trigger?: string) {
|
||||
track('pdf:open', { trigger })
|
||||
}
|
||||
|
||||
function trackPdfClose(pagesVisited: number, durationMs: number) {
|
||||
track('pdf:close', { pages_visited: pagesVisited, duration_ms: durationMs })
|
||||
}
|
||||
|
||||
// ── BookPlayer ───────────────────────────────────────────────────────────
|
||||
function trackPlayerOpen(trigger?: string) {
|
||||
track('player:open', { trigger })
|
||||
}
|
||||
|
||||
function trackPlayerChapter(chapterSlug: string) {
|
||||
track('player:chapter', { chapter_slug: chapterSlug })
|
||||
}
|
||||
|
||||
function trackPlayerMode(mode: 'guided' | 'scroll') {
|
||||
track('player:mode', { mode })
|
||||
}
|
||||
|
||||
// ── Navigation & UX ──────────────────────────────────────────────────────
|
||||
function trackScrollDepth(page: string, depth: 25 | 50 | 75 | 100) {
|
||||
track('scroll:depth', { page, depth })
|
||||
}
|
||||
|
||||
function trackExternalLink(url: string, label?: string) {
|
||||
const route = useRoute()
|
||||
track('link:external', { url, label, from_page: route.path })
|
||||
}
|
||||
|
||||
function trackCta(label: string, target?: string) {
|
||||
const route = useRoute()
|
||||
track('cta:click', { label, target, from_page: route.path })
|
||||
}
|
||||
|
||||
return {
|
||||
track,
|
||||
enabled,
|
||||
trackAudioPlay,
|
||||
trackAudioPause,
|
||||
trackAudioComplete,
|
||||
trackAudioProgress,
|
||||
trackPdfOpen,
|
||||
trackPdfClose,
|
||||
trackPlayerOpen,
|
||||
trackPlayerChapter,
|
||||
trackPlayerMode,
|
||||
trackScrollDepth,
|
||||
trackExternalLink,
|
||||
trackCta,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,15 +1,49 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<main class="pb-[var(--player-height)]">
|
||||
<main class="app-main">
|
||||
<slot />
|
||||
|
||||
<!-- 益 Yì (Increase, #42) — sceau hexagramme (traits épais, coins nets, ~carré) -->
|
||||
<svg class="app-seal" viewBox="0 0 112 105" fill="currentColor" aria-hidden="true">
|
||||
<!-- Line 6 (top) — yang -->
|
||||
<rect x="6" y="6" width="100" height="8"/>
|
||||
<!-- Line 5 — yang -->
|
||||
<rect x="6" y="23" width="100" height="8"/>
|
||||
<!-- Line 4 — yin -->
|
||||
<rect x="6" y="40" width="42" height="8"/>
|
||||
<rect x="64" y="40" width="42" height="8"/>
|
||||
<!-- Line 3 — yin -->
|
||||
<rect x="6" y="57" width="42" height="8"/>
|
||||
<rect x="64" y="57" width="42" height="8"/>
|
||||
<!-- Line 2 — yin -->
|
||||
<rect x="6" y="74" width="42" height="8"/>
|
||||
<rect x="64" y="74" width="42" height="8"/>
|
||||
<!-- Line 1 (bottom) — yang -->
|
||||
<rect x="6" y="91" width="100" height="8"/>
|
||||
</svg>
|
||||
</main>
|
||||
<LayoutTheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useScrollTracking()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* === 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<div class="reading-layout pb-[var(--player-height)]">
|
||||
<div class="reading-layout">
|
||||
<aside class="chapter-sidebar hidden lg:block">
|
||||
<BookChapterNav />
|
||||
</aside>
|
||||
@@ -35,7 +35,7 @@
|
||||
top: var(--header-height);
|
||||
height: calc(100dvh - var(--header-height));
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+55
-3
@@ -1,5 +1,34 @@
|
||||
<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">
|
||||
<ContentRenderer v-if="page" :value="page" class="prose" />
|
||||
</div>
|
||||
@@ -11,11 +40,34 @@ definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'À propos',
|
||||
useSeoPage({
|
||||
title: 'À propos — Le Librodrome',
|
||||
description: 'Le Librodrome : l\'histoire du projet, la démarche et les personnes derrière le livre et les outils d\'autonomie.',
|
||||
})
|
||||
|
||||
const { data: page } = await useAsyncData('about', () =>
|
||||
queryCollection('pages').path('/pages/about').first(),
|
||||
)
|
||||
</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>
|
||||
|
||||
+232
-30
@@ -6,24 +6,66 @@
|
||||
← Chapitres
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">
|
||||
{{ chapter?.slug }}
|
||||
{{ chapterTitle || slug }}
|
||||
</h1>
|
||||
<span class="text-xs text-white/30 font-mono">{{ slug }}</span>
|
||||
</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" />
|
||||
</div>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
</div>
|
||||
|
||||
<template v-if="chapter">
|
||||
<AdminFormSection title="Frontmatter" open>
|
||||
<textarea
|
||||
v-model="frontmatter"
|
||||
class="fm-textarea"
|
||||
rows="6"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<AdminFormSection title="Métadonnées">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="field-label">Titre</label>
|
||||
<input v-model="title" class="field-input" placeholder="Titre du chapitre" />
|
||||
</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 title="Contenu Markdown" open>
|
||||
<AdminMarkdownEditor v-model="body" :rows="30" />
|
||||
<AdminFormSection title="Morceaux associés">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -39,17 +81,80 @@ const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
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 chapterTitle = computed(() => title.value)
|
||||
const wordCount = computed(() => {
|
||||
if (!body.value) return 0
|
||||
return body.value.trim().split(/\s+/).filter(Boolean).length
|
||||
})
|
||||
|
||||
watch(chapter, (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 ?? ''
|
||||
}
|
||||
}, { 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 saved = ref(false)
|
||||
|
||||
@@ -57,13 +162,40 @@ async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
frontmatter: frontmatter.value,
|
||||
body: body.value,
|
||||
},
|
||||
body: { frontmatter, 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
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
@@ -74,21 +206,91 @@ async function save() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fm-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border-radius: 0.5rem;
|
||||
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;
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
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>
|
||||
|
||||
+383
-16
@@ -1,18 +1,105 @@
|
||||
<template>
|
||||
<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 & 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">
|
||||
<NuxtLink
|
||||
v-for="chapter in chapters"
|
||||
<div
|
||||
v-for="(chapter, i) in chapters"
|
||||
:key="chapter.slug"
|
||||
:to="`/admin/book/${chapter.slug}`"
|
||||
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>
|
||||
<span class="chapter-title">{{ chapter.title }}</span>
|
||||
<div class="i-lucide-chevron-right h-4 w-4 text-white/20" />
|
||||
</NuxtLink>
|
||||
<div class="drag-handle" aria-label="Réordonner">
|
||||
<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" />
|
||||
</NuxtLink>
|
||||
</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>
|
||||
@@ -23,37 +110,317 @@ definePageMeta({
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.pdf-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.save-pdf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: none;
|
||||
color: hsl(var(--color-text-muted));
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-pdf-btn:hover:not(:disabled) {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.save-pdf-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chapter-item:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 6%);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.chapter-item--dragging {
|
||||
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 {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(var(--color-primary) / 0.5);
|
||||
font-weight: 600;
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
.chapter-info {
|
||||
flex: 1;
|
||||
color: white;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
display: block;
|
||||
color: hsl(var(--color-text));
|
||||
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>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<p class="text-sm text-white/50">Identité, navigation, footer</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/pages/home" class="dash-card">
|
||||
<div class="i-lucide-home h-8 w-8 text-primary mb-2" />
|
||||
<NuxtLink to="/admin/pages" class="dash-card">
|
||||
<div class="i-lucide-file-text h-8 w-8 text-primary mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Pages</h2>
|
||||
<p class="text-sm text-white/50">Contenus des pages publiques</p>
|
||||
</NuxtLink>
|
||||
@@ -27,6 +27,12 @@
|
||||
<p class="text-sm text-white/50">Métadonnées des pistes</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/messages" class="dash-card">
|
||||
<div class="i-lucide-message-square h-8 w-8 text-accent mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Messages</h2>
|
||||
<p class="text-sm text-white/50">Modération des messages visiteurs</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/media" class="dash-card">
|
||||
<div class="i-lucide-image h-8 w-8 text-accent mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Médias</h2>
|
||||
@@ -47,14 +53,14 @@ definePageMeta({
|
||||
.dash-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dash-card:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 8%);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
|
||||
+35
-10
@@ -22,6 +22,10 @@
|
||||
<div v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
|
||||
Se connecter
|
||||
</button>
|
||||
|
||||
<p v-if="devHint" class="dev-hint">
|
||||
Dev : <code>{{ devHint }}</code>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -34,6 +38,13 @@ definePageMeta({
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
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() {
|
||||
error.value = ''
|
||||
@@ -61,16 +72,16 @@ async function login() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 3.5%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 1rem;
|
||||
background: hsl(20 8% 6%);
|
||||
background: hsl(var(--color-bg));
|
||||
}
|
||||
|
||||
.login-error {
|
||||
@@ -86,7 +97,7 @@ async function login() {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -94,16 +105,16 @@ async function login() {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
@@ -115,7 +126,7 @@ async function login() {
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
@@ -124,11 +135,25 @@ async function login() {
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.7;
|
||||
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>
|
||||
|
||||
+161
-33
@@ -5,7 +5,7 @@
|
||||
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-3">
|
||||
<div v-if="messages?.length" class="space-y-4">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
@@ -17,6 +17,9 @@
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span>
|
||||
<span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span>
|
||||
<span class="type-badge" :class="`type-badge--${msg.type || 'reaction'}`">
|
||||
{{ typeLabel(msg.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
|
||||
@@ -29,23 +32,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texte éditable -->
|
||||
<div v-if="editing === msg.id" class="mb-3">
|
||||
<input
|
||||
v-model="editForm.author"
|
||||
class="admin-input mb-2 w-full"
|
||||
placeholder="Auteur"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editForm.text"
|
||||
class="admin-input w-full"
|
||||
rows="3"
|
||||
/>
|
||||
<!-- Texte du message -->
|
||||
<div v-if="editing === msg.id" class="mb-3 space-y-2">
|
||||
<input v-model="editForm.author" class="admin-input w-full" placeholder="Auteur" />
|
||||
<select v-model="editForm.type" class="admin-input w-full">
|
||||
<option value="question">Question</option>
|
||||
<option value="suggestion">Suggestion</option>
|
||||
<option value="retour">Retour d'expérience</option>
|
||||
</select>
|
||||
<textarea v-model="editForm.text" class="admin-input w-full" rows="3" />
|
||||
</div>
|
||||
<p v-else class="msg-text text-sm leading-relaxed mb-3">{{ msg.text }}</p>
|
||||
|
||||
<!-- Réponse existante -->
|
||||
<div v-if="msg.reply && replyEditing !== msg.id" class="reply-block mb-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-primary/80">Réponse publiée</span>
|
||||
<span class="text-xs text-white/30">{{ formatDate(msg.reply.publishedAt) }}</span>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm leading-relaxed">{{ msg.reply.text }}</p>
|
||||
<button class="action-btn mt-2 text-xs" @click="startReply(msg)">
|
||||
<div class="i-lucide-pencil h-3 w-3" />
|
||||
Modifier la réponse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire réponse -->
|
||||
<div v-if="replyEditing === msg.id" class="reply-form mb-3">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
class="admin-input w-full text-sm"
|
||||
rows="3"
|
||||
placeholder="Votre réponse..."
|
||||
/>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="action-btn action-btn--save" @click="saveReply(msg)">
|
||||
<div class="i-lucide-check h-3.5 w-3.5" />
|
||||
Publier la réponse
|
||||
</button>
|
||||
<button v-if="msg.reply" class="action-btn action-btn--danger" @click="removeReply(msg)">
|
||||
<div class="i-lucide-x h-3.5 w-3.5" />
|
||||
Supprimer la réponse
|
||||
</button>
|
||||
<button class="action-btn" @click="replyEditing = null">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="action-btn" @click="togglePublished(msg)">
|
||||
<div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
|
||||
{{ msg.published ? 'Dépublier' : 'Publier' }}
|
||||
@@ -56,15 +90,18 @@
|
||||
<div class="i-lucide-check h-3.5 w-3.5" />
|
||||
Valider
|
||||
</button>
|
||||
<button class="action-btn" @click="editing = null">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="action-btn" @click="editing = null">Annuler</button>
|
||||
</template>
|
||||
<button v-else class="action-btn" @click="startEdit(msg)">
|
||||
<div class="i-lucide-pencil h-3.5 w-3.5" />
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
<button v-if="replyEditing !== msg.id && !msg.reply" class="action-btn action-btn--reply" @click="startReply(msg)">
|
||||
<div class="i-lucide-reply h-3.5 w-3.5" />
|
||||
Répondre
|
||||
</button>
|
||||
|
||||
<button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
|
||||
<div class="i-lucide-trash-2 h-3.5 w-3.5" />
|
||||
Supprimer
|
||||
@@ -86,18 +123,33 @@ definePageMeta({
|
||||
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
|
||||
|
||||
const editing = ref<number | null>(null)
|
||||
const editForm = reactive({ author: '', text: '' })
|
||||
const editForm = reactive({ author: '', text: '', type: 'question' })
|
||||
|
||||
const replyEditing = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
function startEdit(msg: any) {
|
||||
editing.value = msg.id
|
||||
editForm.author = msg.author
|
||||
editForm.text = msg.text
|
||||
editForm.type = TYPE_LABELS[msg.type] ? msg.type : 'question'
|
||||
}
|
||||
|
||||
async function saveEdit(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { author: editForm.author, text: editForm.text },
|
||||
body: { author: editForm.author, text: editForm.text, type: editForm.type },
|
||||
})
|
||||
editing.value = null
|
||||
await refresh()
|
||||
@@ -111,6 +163,29 @@ async function togglePublished(msg: any) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function startReply(msg: any) {
|
||||
replyEditing.value = msg.id
|
||||
replyText.value = msg.reply?.text ?? ''
|
||||
}
|
||||
|
||||
async function saveReply(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { reply: replyText.value.trim() || null },
|
||||
})
|
||||
replyEditing.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function removeReply(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { reply: null },
|
||||
})
|
||||
replyEditing.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function remove(msg: any) {
|
||||
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
|
||||
@@ -130,14 +205,14 @@ function formatDate(iso: string) {
|
||||
|
||||
<style scoped>
|
||||
.msg-row {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-bg));
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.msg-row--draft {
|
||||
border-left: 3px solid hsl(36 80% 52%);
|
||||
border-left: 3px solid hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -155,22 +230,61 @@ function formatDate(iso: string) {
|
||||
}
|
||||
|
||||
.status-badge--draft {
|
||||
background: hsl(36 80% 52% / 0.15);
|
||||
color: hsl(36 80% 66%);
|
||||
background: hsl(var(--color-accent) / 0.15);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
.type-badge--question {
|
||||
background: hsl(220 70% 50% / 0.12);
|
||||
color: hsl(220 70% 65%);
|
||||
}
|
||||
|
||||
.type-badge--suggestion {
|
||||
background: hsl(280 60% 55% / 0.12);
|
||||
color: hsl(280 60% 70%);
|
||||
}
|
||||
|
||||
.type-badge--retour {
|
||||
background: hsl(35 70% 50% / 0.12);
|
||||
color: hsl(35 70% 65%);
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.3);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.reply-form {
|
||||
background: hsl(var(--color-surface) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -180,16 +294,16 @@ function formatDate(iso: string) {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 60%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
background: none;
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-text));
|
||||
}
|
||||
|
||||
.action-btn--save {
|
||||
@@ -201,6 +315,20 @@ function formatDate(iso: string) {
|
||||
background: hsl(142 70% 40% / 0.1);
|
||||
}
|
||||
|
||||
.action-btn--reply {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary) / 0.8);
|
||||
}
|
||||
|
||||
.action-btn--reply:hover {
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
color: hsl(var(--color-text) / 0.72);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: hsl(0 70% 40% / 0.1);
|
||||
border-color: hsl(0 70% 40% / 0.3);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Dashboard
|
||||
<NuxtLink to="/admin/pages" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Pages
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">
|
||||
Page : {{ pageName }}
|
||||
Page : {{ pagePath }}
|
||||
</h1>
|
||||
</div>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
@@ -40,9 +40,12 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = computed(() => route.params.name as string)
|
||||
const pagePath = computed(() => {
|
||||
const p = route.params.path
|
||||
return Array.isArray(p) ? p.join('/') : p
|
||||
})
|
||||
|
||||
const { data, error } = await useFetch(() => `/api/content/pages/${pageName.value}`)
|
||||
const { data, error } = await useFetch(() => `/api/content/pages/${pagePath.value}`)
|
||||
|
||||
const yamlContent = ref('')
|
||||
|
||||
@@ -60,7 +63,7 @@ async function save() {
|
||||
saved.value = false
|
||||
try {
|
||||
const parsed = yaml.parse(yamlContent.value)
|
||||
await $fetch(`/api/admin/content/pages/${pageName.value}`, {
|
||||
await $fetch(`/api/admin/content/pages/${pagePath.value}`, {
|
||||
method: 'PUT',
|
||||
body: parsed,
|
||||
})
|
||||
@@ -80,10 +83,10 @@ async function save() {
|
||||
.yaml-textarea {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 4%);
|
||||
color: hsl(36 80% 76%);
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-accent));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
@@ -93,6 +96,6 @@ async function save() {
|
||||
|
||||
.yaml-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Dashboard
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">Pages</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pages" class="flex flex-col gap-6">
|
||||
<!-- Root pages -->
|
||||
<div v-if="rootPages.length">
|
||||
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">Pages principales</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="page in rootPages"
|
||||
:key="page.path"
|
||||
:to="`/admin/pages/${page.path}`"
|
||||
class="page-card"
|
||||
>
|
||||
<div class="i-lucide-file-text h-5 w-5 text-primary mb-1" />
|
||||
<span class="font-mono text-sm text-white">{{ page.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section pages -->
|
||||
<div v-for="[section, sectionPages] in sections" :key="section">
|
||||
<h2 class="font-display text-lg font-semibold text-white/80 mb-3">
|
||||
<span class="i-lucide-folder h-4 w-4 inline-block mr-1 text-accent" />
|
||||
{{ section }}
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="page in sectionPages"
|
||||
:key="page.path"
|
||||
:to="`/admin/pages/${page.path}`"
|
||||
class="page-card"
|
||||
>
|
||||
<div class="i-lucide-file-text h-5 w-5 text-primary/60 mb-1" />
|
||||
<span class="font-mono text-sm text-white">{{ page.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
interface PageEntry {
|
||||
path: string
|
||||
label: string
|
||||
section?: string
|
||||
}
|
||||
|
||||
const { data: pages } = await useFetch<PageEntry[]>('/api/admin/content/pages')
|
||||
|
||||
const rootPages = computed(() =>
|
||||
(pages.value ?? []).filter(p => !p.section),
|
||||
)
|
||||
|
||||
const sections = computed(() => {
|
||||
const map = new Map<string, PageEntry[]>()
|
||||
for (const p of pages.value ?? []) {
|
||||
if (p.section) {
|
||||
if (!map.has(p.section)) map.set(p.section, [])
|
||||
map.get(p.section)!.push(p)
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
</style>
|
||||
@@ -65,7 +65,7 @@
|
||||
</AdminFieldList>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="GrateWizard">
|
||||
<AdminFormSection title="grateWizard">
|
||||
<AdminFieldText v-model="data.gratewizard.url" label="URL de l'application" />
|
||||
</AdminFormSection>
|
||||
</template>
|
||||
@@ -103,14 +103,14 @@ async function save() {
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
+175
-12
@@ -6,14 +6,22 @@
|
||||
</div>
|
||||
|
||||
<template v-if="config">
|
||||
<AdminFormSection title="Métadonnées des chansons" open>
|
||||
<AdminFormSection title="Morceaux" open>
|
||||
<div
|
||||
v-for="(song, i) in config.songs"
|
||||
:key="i"
|
||||
:key="song.id"
|
||||
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>
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-2">
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
v-model="song.title"
|
||||
class="admin-input"
|
||||
@@ -24,8 +32,33 @@
|
||||
class="admin-input"
|
||||
placeholder="/audio/fichier.mp3"
|
||||
/>
|
||||
<input
|
||||
v-model="song.id"
|
||||
class="admin-input font-mono text-xs"
|
||||
placeholder="identifiant-slug"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
v-model="song.lyrics"
|
||||
class="admin-input lyrics-textarea"
|
||||
placeholder="Paroles..."
|
||||
rows="2"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -37,14 +70,80 @@ definePageMeta({
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: config } = await useFetch('/api/content/config')
|
||||
const { data: config } = await useFetch<any>('/api/content/config')
|
||||
const saving = 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() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
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', {
|
||||
method: 'PUT',
|
||||
body: config.value,
|
||||
@@ -61,32 +160,96 @@ async function save() {
|
||||
<style scoped>
|
||||
.song-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid hsl(20 8% 10%);
|
||||
padding: 0.75rem 0.5rem;
|
||||
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 {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 40%);
|
||||
color: hsl(var(--color-text-muted));
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
border: 1px solid hsl(var(--color-surface-light));
|
||||
background: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.lyrics-textarea {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<NuxtLink
|
||||
v-if="prevChapter"
|
||||
:to="`/lire/${prevChapter.stem}`"
|
||||
:to="`/economique/modele-eco/${prevChapter.stem?.split('/').pop()}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<div class="i-lucide-arrow-left h-4 w-4" />
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<NuxtLink
|
||||
v-if="nextChapter"
|
||||
:to="`/lire/${nextChapter.stem}`"
|
||||
:to="`/economique/modele-eco/${nextChapter.stem?.split('/').pop()}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<span class="text-sm">{{ nextChapter.title }}</span>
|
||||
@@ -43,6 +43,11 @@ definePageMeta({
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
// Scroll to top when navigating between chapters (component reused by Vue Router)
|
||||
watch(() => slug, () => {
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
// Initialize guided mode
|
||||
useGuidedMode()
|
||||
|
||||
@@ -54,8 +59,11 @@ if (!chapter.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: chapter.value?.title,
|
||||
useSeoPage({
|
||||
title: chapter.value?.title ?? slug,
|
||||
description: chapter.value?.description as string ?? `Chapitre « ${chapter.value?.title} » — Une économie du don, enfin concevable.`,
|
||||
image: '/images/Couv-Economie-du-don.jpg',
|
||||
type: 'article',
|
||||
})
|
||||
|
||||
// Get adjacent chapters for navigation
|
||||
@@ -64,7 +72,7 @@ const { data: allChapters } = await useAsyncData('book-nav', () =>
|
||||
)
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
allChapters.value?.findIndex(c => c.stem === slug) ?? -1,
|
||||
allChapters.value?.findIndex(c => c.stem?.split('/').pop() === slug) ?? -1,
|
||||
)
|
||||
|
||||
const prevChapter = computed(() => {
|
||||
@@ -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. {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">♪</text>
|
||||
<text x="195" y="88" fill="currentColor" opacity="0.25" font-size="16">♫</text>
|
||||
<text x="60" y="65" fill="currentColor" opacity="0.2" font-size="14">♩</text>
|
||||
<text x="180" y="72" fill="currentColor" opacity="0.2" font-size="20">♪</text>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok DJ: character with headphones behind a turntable -->
|
||||
<svg class="shadok-dj" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<!-- Body -->
|
||||
<ellipse cx="130" cy="155" rx="42" ry="50" fill="currentColor" opacity="0.85"/>
|
||||
<!-- Head -->
|
||||
<circle cx="130" cy="88" r="26" fill="currentColor" opacity="0.8"/>
|
||||
<!-- Headphones band -->
|
||||
<path d="M104 78 Q130 55 156 78" stroke="currentColor" stroke-width="4" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<!-- Headphone ear pads -->
|
||||
<ellipse cx="102" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
|
||||
<ellipse cx="158" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Eyes (cool, half-lidded) -->
|
||||
<ellipse cx="120" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
|
||||
<ellipse cx="140" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
|
||||
<circle cx="121" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="141" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
|
||||
<!-- Mouth (grin) -->
|
||||
<path d="M122 98 Q130 104 138 98" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- Arms reaching to turntable -->
|
||||
<line x1="90" y1="150" x2="55" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="170" y1="150" x2="205" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Turntable body -->
|
||||
<rect x="30" y="200" width="200" height="18" rx="4" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Turntable platter -->
|
||||
<ellipse cx="130" cy="200" rx="55" ry="15" fill="currentColor" opacity="0.25"/>
|
||||
<ellipse cx="130" cy="200" rx="55" ry="15" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.35"/>
|
||||
<!-- Record center -->
|
||||
<circle cx="130" cy="200" r="5" fill="currentColor" opacity="0.4"/>
|
||||
<!-- Tone arm -->
|
||||
<line x1="195" y1="188" x2="150" y2="195" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="195" cy="188" r="3" fill="currentColor" opacity="0.35"/>
|
||||
<!-- Legs -->
|
||||
<line x1="115" y1="202" x2="105" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<line x1="145" y1="202" x2="155" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
|
||||
<div class="container-content">
|
||||
<!-- Hero section with book cover -->
|
||||
<div class="mb-12 grid items-center gap-8 md:grid-cols-2">
|
||||
<div class="book-cover-wrapper">
|
||||
<div class="book-cover-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>
|
||||
@@ -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">♪</text>
|
||||
<text x="145" y="68" fill="currentColor" opacity="0.2" font-size="11">♫</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>
|
||||
+119
-54
@@ -1,70 +1,115 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content max-w-3xl mx-auto">
|
||||
<!-- Back link -->
|
||||
<UiScrollReveal>
|
||||
<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" />
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</UiScrollReveal>
|
||||
<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>
|
||||
|
||||
<!-- Header -->
|
||||
<UiScrollReveal>
|
||||
<div class="text-center mb-12">
|
||||
<span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.kicker }}
|
||||
</span>
|
||||
<h1 class="page-title font-display font-bold text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
<div class="container-content max-w-3xl mx-auto">
|
||||
<!-- Back link -->
|
||||
<UiScrollReveal>
|
||||
<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" />
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Explanation cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2 mb-12">
|
||||
<UiScrollReveal
|
||||
v-for="(feature, i) in content?.features"
|
||||
:key="i"
|
||||
:delay="(i + 1) * 100"
|
||||
>
|
||||
<div class="gw-feature-card">
|
||||
<div :class="`i-lucide-${feature.icon}`" class="h-6 w-6 text-amber-400 mb-3" />
|
||||
<h3 class="font-display text-lg font-semibold text-white mb-2">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
{{ feature.description }}
|
||||
<!-- Header -->
|
||||
<UiScrollReveal>
|
||||
<div class="text-center mb-12">
|
||||
<span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.kicker }}
|
||||
</span>
|
||||
<h1 class="page-title font-display font-bold text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-white/40 mb-4">
|
||||
{{ content?.cta.note }}
|
||||
</p>
|
||||
<UiBaseButton @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-5 w-5" />
|
||||
{{ content?.cta.label }}
|
||||
</UiBaseButton>
|
||||
<!-- Explanation cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2 mb-12">
|
||||
<UiScrollReveal
|
||||
v-for="(feature, i) in content?.features"
|
||||
:key="i"
|
||||
:delay="(i + 1) * 100"
|
||||
>
|
||||
<div class="gw-feature-card">
|
||||
<div :class="`i-lucide-${feature.icon}`" class="h-6 w-6 text-amber-400 mb-3" />
|
||||
<h3 class="font-display text-lg font-semibold text-white mb-2">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- CTA -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-white/40 mb-4">
|
||||
{{ content?.cta.note }}
|
||||
</p>
|
||||
<UiBaseButton :href="url" target="_blank" @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-5 w-5" />
|
||||
{{ content?.cta.label }}
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('gratewizard')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'GrateWizard — Coefficients relatifs',
|
||||
useSeoPage({
|
||||
title: content.value?.meta?.title ?? 'grateWizard — Coefficients relatifs',
|
||||
description: 'Calculateur de coefficients relatifs pour l\'économie du don et la monnaie libre (DU/June).',
|
||||
})
|
||||
|
||||
const { launch } = useGrateWizard()
|
||||
const { url, launch } = useGrateWizard()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -75,14 +120,14 @@ const { launch } = useGrateWizard()
|
||||
.gw-feature-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 8% / 0.5);
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
background: hsl(var(--color-surface) / 0.5);
|
||||
transition: border-color 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.gw-feature-card:hover {
|
||||
border-color: hsl(40 80% 50% / 0.25);
|
||||
background: hsl(20 8% 10% / 0.5);
|
||||
background: hsl(var(--color-surface-light) / 0.5);
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -92,4 +137,24 @@ code {
|
||||
border-radius: 0.25em;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
|
||||
.shadok-alchemist {
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 10%;
|
||||
width: clamp(120px, 16vw, 230px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-float-alchemist 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-float-alchemist {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-10px) rotate(1deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-alchemist { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<HomeHeroSection />
|
||||
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeGrateWizardTeaser />
|
||||
<HomeLanding @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeMessages />
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Accueil',
|
||||
useSeoPage({
|
||||
title: 'Le Librodrome — autonomie numérique, économique, citoyenne',
|
||||
description: 'Construire une autonomie collective à l\'échelle des bassins de vie. Un livre, des chansons et des outils pour l\'émancipation.',
|
||||
})
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
|
||||
@@ -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
@@ -2,20 +2,33 @@
|
||||
<div class="section-padding">
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1>
|
||||
<p class="text-white/50 mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
|
||||
<p class="page-subtitle mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-4">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
<!-- En-tête auteur -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="msg-author font-semibold text-sm">{{ msg.author }}</span>
|
||||
<span class="type-pill">{{ typeLabel(msg.type) }}</span>
|
||||
<span class="msg-date text-xs ml-auto">{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
<!-- Texte du message -->
|
||||
<p class="msg-text text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<!-- Réponse -->
|
||||
<div v-if="msg.reply?.text" class="reply-thread">
|
||||
<div class="reply-connector" aria-hidden="true" />
|
||||
<div class="reply-block">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<div class="i-lucide-corner-down-right h-3 w-3 reply-icon" />
|
||||
<span class="reply-author text-xs font-semibold">Le Librodrome</span>
|
||||
</div>
|
||||
<p class="reply-text text-sm leading-relaxed italic">{{ msg.reply.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-white/40 py-12">Aucun message pour l'instant.</p>
|
||||
<p v-else class="text-center page-subtitle py-12">Aucun message pour l'instant.</p>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<NuxtLink to="/" class="btn-ghost text-sm">
|
||||
@@ -28,12 +41,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Messages',
|
||||
useSeoPage({
|
||||
title: 'Messages — Le Librodrome',
|
||||
description: 'Questions, retours et réactions des lecteurs et visiteurs du Librodrome.',
|
||||
})
|
||||
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reaction: 'Réaction',
|
||||
question: 'Question',
|
||||
suggestion: 'Suggestion',
|
||||
retour: 'Retour',
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
return TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
@@ -41,10 +66,59 @@ function formatDate(iso: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-subtitle { color: hsl(var(--color-text) / 0.5); }
|
||||
.msg-author { color: hsl(var(--color-text) / 0.75); }
|
||||
.msg-date { color: hsl(var(--color-text) / 0.38); }
|
||||
.msg-text { color: hsl(var(--color-text) / 0.78); white-space: pre-line; }
|
||||
.reply-icon { color: hsl(var(--color-primary) / 0.5); }
|
||||
.reply-author { color: hsl(var(--color-primary) / 0.8); }
|
||||
.reply-text { color: hsl(var(--color-text) / 0.62); }
|
||||
|
||||
.message-card {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
}
|
||||
|
||||
.reply-thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.reply-connector {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: -0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--color-primary) / 0.35),
|
||||
hsl(var(--color-primary) / 0.15)
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.reply-block {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.25);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.6rem 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"></></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>
|
||||
@@ -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' }
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -119,20 +119,13 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
function prevSong(): Song | 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
|
||||
if (prevIdx < 0) {
|
||||
if (repeatMode.value === 'all') {
|
||||
prevIdx = playlist.value.length - 1
|
||||
}
|
||||
else {
|
||||
currentTime.value = 0
|
||||
return currentSong.value
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export interface ChapterPageLink {
|
||||
chapterSlug: string
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
title: string
|
||||
author: string
|
||||
description: string
|
||||
coverImage?: string
|
||||
pdfFile?: string
|
||||
chapters: ChapterMeta[]
|
||||
songs: import('./song').Song[]
|
||||
chapterSongs: ChapterSongLink[]
|
||||
chapterPages: ChapterPageLink[]
|
||||
defaultPlaylistOrder: string[]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineContentConfig({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
order: z.number(),
|
||||
page: z.number().optional(),
|
||||
readingTime: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user