Merge branch 'develop'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-02-27 19:40:43 +01:00
171 changed files with 5189 additions and 2315 deletions

3
.gitignore vendored
View File

@@ -18,6 +18,9 @@ logs
.fleet
.idea
# Sources originales (PDF, JPG — pas servies par l'appli)
sources/
# Local env files
.env
.env.*

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# Librodrome
Site vitrine du projet Le Librodrome — livre + chansons sur l'économie du don.
## Stack
- **Nuxt 4** (Vue 3, TypeScript, Nitro)
- **Modules** : Nuxt Content, Pinia, UnoCSS, VueUse, Nuxt Image
- **Icônes** : Lucide + Phosphor (via @iconify-json)
- **Package manager** : pnpm
- **Déploiement** : Docker + Traefik, CI via Woodpecker
## Structure
```
app/
pages/ # Routes : index, lire/, ecouter/, autonomie, evenement, gratewizard, a-propos, admin/
components/ # admin, book, content, home, layout, player, song, ui
composables/ # useAudioPlayer, useBookData, useGrateWizard, usePlaylist, usePageContent, useSiteContent...
assets/css/ # main.css (UnoCSS)
data/
pages/ # Contenu YAML par page (home, lire, ecouter, autonomie, evenement...)
site.yml # Config globale du site
server/
api/ # Endpoints API (admin, health)
middleware/ # Auth middleware
docker/
Dockerfile # Build multi-stage (dev + prod)
docker-compose.yml # Production (Traefik)
docker-compose.dev.yml # Dev Docker
```
## Ports dev (CRITIQUE)
Chaque projet a un port fixe pour éviter les conflits Nuxt auto-increment :
| Projet | Port | Config |
|--------|------|--------|
| **librodrome** | **3000** | `nuxt.config.ts``devServer.port: 3000` |
| **GrateWizard** | **3001** | `package.json``next dev --port 3001` |
| **SejeteralO frontend** | **3009** | `frontend/nuxt.config.ts``devServer.port: 3009` |
| **SejeteralO backend** | **8000** | Makefile → `uvicorn --port 8000` |
Script de gestion : `/home/yvv/Documents/PROD/DEV/dev-ports.sh` (status/kill/clean/start)
**Ne jamais changer ces ports.** Si un port est occupé, tuer le process parasite plutôt que laisser Nuxt auto-incrémenter.
## Intégration GrateWizard
- URL dev configurée dans `app/app.config.ts``localhost:3001`
- URL prod : `https://gratewizard.ml`
- Ouverture en popup via `composables/useGrateWizard.ts`
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
## Contenu
Le contenu des pages est dans `data/pages/*.yml` et chargé via `composables/usePageContent.ts`. Le contenu riche (articles) utilise Nuxt Content avec des fichiers Markdown.
## Commandes
```bash
pnpm dev # Dev server sur :3000
pnpm build # Build production
pnpm generate # Génération statique
```
## Conventions
- Langue du site : français
- Commits en français, style concis
- CSS via UnoCSS (utility-first), pas de framework CSS externe
- Composants Vue SFC avec `<script setup lang="ts">`

View File

@@ -1,26 +1,27 @@
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: 'Autonomie', to: '/autonomie' },
{ label: 'Modèle éco', to: '/modele-eco' },
{ label: 'En musique', to: '/en-musique' },
{ label: 'Évènement', to: '/evenement' },
{ label: 'À propos', to: '/a-propos' },
],
},
footer: {
credits: '© 2026 Le Librodrome — 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.ml',
popup: {
width: 420,
height: 720,

View File

@@ -14,15 +14,15 @@
</template>
<script setup lang="ts">
const { data: site } = await useSiteContent()
const paletteStore = usePaletteStore()
onMounted(() => paletteStore.applyToDOM())
useHead({
titleTemplate: (title) => {
const siteName = site.value?.identity.name ?? 'Le Librodrome'
return title ? `${title}${siteName}` : siteName
return title ? `${title} — Le Librodrome` : 'Le librodrome'
},
meta: [
{ name: 'description', content: site.value?.identity.description ?? '' },
{ name: 'description', content: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.' },
],
})
</script>

View File

@@ -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);
}
}

View File

@@ -2,11 +2,11 @@
/* This file provides fallback and utility classes */
.font-display {
font-family: 'Syne', system-ui, sans-serif;
font-family: 'Outfit', system-ui, sans-serif;
}
.font-sans {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-family: 'Inter', system-ui, sans-serif;
}
.font-mono {

View File

@@ -3,20 +3,20 @@
@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: 20 10% 7%;
--color-surface: 20 10% 12%;
--color-surface-light: 20 8% 17%;
--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: 'Outfit', sans-serif;
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
@@ -43,9 +43,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 +85,113 @@ body {
background: hsl(var(--color-text) / 0.25);
}
/* ═══ 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\/70 { color: hsl(var(--color-text) / 0.78) !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; }
/* 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 {

View File

@@ -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 {

View File

@@ -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,22 +61,38 @@ 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>
@@ -64,14 +103,23 @@ const renderedHtml = computed(() => {
overflow: hidden;
}
.md-tabs {
.md-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: hsl(20 8% 6%);
border-bottom: 1px solid hsl(20 8% 14%);
}
.md-tabs {
display: flex;
}
.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%);
@@ -85,6 +133,39 @@ const renderedHtml = computed(() => {
background: hsl(20 8% 10%);
}
.md-fullscreen {
padding: 0.5rem 0.75rem;
color: hsl(20 8% 40%);
transition: color 0.2s;
}
.md-fullscreen:hover,
.md-fullscreen--active { color: white; }
.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(20 8% 14%);
}
.md-body--fullscreen {
position: fixed;
inset: 0;
z-index: 50;
background: hsl(20 8% 4%);
}
.md-body--fullscreen .md-textarea,
.md-body--fullscreen .md-preview {
height: 100vh;
}
.md-textarea {
width: 100%;
padding: 1rem;
@@ -95,7 +176,8 @@ const renderedHtml = computed(() => {
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;
min-height: 24rem;
max-height: 70vh;
overflow-y: auto;
background: hsl(20 8% 4%);
}
</style>

View File

@@ -27,17 +27,17 @@
<div class="i-lucide-home h-4 w-4" />
Accueil
</NuxtLink>
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active">
<NuxtLink to="/admin/pages/modele-eco" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-book-open h-4 w-4" />
Lire
Modèle éco
</NuxtLink>
<NuxtLink to="/admin/pages/ecouter" class="sidebar-link" active-class="sidebar-link--active">
<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>

View File

@@ -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" />
<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>
@@ -177,90 +153,97 @@ 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) => {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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 +252,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 +273,36 @@ 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) {
_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 +310,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 +320,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 +335,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 +366,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()
@@ -383,11 +377,7 @@ function onTouchEnd(e: TouchEvent) {
// ── Lifecycle ──
watch(isOpen, async (open) => {
if (open) {
phase.value = 'intro'
chapterIdx.value = 0
currentPage.value = 0
showSommaire.value = false
contentLoaded.value = false
await initBookData()
await nextTick()
overlayRef.value?.focus()
@@ -396,8 +386,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 +399,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' : ''
})
@@ -442,7 +453,7 @@ onUnmounted(() => {
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,33 +530,10 @@ 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;
@@ -565,135 +553,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 +564,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,18 +581,24 @@ 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(20 8% 45%);
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;
@@ -844,12 +711,11 @@ onUnmounted(() => {
position: relative;
flex: 1;
width: 100%;
overflow: hidden;
overflow: hidden auto;
border-radius: 0.75rem;
background: hsl(20 8% 5% / 0.4);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(20 8% 14% / 0.4);
}
.reader-columns {
@@ -860,6 +726,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 +763,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 {
@@ -910,7 +823,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 +832,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(20 8% 45%);
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;

View File

@@ -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>

View File

@@ -57,6 +57,6 @@ 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);
}
</style>

View File

@@ -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="`/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"
>

View File

@@ -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,8 +126,8 @@ 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 {
@@ -89,4 +140,41 @@ const { data: content } = await usePageContent('home')
.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>

View File

@@ -4,7 +4,24 @@
<div class="grid items-center gap-12 md:grid-cols-2">
<!-- Book cover -->
<UiScrollReveal>
<div class="book-cover-wrapper">
<div class="book-cover-wrapper relative">
<!-- Shadok pumper -->
<svg class="shadok-pumper" viewBox="0 0 200 240" fill="none" aria-hidden="true">
<ellipse cx="100" cy="130" rx="55" ry="65" fill="currentColor" opacity="0.9"/>
<ellipse cx="100" cy="60" rx="30" ry="28" fill="currentColor" opacity="0.85"/>
<circle cx="88" cy="54" r="6" fill="currentColor" opacity="0.2"/>
<circle cx="112" cy="54" r="6" fill="currentColor" opacity="0.2"/>
<circle cx="90" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
<circle cx="114" cy="53" r="2.5" fill="currentColor" opacity="0.5"/>
<polygon points="100,68 115,78 85,78" fill="currentColor" opacity="0.6"/>
<line x1="80" y1="192" x2="70" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
<line x1="120" y1="192" x2="130" y2="230" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
<line x1="70" y1="230" x2="55" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<line x1="130" y1="230" x2="145" y2="232" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
<line x1="155" y1="110" x2="190" y2="90" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<line x1="190" y1="90" x2="190" y2="120" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.6"/>
<rect x="180" y="118" width="18" height="40" rx="3" fill="currentColor" opacity="0.4"/>
</svg>
<div class="book-cover-3d">
<img
:src="content?.book.coverImage"
@@ -68,10 +85,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;
@@ -80,8 +97,8 @@ 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 {
@@ -94,4 +111,25 @@ const { data: content } = await usePageContent('home')
.heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem);
}
.shadok-pumper {
position: absolute;
right: 3%;
bottom: 8%;
width: clamp(90px, 12vw, 180px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float 10s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (max-width: 768px) {
.shadok-pumper { display: none; }
}
</style>

View File

@@ -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>

View File

@@ -2,8 +2,20 @@
<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">
<div class="gw-card relative overflow-hidden">
<!-- Shadok blob -->
<svg class="shadok-blob" viewBox="0 0 200 180" fill="none" aria-hidden="true">
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" fill="currentColor" opacity="0.12"/>
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
<circle cx="100" cy="80" r="8" fill="currentColor" opacity="0.08"/>
<circle cx="120" cy="110" r="6" fill="currentColor" opacity="0.06"/>
<circle cx="80" cy="105" r="5" fill="currentColor" opacity="0.07"/>
<circle cx="95" cy="72" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="108" cy="70" r="3" fill="currentColor" opacity="0.3"/>
<circle cx="96" cy="71" r="1.2" fill="currentColor" opacity="0.5"/>
<circle cx="109" cy="69" r="1.2" fill="currentColor" opacity="0.5"/>
</svg>
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8 relative z-1">
<!-- Icon -->
<div class="gw-icon-wrapper">
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
@@ -24,7 +36,7 @@
<!-- CTAs -->
<div class="shrink-0 flex flex-col gap-2">
<UiBaseButton @click="launch">
<UiBaseButton :href="url" target="_blank" @click="launch">
<div class="i-lucide-external-link mr-2 h-4 w-4" />
{{ content?.grateWizardTeaser.cta.launch }}
</UiBaseButton>
@@ -41,7 +53,7 @@
</template>
<script setup lang="ts">
const { launch } = useGrateWizard()
const { url, launch } = useGrateWizard()
const { data: content } = await usePageContent('home')
</script>
@@ -75,4 +87,24 @@ const { data: content } = await usePageContent('home')
border: 1px solid hsl(40 80% 50% / 0.15);
flex-shrink: 0;
}
.shadok-blob {
position: absolute;
right: -2%;
top: -20%;
width: clamp(120px, 16vw, 220px);
opacity: 0.35;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-drift 12s ease-in-out infinite;
}
@keyframes shadok-drift {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(3deg); }
}
@media (max-width: 768px) {
.shadok-blob { display: none; }
}
</style>

View File

@@ -4,6 +4,44 @@
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
<!-- Shadok bird decoration -->
<svg class="shadok-bird" viewBox="0 0 180 260" fill="none" aria-hidden="true">
<ellipse cx="90" cy="100" rx="45" ry="40" fill="currentColor" opacity="0.85"/>
<circle cx="130" cy="60" r="22" fill="currentColor" opacity="0.8"/>
<path d="M110 85 Q125 70 128 63" stroke="currentColor" stroke-width="8" stroke-linecap="round" opacity="0.7" fill="none"/>
<circle cx="136" cy="55" r="5" fill="currentColor" opacity="0.3"/>
<circle cx="137" cy="54" r="2" fill="currentColor" opacity="0.6"/>
<polygon points="150,58 175,50 152,65" fill="currentColor" opacity="0.6"/>
<line x1="75" y1="138" x2="60" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="105" y1="138" x2="115" y2="230" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<circle cx="66" cy="190" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="111" cy="190" r="4" fill="currentColor" opacity="0.4"/>
<path d="M60 230 L45 233 M60 230 L55 236 M60 230 L65 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M115 230 L100 233 M115 230 L110 236 M115 230 L120 235" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M48 95 Q20 80 15 65" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.5" fill="none"/>
<path d="M48 100 Q22 92 10 85" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.4" fill="none"/>
<path d="M48 105 Q25 102 12 100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3" fill="none"/>
</svg>
<!-- Shadok boulanger: character with oven and bread -->
<svg class="shadok-boulanger" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
<ellipse cx="120" cy="68" rx="18" ry="22" fill="currentColor" opacity="0.35"/>
<rect x="105" y="78" width="30" height="5" rx="1" fill="currentColor" opacity="0.4"/>
<path d="M110 88 Q114 84 118 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M124 88 Q128 84 132 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M112 102 Q120 108 128 102" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
<line x1="160" y1="145" x2="190" y2="135" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<rect x="185" y="125" width="40" height="10" rx="5" fill="currentColor" opacity="0.4" transform="rotate(-15 205 130)"/>
<line x1="80" y1="148" x2="55" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="105" y1="200" x2="95" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="200" x2="145" y2="258" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<rect x="30" y="180" width="50" height="40" rx="4" fill="currentColor" opacity="0.25"/>
<rect x="35" y="185" width="40" height="20" rx="2" fill="currentColor" opacity="0.15"/>
<ellipse cx="55" cy="195" rx="12" ry="6" fill="currentColor" opacity="0.12"/>
</svg>
<!-- Content -->
<div class="container-content relative z-10 px-4">
<div class="mx-auto max-w-3xl text-center">
@@ -54,4 +92,43 @@ const { data: content } = await usePageContent('home')
.hero-title {
font-size: clamp(2.25rem, 7vw, 4rem);
}
.shadok-bird {
position: absolute;
right: 4%;
top: 12%;
width: clamp(110px, 15vw, 220px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float 8s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.shadok-boulanger {
position: absolute;
left: 3%;
bottom: 8%;
width: clamp(100px, 13vw, 190px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-boulanger 9s ease-in-out infinite;
z-index: 1;
}
@keyframes shadok-float-boulanger {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@media (max-width: 768px) {
.shadok-bird { display: none; }
.shadok-boulanger { display: none; }
}
</style>

View File

@@ -106,8 +106,8 @@ function formatDate(iso: string) {
<style scoped>
.message-form-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.5rem;
}
@@ -116,25 +116,25 @@ function formatDate(iso: string) {
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;
border: 1px solid hsl(var(--color-text) / 0.12);
background: hsl(var(--color-bg));
color: hsl(var(--color-text));
font-size: 0.875rem;
transition: border-color 0.2s;
}
.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);
border-color: hsl(var(--color-primary) / 0.5);
}
.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: 1rem 1.25rem;
}

View File

@@ -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>

View File

@@ -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)"
>

View File

@@ -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>

View File

@@ -1,10 +1,17 @@
<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">
<svg class="logo-icon" viewBox="0 0 64 80" fill="none" aria-hidden="true">
<path d="M38 8 C28 6 18 10 18 20 C18 28 26 32 34 34 C42 36 48 40 48 48 C48 52 46 55 42 57 L44 40 C44 36 40 32 34 30 C28 28 22 24 22 18 C22 14 24 11 28 10Z" fill="currentColor" opacity="0.9"/>
<path d="M26 72 C36 74 46 70 46 60 C46 52 38 48 30 46 C22 44 16 40 16 32 C16 28 18 25 22 23 L20 40 C20 44 24 48 30 50 C36 52 42 56 42 62 C42 66 40 69 36 70Z" fill="currentColor" opacity="0.9"/>
<path d="M20 16 C20 8 28 4 36 6 C42 8 46 14 44 20" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M44 64 C44 72 36 76 28 74 C22 72 18 66 20 60" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M36 4 Q42 2 46 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M28 76 Q22 78 18 74" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.5"/>
</svg>
<span class="logo-text">{{ site?.identity.name }}</span>
</NuxtLink>
<!-- Desktop navigation -->
@@ -14,10 +21,11 @@
:key="item.to"
:to="item.to"
class="btn-ghost text-sm"
active-class="text-white! bg-white/5"
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
>
{{ item.label }}
</NuxtLink>
<UiPaletteSelector />
</nav>
<!-- Mobile menu button -->
@@ -39,3 +47,27 @@
const { data: site } = await useSiteContent()
const isMobileMenuOpen = ref(false)
</script>
<style scoped>
.logo-link {
transition: opacity 0.2s;
}
.logo-link:hover {
opacity: 0.8;
}
.logo-icon {
width: 1.6rem;
height: 2rem;
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.logo-text {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.15rem;
letter-spacing: 0.02em;
color: hsl(var(--color-primary));
}
</style>

View File

@@ -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,322 @@ 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;
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%);
}
.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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,261 @@
<template>
<div class="settings-selector" ref="selectorRef">
<button
class="settings-trigger"
aria-label="Réglages d'affichage"
@click="isOpen = !isOpen"
>
<div class="i-lucide-settings h-5 w-5" />
</button>
<Transition name="settings-dropdown">
<div v-if="isOpen" class="settings-dropdown">
<h4 class="settings-title">Affichage</h4>
<!-- Palette grid : 4 saisons -->
<div class="settings-section">
<span class="settings-label">Ambiance</span>
<div class="settings-palette-grid">
<button
v-for="name in paletteNames"
:key="name"
class="settings-palette-btn"
:class="{
'settings-palette-btn--active': paletteStore.currentPalette === name,
'settings-palette-btn--light': paletteStore.palettes[name].isLight,
}"
:title="paletteStore.palettes[name].label"
@click="paletteStore.setPalette(name)"
>
<span class="settings-palette-preview">
<span class="settings-palette-dot" :style="{ background: `hsl(${paletteStore.palettes[name].primary})` }" />
<span class="settings-palette-dot" :style="{ background: `hsl(${paletteStore.palettes[name].accent})` }" />
</span>
<span class="settings-palette-info">
<span class="settings-palette-name">{{ paletteStore.palettes[name].label }}</span>
<span class="settings-palette-mode">{{ paletteStore.palettes[name].isLight ? 'Clair' : 'Sombre' }}</span>
</span>
</button>
</div>
</div>
<!-- Font size -->
<div class="settings-section">
<span class="settings-label">Taille texte</span>
<div class="settings-toggle-group">
<button
v-for="size in fontSizes"
:key="size.value"
class="settings-toggle"
:class="{ 'settings-toggle--active': currentFontSize === size.value }"
@click="setFontSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import type { PaletteName } from '~/stores/palette'
const paletteStore = usePaletteStore()
const selectorRef = ref<HTMLElement>()
const isOpen = ref(false)
const paletteNames: PaletteName[] = ['automne', 'hiver', 'printemps', 'ete']
const currentFontSize = ref(
(import.meta.client && localStorage.getItem('fontSize')) || 'normal',
)
const fontSizes = [
{ label: 'A-', value: 'small' },
{ label: 'A', value: 'normal' },
{ label: 'A+', value: 'large' },
]
function setFontSize(size: string) {
currentFontSize.value = size
if (import.meta.client) {
localStorage.setItem('fontSize', size)
const root = document.documentElement
const map: Record<string, string> = { small: '14px', normal: '16px', large: '18px' }
root.style.fontSize = map[size] || '16px'
}
}
// Apply font size on mount
onMounted(() => {
const saved = localStorage.getItem('fontSize')
if (saved) setFontSize(saved)
})
onClickOutside(selectorRef, () => { isOpen.value = false })
</script>
<style scoped>
.settings-selector {
position: relative;
}
.settings-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
color: hsl(var(--color-text) / 0.7);
transition: all 0.2s;
}
.settings-trigger:hover {
color: hsl(var(--color-text));
background: hsl(var(--color-text) / 0.1);
}
.settings-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: 260px;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-surface));
backdrop-filter: blur(16px);
border: 1px solid hsl(var(--color-text) / 0.08);
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.3);
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 50;
}
.settings-title {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: hsl(var(--color-text) / 0.4);
margin: 0;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.settings-label {
font-size: 0.7rem;
font-weight: 600;
color: hsl(var(--color-text) / 0.5);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-toggle-group {
display: flex;
gap: 0.25rem;
background: hsl(var(--color-text) / 0.04);
border-radius: 0.5rem;
padding: 0.125rem;
}
.settings-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-text) / 0.5);
transition: all 0.15s;
}
.settings-toggle:hover {
color: hsl(var(--color-text) / 0.8);
}
.settings-toggle--active {
background: hsl(var(--color-primary));
color: white;
box-shadow: 0 1px 4px hsl(var(--color-primary) / 0.3);
}
.settings-palette-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.settings-palette-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
transition: all 0.2s;
color: hsl(var(--color-text) / 0.6);
background: hsl(var(--color-text) / 0.02);
}
.settings-palette-btn:hover {
background: hsl(var(--color-text) / 0.06);
color: hsl(var(--color-text) / 0.9);
}
.settings-palette-btn--active {
background: hsl(var(--color-text) / 0.1);
color: hsl(var(--color-text));
box-shadow: inset 0 0 0 1.5px hsl(var(--color-primary) / 0.5);
}
.settings-palette-preview {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
}
.settings-palette-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
box-shadow: 0 1px 3px hsl(0 0% 0% / 0.25);
}
.settings-palette-info {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.settings-palette-name {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.settings-palette-mode {
font-size: 0.6rem;
opacity: 0.5;
line-height: 1.2;
}
.settings-dropdown-enter-active {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.settings-dropdown-leave-active {
transition: all 0.15s ease;
}
.settings-dropdown-enter-from,
.settings-dropdown-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
</style>

View File

@@ -124,13 +124,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)
}
}

View File

@@ -1,4 +1,3 @@
import yaml from 'yaml'
import type { Song } from '~/types/song'
import type { ChapterSongLink, BookConfig } from '~/types/book'
@@ -7,8 +6,7 @@ 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,
@@ -70,6 +68,14 @@ 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 getBookMeta() {
if (!config.value) return null
return {
@@ -89,6 +95,7 @@ export function useBookData() {
getChapterSongs,
getPrimarySong,
getChapterSongLinks,
getChapterForSong,
getPlaylistOrder,
getBookMeta,
}

View File

@@ -1,16 +1,17 @@
export function useGrateWizard() {
const appConfig = useAppConfig()
const { url, popup } = 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 } }
function launch(e?: Event) {
const left = Math.round((window.screen.width - popup.width) / 2)
const top = Math.round((window.screen.height - popup.height) / 2)
window.open(
const win = window.open(
url,
'GrateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
'grateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=yes,resizable=yes`,
)
if (win) e?.preventDefault()
}
return { launch }
return { url, launch }
}

View File

@@ -0,0 +1,41 @@
export function useKeyboardShortcuts() {
const store = usePlayerStore()
const { togglePlayPause, playNext, playPrev } = useAudioPlayer()
function isInputFocused(): boolean {
const el = document.activeElement
if (!el) return false
const tag = el.tagName.toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
if ((el as HTMLElement).isContentEditable) return true
return false
}
function onKeyDown(e: KeyboardEvent) {
if (isInputFocused()) return
if (!store.currentSong) return
switch (e.code) {
case 'Space':
e.preventDefault()
togglePlayPause()
break
case 'ArrowRight':
e.preventDefault()
playNext()
break
case 'ArrowLeft':
e.preventDefault()
playPrev()
break
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
}

View File

@@ -1,180 +0,0 @@
book:
title: "Une économie du don — enfin concevable"
author: "Yvv"
description: "Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don."
coverImage: "/images/book-cover.jpg"
license: "CC-BY-NC"
isbn: "979-1-042-45206-3"
songs:
- id: chanson-01
title: "1. Ce livre est une façon"
artist: Yvv
file: /audio/chanson-01.mp3
duration: 718
lyrics: ""
tags: [introduction, livre, don]
- id: chanson-02
title: "2. Un don qui se mesure"
artist: Yvv
file: /audio/chanson-02.mp3
duration: 589
lyrics: ""
tags: [don, mesure, valeur]
- id: chanson-03
title: "3. Les asymétries"
artist: Yvv
file: /audio/chanson-03.mp3
duration: 727
lyrics: ""
tags: [asymétrie, communauté, philosophie]
- id: chanson-04
title: "4. Inverser les flux"
artist: Yvv
file: /audio/chanson-04.mp3
duration: 610
lyrics: ""
tags: [flux, économie, production]
- id: chanson-05
title: "5. Ainsi soit-il"
artist: Yvv
file: /audio/chanson-05.mp3
duration: 545
lyrics: ""
tags: [action, engagement, avenir]
- id: chanson-06
title: "6. La croissance, une option ?"
artist: Yvv
file: /audio/chanson-06.mp3
duration: 510
lyrics: ""
tags: [croissance, monnaie, questionnement]
- id: chanson-07
title: "7. Monnaie libre essence"
artist: Yvv
file: /audio/chanson-07.mp3
duration: 475
lyrics: ""
tags: [monnaie libre, TRM, June]
- id: chanson-08
title: "8. Des cercles qui se croisent"
artist: Yvv
file: /audio/chanson-08.mp3
duration: 496
lyrics: ""
tags: [échange, réseau, cercles]
- id: chanson-09
title: "9. Coder la liberté"
artist: Yvv
file: /audio/chanson-09.mp3
duration: 376
lyrics: ""
tags: [logiciel libre, code, liberté]
chapterSongs:
# Chapitre 1 — Introduction
- chapterSlug: introduction
songId: chanson-01
primary: true
- chapterSlug: introduction
songId: chanson-02
primary: false
# Chapitre 2 — De quel don parlons-nous ?
- chapterSlug: de-quel-don-parlons-nous
songId: chanson-03
primary: true
- chapterSlug: de-quel-don-parlons-nous
songId: chanson-01
primary: false
# Chapitre 3 — La mesure du don
- chapterSlug: la-mesure-du-don
songId: chanson-02
primary: true
- chapterSlug: la-mesure-du-don
songId: chanson-03
primary: false
# Chapitre 4 — Raison d'être d'une monnaie
- chapterSlug: raison-d-etre-d-une-monnaie
songId: chanson-06
primary: true
- chapterSlug: raison-d-etre-d-une-monnaie
songId: chanson-07
primary: false
# Chapitre 5 — La TRM
- chapterSlug: la-trm
songId: chanson-07
primary: true
- chapterSlug: la-trm
songId: chanson-06
primary: false
# Chapitre 6 — Créer une économie ?
- chapterSlug: creer-une-economie
songId: chanson-04
primary: true
- chapterSlug: creer-une-economie
songId: chanson-07
primary: false
# Chapitre 7 — Échanger
- chapterSlug: echanger
songId: chanson-08
primary: true
- chapterSlug: echanger
songId: chanson-04
primary: false
# Chapitre 8 — Relation institutionnelle
- chapterSlug: relation-institutionnelle
songId: chanson-05
primary: false
- chapterSlug: relation-institutionnelle
songId: chanson-08
primary: false
# Chapitre 9 — Autres greffes
- chapterSlug: autres-greffes
songId: chanson-04
primary: false
- chapterSlug: autres-greffes
songId: chanson-08
primary: false
# Chapitre 10 — Et maintenant ?
- chapterSlug: et-maintenant
songId: chanson-05
primary: true
- chapterSlug: et-maintenant
songId: chanson-09
primary: false
# Chapitre 11 — Annexes
- chapterSlug: annexes
songId: chanson-09
primary: true
- chapterSlug: annexes
songId: chanson-07
primary: false
defaultPlaylistOrder:
- chanson-01
- chanson-02
- chanson-03
- chanson-04
- chanson-05
- chanson-06
- chanson-07
- chanson-08
- chanson-09

View File

@@ -1,7 +1,7 @@
<template>
<div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader />
<main class="pb-[var(--player-height)]">
<main>
<slot />
</main>
<LayoutTheFooter />

View File

@@ -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;
}

View File

@@ -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>
@@ -19,3 +48,25 @@ 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>

View File

@@ -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(20 8% 50%);
margin-bottom: 0.25rem;
}
.fm-textarea:focus {
.field-input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.85rem;
}
.field-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
/* ── Song tags ── */
.song-tag {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border: 1px solid hsl(20 8% 22%);
transition: all 0.15s;
overflow: hidden;
}
.song-tag:hover {
border-color: hsl(12 76% 48% / 0.4);
}
.song-tag--active {
border-color: hsl(12 76% 48% / 0.6);
background: hsl(12 76% 48% / 0.08);
}
.song-tag--primary {
border-color: hsl(45 90% 55%);
background: hsl(45 90% 55% / 0.08);
}
.song-tag-label {
padding: 0.375rem 0.75rem;
background: none;
border: none;
color: hsl(20 8% 50%);
font-size: 0.8rem;
cursor: pointer;
transition: color 0.15s;
}
.song-tag--active .song-tag-label {
color: hsl(12 76% 68%);
}
.song-tag--primary .song-tag-label {
color: hsl(45 90% 65%);
}
.song-tag-label:hover {
color: white;
}
.song-star {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0 0.375rem 0.625rem;
background: none;
border: none;
color: hsl(20 8% 30%);
cursor: pointer;
transition: color 0.15s;
}
.song-star:hover {
color: hsl(45 90% 55%);
}
.song-star--active {
color: hsl(45 90% 55%);
}
</style>

View File

@@ -1,18 +1,67 @@
<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">Chapitres</h1>
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
</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>
<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,7 +72,87 @@ 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('')
// 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>
@@ -34,7 +163,6 @@ const { data: chapters } = await useFetch('/api/admin/chapters')
padding: 0.75rem 1rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
text-decoration: none;
transition: all 0.2s;
}
@@ -43,6 +171,25 @@ const { data: chapters } = await useFetch('/api/admin/chapters')
background: hsl(20 8% 6%);
}
.chapter-item--dragging {
opacity: 0.4;
}
.chapter-item--over {
border-top: 2px solid hsl(12 76% 48%);
}
.drag-handle {
cursor: grab;
padding: 0.25rem;
color: hsl(20 8% 35%);
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.chapter-order {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
@@ -51,9 +198,89 @@ const { data: chapters } = await useFetch('/api/admin/chapters')
width: 1.75rem;
}
.chapter-title {
.chapter-info {
flex: 1;
min-width: 0;
}
.chapter-title {
display: block;
color: white;
font-weight: 500;
text-decoration: none;
}
.chapter-title:hover {
color: hsl(12 76% 68%);
}
.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(12 76% 48% / 0.1);
color: hsl(12 76% 60%);
border: 1px solid hsl(12 76% 48% / 0.2);
}
.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(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.8rem;
}
.admin-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
.add-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 25%);
background: none;
color: hsl(20 8% 55%);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.add-btn:hover:not(:disabled) {
border-color: hsl(12 76% 48% / 0.5);
color: hsl(12 76% 68%);
}
.add-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>

View File

@@ -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 = ''
@@ -131,4 +142,18 @@ async function login() {
opacity: 0.7;
cursor: wait;
}
.dev-hint {
margin-top: 1rem;
text-align: center;
font-size: 0.75rem;
color: hsl(20 8% 40%);
}
.dev-hint code {
color: hsl(12 76% 60%);
background: hsl(20 8% 10%);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
</style>

View File

@@ -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>

View File

@@ -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,10 +160,31 @@ async function save() {
<style scoped>
.song-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem;
padding: 0.75rem 0.5rem;
border-bottom: 1px solid hsl(20 8% 10%);
transition: background 0.15s;
}
.song-row--dragging {
opacity: 0.4;
}
.song-row--over {
border-top: 2px solid hsl(12 76% 48%);
}
.drag-handle {
cursor: grab;
padding: 0.25rem;
color: hsl(20 8% 35%);
flex-shrink: 0;
margin-top: 0.25rem;
}
.drag-handle:active {
cursor: grabbing;
}
.song-num {
@@ -73,6 +193,8 @@ async function save() {
color: hsl(20 8% 40%);
width: 1.25rem;
text-align: right;
flex-shrink: 0;
margin-top: 0.375rem;
}
.admin-input {
@@ -89,4 +211,45 @@ async function save() {
outline: none;
border-color: hsl(12 76% 48% / 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(20 8% 25%);
background: none;
color: hsl(20 8% 55%);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.add-btn:hover {
border-color: hsl(12 76% 48% / 0.5);
color: hsl(12 76% 68%);
}
</style>

168
app/pages/autonomie.vue Normal file
View File

@@ -0,0 +1,168 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok jardinier: character with watering can and plant -->
<svg class="shadok-jardinier" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="110" cy="160" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="110" cy="96" r="25" fill="currentColor" opacity="0.8"/>
<!-- Straw hat -->
<ellipse cx="110" cy="78" rx="35" ry="8" fill="currentColor" opacity="0.4"/>
<path d="M85 78 Q110 60 135 78" fill="currentColor" opacity="0.35"/>
<!-- Eyes (focused, looking down at plant) -->
<circle cx="102" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="120" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="103" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="121" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Smile -->
<path d="M103 106 Q110 111 118 106" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm holding watering can -->
<line x1="70" y1="150" x2="40" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Watering can -->
<rect x="20" y="165" width="30" height="20" rx="3" fill="currentColor" opacity="0.4"/>
<line x1="20" y1="168" x2="10" y2="160" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Water drops -->
<circle cx="12" cy="165" r="1.5" fill="currentColor" opacity="0.25"/>
<circle cx="8" cy="170" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="15" cy="172" r="1.5" fill="currentColor" opacity="0.2"/>
<!-- Other arm -->
<line x1="150" y1="150" x2="170" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="95" y1="205" x2="85" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="125" y1="205" x2="135" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Plant -->
<line x1="180" y1="220" x2="180" y2="180" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<path d="M180 195 Q195 185 190 175" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 205 Q165 195 168 185" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 185 Q192 172 188 165" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Pot -->
<path d="M170 220 L175 240 L185 240 L190 220 Z" fill="currentColor" opacity="0.35"/>
</svg>
<!-- Shadok bâtisseur: character with trowel building a wall -->
<svg class="shadok-batisseur" viewBox="0 0 260 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="130" cy="150" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="130" cy="86" r="25" fill="currentColor" opacity="0.8"/>
<!-- Hard hat -->
<ellipse cx="130" cy="68" rx="28" ry="6" fill="currentColor" opacity="0.4"/>
<rect x="108" y="60" width="44" height="10" rx="3" fill="currentColor" opacity="0.35"/>
<!-- Eyes (determined) -->
<circle cx="122" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="140" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="123" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="141" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Grin -->
<path d="M123 96 Q130 101 138 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm with trowel -->
<line x1="170" y1="140" x2="210" y2="120" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Trowel -->
<polygon points="210,115 230,110 225,120 210,122" fill="currentColor" opacity="0.45"/>
<line x1="210" y1="118" x2="200" y2="125" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Other arm -->
<line x1="90" y1="145" x2="65" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="115" y1="195" x2="105" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="145" y1="195" x2="155" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Wall (bricks) -->
<rect x="40" y="200" width="50" height="16" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="45" y="183" width="40" height="16" rx="1" fill="currentColor" opacity="0.28"/>
<rect x="50" y="166" width="30" height="16" rx="1" fill="currentColor" opacity="0.25"/>
<!-- Brick lines -->
<line x1="65" y1="200" x2="65" y2="216" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="55" y1="183" x2="55" y2="199" stroke="currentColor" stroke-width="1" opacity="0.15"/>
</svg>
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-6">
<div
v-for="(extract, i) in content?.extracts"
:key="i"
class="card-surface"
>
<p class="mb-2 font-mono text-xs tracking-widest text-accent uppercase">
{{ extract.chapter }}
</p>
<blockquote class="border-l-2 border-primary/30 pl-4 text-white/70 italic leading-relaxed whitespace-pre-line">
{{ extract.text }}
</blockquote>
<div class="mt-4">
<NuxtLink
:to="`/modele-eco/${extract.chapterSlug}`"
class="inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors"
>
Lire le chapitre
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('autonomie')
useHead({
title: content.value?.meta?.title ?? 'Autonomie',
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.shadok-jardinier {
position: absolute;
left: 2%;
top: 5%;
width: clamp(100px, 14vw, 200px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-jardinier 10s ease-in-out infinite;
}
.shadok-batisseur {
position: absolute;
right: 2%;
bottom: 5%;
width: clamp(110px, 15vw, 210px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-batisseur 11s ease-in-out infinite;
}
@keyframes shadok-float-jardinier {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-batisseur {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(1deg); }
}
@media (max-width: 768px) {
.shadok-jardinier { display: none; }
.shadok-batisseur { display: none; }
}
</style>

View File

@@ -1,110 +0,0 @@
<template>
<div class="section-padding">
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<!-- Search + view toggle -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="relative flex-1 max-w-md">
<div class="i-lucide-search absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
<input
v-model="search"
type="text"
:placeholder="content?.searchPlaceholder"
class="w-full rounded-lg bg-surface border border-white/8 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-primary/50 focus:outline-none"
>
</div>
<div class="flex items-center gap-1 rounded-lg bg-surface p-1">
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'list'"
>
<div class="i-lucide-list h-4 w-4" />
</button>
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'grid'"
>
<div class="i-lucide-grid-3x3 h-4 w-4" />
</button>
</div>
</div>
<!-- Song list -->
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<!-- Song grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<p v-if="filteredSongs.length === 0" class="text-center text-white/40 py-12">
{{ content?.noResults }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('ecouter')
useHead({
title: content.value?.meta?.title ?? 'Écouter',
})
const store = usePlayerStore()
const bookData = useBookData()
const { loadFullPlaylist } = usePlaylist()
await bookData.init()
// Switch to free mode
store.setMode('free')
await loadFullPlaylist()
const search = ref('')
const viewMode = ref<'list' | 'grid'>('list')
const filteredSongs = computed(() => {
const songs = bookData.getSongs()
if (!search.value.trim()) return songs
const q = search.value.toLowerCase()
return songs.filter(
s => s.title.toLowerCase().includes(q)
|| s.artist.toLowerCase().includes(q)
|| s.tags.some(t => t.toLowerCase().includes(q)),
)
})
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok danseur: character dancing with music notes -->
<svg class="shadok-danseur" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body (dynamic pose, leaning) -->
<ellipse cx="120" cy="155" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
<!-- Head (tilted with joy) -->
<ellipse cx="125" cy="92" rx="24" ry="23" fill="currentColor" opacity="0.8"/>
<!-- Eyes (happy, squinted) -->
<path d="M114 88 Q118 84 122 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M130 88 Q134 84 138 88" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Big smile -->
<path d="M116 100 Q125 108 134 100" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms thrown up (dancing) -->
<line x1="85" y1="140" x2="50" y2="100" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="155" y1="140" x2="190" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Hands -->
<circle cx="50" cy="98" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="190" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<!-- Legs (one kicked up) -->
<line x1="105" y1="198" x2="80" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="198" x2="170" y2="240" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Feet -->
<path d="M80 255 L68 258 M80 255 L75 261" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<path d="M170 240 L180 238 M170 240 L175 246" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<!-- Music notes floating around -->
<text x="42" y="82" fill="currentColor" opacity="0.3" font-size="18">&#9834;</text>
<text x="195" y="88" fill="currentColor" opacity="0.25" font-size="16">&#9835;</text>
<text x="60" y="65" fill="currentColor" opacity="0.2" font-size="14">&#9833;</text>
<text x="180" y="72" fill="currentColor" opacity="0.2" font-size="20">&#9834;</text>
</svg>
<!-- Shadok DJ: character with headphones behind a turntable -->
<svg class="shadok-dj" viewBox="0 0 260 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="130" cy="155" rx="42" ry="50" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="130" cy="88" r="26" fill="currentColor" opacity="0.8"/>
<!-- Headphones band -->
<path d="M104 78 Q130 55 156 78" stroke="currentColor" stroke-width="4" stroke-linecap="round" fill="none" opacity="0.6"/>
<!-- Headphone ear pads -->
<ellipse cx="102" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
<ellipse cx="158" cy="85" rx="8" ry="12" fill="currentColor" opacity="0.5"/>
<!-- Eyes (cool, half-lidded) -->
<ellipse cx="120" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
<ellipse cx="140" cy="85" rx="5" ry="3" fill="currentColor" opacity="0.25"/>
<circle cx="121" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="141" cy="86" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Mouth (grin) -->
<path d="M122 98 Q130 104 138 98" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms reaching to turntable -->
<line x1="90" y1="150" x2="55" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="170" y1="150" x2="205" y2="195" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Turntable body -->
<rect x="30" y="200" width="200" height="18" rx="4" fill="currentColor" opacity="0.4"/>
<!-- Turntable platter -->
<ellipse cx="130" cy="200" rx="55" ry="15" fill="currentColor" opacity="0.25"/>
<ellipse cx="130" cy="200" rx="55" ry="15" stroke="currentColor" stroke-width="1.5" fill="none" opacity="0.35"/>
<!-- Record center -->
<circle cx="130" cy="200" r="5" fill="currentColor" opacity="0.4"/>
<!-- Tone arm -->
<line x1="195" y1="188" x2="150" y2="195" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<circle cx="195" cy="188" r="3" fill="currentColor" opacity="0.35"/>
<!-- Legs -->
<line x1="115" y1="202" x2="105" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="145" y1="202" x2="155" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<div class="container-content">
<!-- Hero section with book cover -->
<div class="mb-12 grid items-center gap-8 md:grid-cols-2">
<div class="book-cover-wrapper">
<div class="book-cover-3d">
<img
:src="homeContent?.book.coverImage"
:alt="homeContent?.book.coverAlt"
class="book-cover-img"
/>
</div>
</div>
<div class="text-center md:text-left">
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 text-white/60">
{{ content?.description }}
</p>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:gap-4 justify-center md:justify-start">
<UiBaseButton @click="showBookPlayer = true">
<div class="i-lucide-play mr-2 h-5 w-5" />
Présentation musicale
</UiBaseButton>
<UiBaseButton variant="accent" @click="showPdfReader = true">
<div class="i-lucide-book-open mr-2 h-5 w-5" />
Lire le livre
</UiBaseButton>
</div>
</div>
</div>
<!-- Search + view toggle -->
<div class="mb-6 flex items-center justify-between gap-4">
<div class="relative flex-1 max-w-md">
<div class="i-lucide-search absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
<input
v-model="search"
type="text"
:placeholder="content?.searchPlaceholder"
class="w-full rounded-lg bg-surface border border-white/8 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-primary/50 focus:outline-none"
>
</div>
<div class="flex items-center gap-1 rounded-lg bg-surface p-1">
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'list'"
>
<div class="i-lucide-list h-4 w-4" />
</button>
<button
class="rounded p-1.5 transition-colors"
:class="viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-white/40'"
@click="viewMode = 'grid'"
>
<div class="i-lucide-grid-3x3 h-4 w-4" />
</button>
</div>
</div>
<!-- Song list -->
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<!-- Song grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SongItem
v-for="song in filteredSongs"
:key="song.id"
:song="song"
/>
</div>
<p v-if="filteredSongs.length === 0" class="text-center text-white/40 py-12">
{{ content?.noResults }}
</p>
</div>
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('en-musique')
const { data: homeContent } = await usePageContent('home')
useHead({
title: content.value?.meta?.title ?? 'En musique',
})
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>

267
app/pages/evenement.vue Normal file
View File

@@ -0,0 +1,267 @@
<template>
<div class="relative overflow-hidden section-padding min-h-[70vh] flex items-center justify-center">
<!-- Shadok jongleur: juggling coins (top-left) -->
<svg class="shadok-juggler" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="160" rx="38" ry="46" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="98" r="24" fill="currentColor" opacity="0.8"/>
<!-- Eyes (looking up at coins) -->
<circle cx="112" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="92" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="91" r="1.5" fill="currentColor" opacity="0.5"/>
<!-- Smile -->
<path d="M112 108 Q120 114 128 108" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.35"/>
<!-- Arms up (juggling) -->
<line x1="85" y1="145" x2="55" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="155" y1="145" x2="185" y2="105" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Hands -->
<circle cx="55" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<circle cx="185" cy="103" r="4" fill="currentColor" opacity="0.4"/>
<!-- Juggling coins -->
<circle cx="90" cy="55" r="8" fill="currentColor" opacity="0.35"/>
<text x="86" y="59" fill="currentColor" opacity="0.5" font-size="10" font-weight="bold">$</text>
<circle cx="120" cy="40" r="8" fill="currentColor" opacity="0.3"/>
<text x="116" y="44" fill="currentColor" opacity="0.45" font-size="10" font-weight="bold">$</text>
<circle cx="150" cy="50" r="8" fill="currentColor" opacity="0.32"/>
<text x="146" y="54" fill="currentColor" opacity="0.48" font-size="10" font-weight="bold">$</text>
<!-- Legs -->
<line x1="105" y1="203" x2="95" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="203" x2="145" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok échelle: on a wobbly ladder (top-right) -->
<svg class="shadok-ladder" viewBox="0 0 220 320" fill="none" aria-hidden="true">
<!-- Ladder (tilting) -->
<line x1="80" y1="50" x2="70" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
<line x1="150" y1="50" x2="140" y2="300" stroke="currentColor" stroke-width="3" opacity="0.35"/>
<!-- Rungs -->
<line x1="82" y1="80" x2="148" y2="80" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="83" y1="120" x2="147" y2="120" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="84" y1="160" x2="146" y2="160" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="85" y1="200" x2="145" y2="200" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<line x1="86" y1="240" x2="144" y2="240" stroke="currentColor" stroke-width="2.5" opacity="0.3"/>
<!-- Shadok on top (arms out for balance) -->
<ellipse cx="115" cy="68" rx="18" ry="14" fill="currentColor" opacity="0.85"/>
<circle cx="115" cy="46" r="14" fill="currentColor" opacity="0.8"/>
<!-- Eyes (worried) -->
<circle cx="110" cy="43" r="3" fill="currentColor" opacity="0.25"/>
<circle cx="122" cy="43" r="3" fill="currentColor" opacity="0.25"/>
<circle cx="110" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
<circle cx="122" cy="44" r="1.2" fill="currentColor" opacity="0.5"/>
<!-- Worried mouth -->
<path d="M108 52 Q115 49 122 52" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arms out (balancing) -->
<line x1="97" y1="62" x2="60" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
<line x1="133" y1="62" x2="170" y2="55" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok acrobate: doing a cartwheel (center) -->
<svg class="shadok-acrobat" viewBox="0 0 260 240" fill="none" aria-hidden="true">
<!-- Body (sideways, mid-cartwheel) -->
<ellipse cx="130" cy="120" rx="30" ry="38" fill="currentColor" opacity="0.85" transform="rotate(45 130 120)"/>
<!-- Head -->
<circle cx="155" cy="82" r="20" fill="currentColor" opacity="0.8"/>
<!-- Eyes (dizzy/happy) -->
<path d="M148 78 Q152 74 156 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M160 78 Q164 74 168 78" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Smile -->
<path d="M150 90 Q158 95 165 90" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arms (one touching ground, one up) -->
<line x1="110" y1="100" x2="80" y2="130" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<line x1="150" y1="105" x2="185" y2="70" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<!-- Hand on ground -->
<circle cx="78" cy="132" r="4" fill="currentColor" opacity="0.4"/>
<!-- Legs (splayed in cartwheel) -->
<line x1="125" y1="155" x2="100" y2="195" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<line x1="140" y1="150" x2="175" y2="175" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.6"/>
<!-- Motion lines -->
<path d="M70 110 Q60 105 55 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M190 60 Q200 55 205 65" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
</svg>
<!-- Shadok dormeur: sleeping on a cloud (bottom-left) -->
<svg class="shadok-sleeper" viewBox="0 0 260 220" fill="none" aria-hidden="true">
<!-- Cloud -->
<ellipse cx="130" cy="150" rx="80" ry="25" fill="currentColor" opacity="0.2"/>
<circle cx="80" cy="140" r="25" fill="currentColor" opacity="0.18"/>
<circle cx="120" cy="130" r="30" fill="currentColor" opacity="0.2"/>
<circle cx="165" cy="135" r="22" fill="currentColor" opacity="0.18"/>
<circle cx="190" cy="142" r="18" fill="currentColor" opacity="0.15"/>
<!-- Shadok body (lying down) -->
<ellipse cx="130" cy="125" rx="35" ry="18" fill="currentColor" opacity="0.85"/>
<!-- Head (on cloud, sideways) -->
<ellipse cx="85" cy="118" rx="18" ry="16" fill="currentColor" opacity="0.8"/>
<!-- Closed eyes (sleeping) -->
<path d="M76 115 Q80 112 84 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<path d="M88 115 Q92 112 96 115" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.4"/>
<!-- Snooze bubbles -->
<text x="70" y="100" fill="currentColor" opacity="0.3" font-size="12" font-weight="bold">z</text>
<text x="60" y="85" fill="currentColor" opacity="0.25" font-size="16" font-weight="bold">z</text>
<text x="48" y="68" fill="currentColor" opacity="0.2" font-size="20" font-weight="bold">z</text>
<!-- Legs (curled) -->
<path d="M165 125 Q180 130 175 140" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M160 130 Q172 138 168 148" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.5"/>
</svg>
<!-- Shadok cuisinier: cooking in a cauldron (bottom-right) -->
<svg class="shadok-cook" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="145" rx="38" ry="45" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="85" r="24" fill="currentColor" opacity="0.8"/>
<!-- Chef hat -->
<ellipse cx="120" cy="62" rx="22" ry="18" fill="currentColor" opacity="0.35"/>
<rect x="105" y="68" width="30" height="6" rx="1" fill="currentColor" opacity="0.4"/>
<!-- Eyes (focused on cooking) -->
<circle cx="112" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="82" r="3.5" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="83" r="1.5" fill="currentColor" opacity="0.5"/>
<!-- Tongue out (concentrating) -->
<path d="M115 96 Q120 100 125 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm with ladle -->
<line x1="155" y1="135" x2="185" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Ladle -->
<line x1="185" y1="175" x2="175" y2="200" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<ellipse cx="175" cy="205" rx="8" ry="5" fill="currentColor" opacity="0.35"/>
<!-- Other arm -->
<line x1="85" y1="140" x2="60" y2="175" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="105" y1="188" x2="95" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="188" x2="145" y2="250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Cauldron -->
<path d="M55 220 Q55 260 120 260 Q185 260 185 220" fill="currentColor" opacity="0.3"/>
<ellipse cx="120" cy="220" rx="65" ry="12" fill="currentColor" opacity="0.25"/>
<ellipse cx="120" cy="220" rx="65" ry="12" stroke="currentColor" stroke-width="2" fill="none" opacity="0.35"/>
<!-- Steam -->
<path d="M95 210 Q90 195 95 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M120 208 Q118 190 122 180" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
<path d="M145 210 Q148 195 143 185" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.2"/>
</svg>
<div class="container-content relative z-10 text-center">
<p class="mb-3 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-extrabold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 text-lg text-white/50">
{{ content?.description }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('evenement')
useHead({
title: content.value?.meta?.title ?? 'Évènement',
})
</script>
<style scoped>
.page-title {
font-size: clamp(2.5rem, 6vw, 3.5rem);
}
.shadok-juggler {
position: absolute;
left: 4%;
top: 5%;
width: clamp(100px, 14vw, 190px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-bounce-juggler 4s ease-in-out infinite;
}
.shadok-ladder {
position: absolute;
right: 4%;
top: 3%;
width: clamp(90px, 12vw, 170px);
opacity: 0.28;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-wobble-ladder 5s ease-in-out infinite;
}
.shadok-acrobat {
position: absolute;
left: 50%;
top: 55%;
transform: translateX(-50%);
width: clamp(100px, 13vw, 180px);
opacity: 0.2;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-spin-acrobat 6s ease-in-out infinite;
}
.shadok-sleeper {
position: absolute;
left: 3%;
bottom: 5%;
width: clamp(110px, 15vw, 210px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-sleeper 8s ease-in-out infinite;
}
.shadok-cook {
position: absolute;
right: 3%;
bottom: 4%;
width: clamp(100px, 14vw, 200px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-bounce-cook 5s ease-in-out infinite;
}
@keyframes shadok-bounce-juggler {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-12px); }
60% { transform: translateY(-6px); }
}
@keyframes shadok-wobble-ladder {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(3deg); }
75% { transform: rotate(-3deg); }
}
@keyframes shadok-spin-acrobat {
0% { transform: translateX(-50%) rotate(0deg); }
25% { transform: translateX(-50%) rotate(15deg); }
50% { transform: translateX(-50%) rotate(0deg); }
75% { transform: translateX(-50%) rotate(-15deg); }
100% { transform: translateX(-50%) rotate(0deg); }
}
@keyframes shadok-float-sleeper {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes shadok-bounce-cook {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-10px); }
70% { transform: translateY(-4px); }
}
@media (max-width: 768px) {
.shadok-juggler { display: none; }
.shadok-ladder { display: none; }
.shadok-acrobat { display: none; }
.shadok-sleeper { display: none; }
.shadok-cook { display: none; }
}
</style>

View File

@@ -1,70 +1,114 @@
<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 &agrave; 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',
title: content.value?.meta?.title ?? 'grateWizard \u2014 Coefficients relatifs',
})
const { launch } = useGrateWizard()
const { url, launch } = useGrateWizard()
</script>
<style scoped>
@@ -75,14 +119,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 +136,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>

View File

@@ -1,71 +0,0 @@
<template>
<div class="section-padding">
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl">
<ul class="flex flex-col gap-3">
<li
v-for="chapter in chapters"
:key="chapter.path"
>
<NuxtLink
:to="`/lire/${chapter.stem}`"
class="card-surface flex items-start gap-4 group"
>
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
{{ String(chapter.order).padStart(2, '0') }}
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
{{ chapter.title }}
</h2>
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
{{ chapter.description }}
</p>
<div class="mt-2 flex items-center gap-3">
<span v-if="chapter.readingTime" class="text-xs text-white/30">
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
{{ chapter.readingTime }}
</span>
<SongBadges :chapter-slug="chapter.stem!" />
</div>
</div>
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
</NuxtLink>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('lire')
useHead({
title: content.value?.meta?.title ?? 'Table des matières',
})
const { data: chapters } = await useAsyncData('book-toc', () =>
queryCollection('book').order('order', 'ASC').all(),
)
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
</style>

View File

@@ -42,8 +42,8 @@ function formatDate(iso: string) {
<style scoped>
.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;
}

View File

@@ -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="`/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="`/modele-eco/${nextChapter.stem?.split('/').pop()}`"
class="btn-ghost gap-2"
>
<span class="text-sm">{{ nextChapter.title }}</span>
@@ -64,7 +64,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(() => {

View File

@@ -0,0 +1,199 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok reader: character with big glasses reading a book -->
<svg class="shadok-reader" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="120" cy="170" rx="45" ry="55" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="120" cy="100" r="28" fill="currentColor" opacity="0.8"/>
<!-- Big round glasses -->
<circle cx="107" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
<circle cx="133" cy="94" r="11" stroke="currentColor" stroke-width="2.5" fill="none" opacity="0.5"/>
<line x1="118" y1="94" x2="122" y2="94" stroke="currentColor" stroke-width="2" opacity="0.5"/>
<!-- Eyes behind glasses -->
<circle cx="108" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
<circle cx="134" cy="93" r="2.5" fill="currentColor" opacity="0.5"/>
<!-- Arms holding book -->
<line x1="78" y1="155" x2="60" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="162" y1="155" x2="180" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Book (open) -->
<rect x="55" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.35"/>
<rect x="110" y="175" width="55" height="40" rx="2" fill="currentColor" opacity="0.3"/>
<line x1="110" y1="175" x2="110" y2="215" stroke="currentColor" stroke-width="2" opacity="0.5"/>
<!-- Book lines (text) -->
<line x1="65" y1="188" x2="100" y2="188" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<line x1="65" y1="195" x2="95" y2="195" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<line x1="65" y1="202" x2="98" y2="202" stroke="currentColor" stroke-width="1" opacity="0.2"/>
<!-- Legs -->
<line x1="105" y1="222" x2="95" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="135" y1="222" x2="145" y2="270" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
</svg>
<!-- Shadok stack: pile of books tilting -->
<svg class="shadok-stack" viewBox="0 0 160 220" fill="none" aria-hidden="true">
<!-- Bottom book -->
<rect x="20" y="170" width="120" height="22" rx="3" fill="currentColor" opacity="0.5" transform="rotate(-2 80 181)"/>
<!-- Second book -->
<rect x="30" y="145" width="100" height="20" rx="3" fill="currentColor" opacity="0.45" transform="rotate(3 80 155)"/>
<!-- Third book -->
<rect x="25" y="120" width="110" height="18" rx="3" fill="currentColor" opacity="0.4" transform="rotate(-4 80 129)"/>
<!-- Fourth book -->
<rect x="35" y="97" width="90" height="18" rx="3" fill="currentColor" opacity="0.35" transform="rotate(5 80 106)"/>
<!-- Fifth book (tilting more) -->
<rect x="40" y="74" width="80" height="17" rx="3" fill="currentColor" opacity="0.3" transform="rotate(-7 80 82)"/>
<!-- Top book (really tilting) -->
<rect x="45" y="52" width="70" height="16" rx="3" fill="currentColor" opacity="0.25" transform="rotate(10 80 60)"/>
<!-- Tiny Shadok sitting on top -->
<ellipse cx="85" cy="42" rx="10" ry="8" fill="currentColor" opacity="0.5"/>
<circle cx="85" cy="30" r="6" fill="currentColor" opacity="0.45"/>
<circle cx="87" cy="29" r="1.5" fill="currentColor" opacity="0.3"/>
</svg>
<!-- Shadok scribe: character with quill and inkwell -->
<svg class="shadok-scribe" viewBox="0 0 240 280" fill="none" aria-hidden="true">
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
<ellipse cx="120" cy="72" rx="20" ry="8" fill="currentColor" opacity="0.35"/>
<circle cx="112" cy="90" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="130" cy="90" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="113" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="131" cy="91" r="1.8" fill="currentColor" opacity="0.5"/>
<path d="M118 104 Q120 108 122 104" fill="currentColor" opacity="0.3"/>
<line x1="160" y1="140" x2="190" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="190" y1="170" x2="210" y2="145" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.45"/>
<path d="M210 145 Q215 140 212 135 Q208 138 210 145" fill="currentColor" opacity="0.3"/>
<line x1="80" y1="148" x2="55" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<path d="M100 200 Q90 230 95 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<path d="M140 200 Q150 230 145 250" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" fill="none" opacity="0.6"/>
<rect x="45" y="185" width="20" height="18" rx="3" fill="currentColor" opacity="0.35"/>
<ellipse cx="55" cy="185" rx="12" ry="4" fill="currentColor" opacity="0.3"/>
<rect x="170" y="175" width="40" height="50" rx="2" fill="currentColor" opacity="0.2"/>
<line x1="178" y1="188" x2="202" y2="188" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="178" y1="195" x2="200" y2="195" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="178" y1="202" x2="198" y2="202" stroke="currentColor" stroke-width="1" opacity="0.15"/>
</svg>
<div class="container-content">
<header class="mb-12 text-center">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl">
<ul class="flex flex-col gap-3">
<li
v-for="chapter in chapters"
:key="chapter.path"
>
<NuxtLink
:to="`/modele-eco/${chapter.stem?.split('/').pop()}`"
class="card-surface flex items-start gap-4 group"
>
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
{{ String(chapter.order).padStart(2, '0') }}
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
{{ chapter.title }}
</h2>
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
{{ chapter.description }}
</p>
<div class="mt-2 flex items-center gap-3">
<span v-if="chapter.readingTime" class="text-xs text-white/30">
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
{{ chapter.readingTime }}
</span>
<SongBadges :chapter-slug="chapter.stem?.split('/').pop() ?? ''" />
</div>
</div>
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
</NuxtLink>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('modele-eco')
useHead({
title: content.value?.meta?.title ?? 'Table des matières',
})
const { data: chapters } = await useAsyncData('book-toc', () =>
queryCollection('book').order('order', 'ASC').all(),
)
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.shadok-reader {
position: absolute;
right: 2%;
top: 3%;
width: clamp(110px, 15vw, 220px);
opacity: 0.3;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-reader 9s ease-in-out infinite;
}
.shadok-stack {
position: absolute;
left: 2%;
bottom: 5%;
width: clamp(100px, 13vw, 180px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-accent));
animation: shadok-float-stack 11s ease-in-out infinite;
}
@keyframes shadok-float-reader {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes shadok-float-stack {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(-2deg); }
}
.shadok-scribe {
position: absolute;
left: 50%;
bottom: 3%;
transform: translateX(-50%);
width: clamp(100px, 13vw, 180px);
opacity: 0.25;
pointer-events: none;
color: hsl(var(--color-primary));
animation: shadok-float-scribe 10s ease-in-out infinite;
}
@keyframes shadok-float-scribe {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-8px); }
}
@media (max-width: 768px) {
.shadok-reader { display: none; }
.shadok-stack { display: none; }
.shadok-scribe { display: none; }
}
</style>

118
app/stores/palette.ts Normal file
View File

@@ -0,0 +1,118 @@
export type PaletteName = 'automne' | 'hiver' | 'printemps' | 'ete'
interface PaletteColors {
primary: string
accent: string
surface: string
bg: string
surfaceLight: string
text: string
textMuted: 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%',
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%',
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
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
isLight: true,
label: 'Été',
icon: 'i-lucide-sun',
},
}
export const usePaletteStore = defineStore('palette', () => {
const currentPalette = ref<PaletteName>(
(import.meta.client && localStorage.getItem('palette') as PaletteName) || 'automne',
)
const colors = computed(() => palettes[currentPalette.value])
const isLight = computed(() => colors.value.isLight)
function applyToDOM() {
if (!import.meta.client) return
const c = colors.value
const root = document.documentElement
const s = root.style
s.setProperty('--color-primary', c.primary)
s.setProperty('--color-accent', c.accent)
s.setProperty('--color-surface', c.surface)
s.setProperty('--color-bg', c.bg)
s.setProperty('--color-surface-light', c.surfaceLight)
s.setProperty('--color-text', c.text)
s.setProperty('--color-text-muted', c.textMuted)
// Toggle light/dark class for CSS overrides
root.classList.toggle('palette-light', c.isLight)
root.classList.toggle('palette-dark', !c.isLight)
s.setProperty('color-scheme', c.isLight ? 'light' : 'dark')
}
function setPalette(name: PaletteName) {
currentPalette.value = name
if (import.meta.client) localStorage.setItem('palette', name)
applyToDOM()
}
return {
currentPalette,
colors,
palettes,
isLight,
setPalette,
applyToDOM,
}
})

View File

@@ -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
}
}

View File

@@ -0,0 +1,37 @@
---
title: "Introduction"
description: "Un livre et des chansons pour réaliser une économie du don, portée par les monnaie-libristes."
order: 1
readingTime: "15 min"
---
## Ce livre est une façon
Ce livre est un essai. Une proposition. Une intention. Une invitation. Une façon.
Il ne s'agit pas d'une théorie universelle, mais d'une **expérimentation concrète, économique, civile et artistique**. Pas un guide, pas un kit : plutôt un *git* — un dépôt de réflexions ouvertes, une cartographie et quelques boussoles dans une jungle à défricher.
## À qui s'adresse ce livre ?
Ce livre s'adresse aux quelque **8 500 monnaie-libristes** (au moment de l'écriture) qui utilisent la June (Ğ1), monnaie libre fondée sur la Théorie Relative de la Monnaie. L'ambition : proposer un **modèle économique** concret pour passer de l'expérimentation monétaire à une véritable économie du don.
## Pourquoi une économie du don ?
Nous avons déjà une économie. Elle couvre nos besoins vitaux, de facto. Alors pourquoi en créer une autre ?
Pour **l'autonomie**. Et c'est tout sauf un repli. Réfuter l'autonomie, c'est nous bannir en tant qu'adultes bien vivants. L'autonomie, ici, c'est la capacité d'un groupe humain à couvrir ses besoins essentiels par ses propres moyens — sans pour autant se couper du reste du monde.
## Ce que ce livre n'est pas
- Ce n'est pas une baguette magique
- Ce n'est pas un programme politique
- Ça ne dit pas quoi faire lundi
- Ce n'est pas du prêt-à-porter : à nous de tailler
## Ce que ce livre est
Un **os à ronger**. Une cartographie avec quelques boussoles. Un livre *et* des chansons, parce que la musique porte les idées autrement.
Chaque chapitre est accompagné d'un morceau qui en prolonge le propos. Le livre se lit, s'écoute, se vit.
> *On tourne une page pour voir ?*

42
content/book/02-don.md Normal file
View File

@@ -0,0 +1,42 @@
---
title: "De quel don parlons-nous ?"
description: "Du don de Marcel Mauss aux asymétries du quotidien : comprendre le triple geste donner-recevoir-rendre."
order: 2
readingTime: "20 min"
---
## L'oxymore apparent
« Économie du don » — le mariage fait peur. Un oxymore pour l'esprit. Pour éviter tout quiproquo : il ne s'agit ni de spirituel, ni de karma, ni du « centuple divin ». Ici, le don est un **geste opératoire**, une base, une fondation pour une autre forme de construction.
## Marcel Mauss et le triple geste
Ce n'est pas l'image d'Épinal du don gratuit. **Marcel Mauss**, dans son *Essai sur le don* (1925), a montré que le don est un cycle vital en trois temps :
1. **Donner** — l'initiative, le geste premier
2. **Recevoir** — accepter crée un lien, une obligation
3. **Rendre** — boucler le cycle, entretenir la relation
C'est un pacte, une tension, parfois même un combat. Si tu enfreins le protocole... ça ne pardonne pas.
## La monnaie-dette n'est pas la monnaie
On entend souvent : « Brûlons la monnaie ! Le troc, la gratuité, le grand projet... » La monnaie serait le vice, la source de tout le mal. **Erreur de cible.** Ce n'est pas la monnaie le problème, c'est la *dette*. La monnaie-dette, celle qui nous tient, celle qui nous guette. Mais si la monnaie est libre ? Elle permet les équilibres.
## Les asymétries
Rien n'est symétrique. Ce n'est pas magique. Une pomme aujourd'hui n'est pas la même demain. Une cagette, ce n'est pas cinq palettes. Six heures assis à parler bien à l'aise... six heures à genoux sur un toit — est-ce le même geste ?
Les asymétries sont partout. On ne peut pas les supprimer, mais on peut les **mesurer**. Et c'est précisément ce que fait une monnaie bien conçue.
## Communautés et protocoles
Les collectifs qui prônent le don sans outil de mesure finissent souvent par l'usure, le ressentiment, l'abandon. Légiférer, prescrire un comportement ? C'est tentant, mais bancal.
Mieux vaut un **protocole** — une façon de traiter les asymétries, d'éviter de juger, de préfigurer. Sans trancher les sorts à l'insu des participants.
## Eco si nuestra
L'expérience « Made in Ğ1 » et le jeu **321DU** montrent qu'il est possible de simuler et de vivre une micro-économie du don. Ces prototypages en communauté pseudo-isolée révèlent les mécanismes, les biais, les opportunités.
> *Pour se rétablir, retomber sur nos pieds, il existe un outil qui s'appelle... « la monnaie ».*

34
content/book/03-mesure.md Normal file
View File

@@ -0,0 +1,34 @@
---
title: "La mesure du don"
description: "Le DU comme unité de mesure du don : un retournement sémantique qui change tout."
order: 3
readingTime: "8 min"
---
## Une mesure, pas un prix
Le mot « mesure » a une portée symbolique décisive. Mesurer le don, ce n'est pas le dénaturer ni le marchandiser. C'est lui donner un **référentiel commun** pour que chacun puisse s'y retrouver.
Le **Dividende Universel (DU)** — la quantité de monnaie libre créée chaque jour pour chaque membre — devient l'unité de mesure naturelle. Un DU, c'est la part quotidienne de création monétaire d'un être humain. Ni plus, ni moins.
## Le retournement sémantique
Voici le basculement fondamental que propose l'économie du don en monnaie libre :
- **« Je ne vends plus, je donne »** — mon geste premier est un don, pas une transaction marchande
- **« Je n'achète plus, je reçois »** — recevoir engage, crée un lien
- **« Je ne paye plus, je mesure »** — la monnaie libre mesure la gratitude, pas le prix
Ce retournement n'est pas qu'un jeu de mots. Il transforme la posture intérieure de chaque participant à l'échange.
## Trans·action
Le mot « transaction » retrouve son sens premier : **trans** (à travers) + **action**. C'est une action qui traverse, qui relie. Pas un échange froid entre deux intérêts, mais un geste qui crée du lien.
En monnaie libre, chaque transaction est une **mesure de gratitude**. Combien de DU vaut ce que tu m'as donné ? C'est toi qui le dis. C'est moi qui accepte. Et la monnaie, symétrique dans sa création, garantit que personne n'est structurellement avantagé.
## Mesurer sans juger
Le protocole de mesure remplace le besoin de légiférer. Pas besoin d'écrire des règles pour chaque situation : la mesure en DU est un **langage commun** qui permet aux asymétries de s'exprimer et de se résorber naturellement.
> *Il suffit d'une mesure. Sans morsure. Qui fait sens pour toi et moi, pour tout le monde.*

View File

@@ -0,0 +1,36 @@
---
title: "Raison d'être d'une monnaie"
description: "Pourquoi la monnaie est la clé de voûte de toute économie — et pourquoi les monnaies-dette sont génétiquement viciées."
order: 4
readingTime: "25 min"
---
## La clé de voûte
La monnaie est l'outil le plus puissant et le plus sous-estimé de toute économie. C'est elle qui, en permanence, résout les asymétries. Même sans qu'on y pense. Chaque monnaie **programme sa propre engeance** — son pouvoir est immense.
## L'équation de Fischer
**E = Q × V** — l'équation de Fischer pose les bases : la masse monétaire en circulation (E) est le produit de la quantité de monnaie (Q) par sa vitesse de circulation (V). Cette relation simple révèle pourquoi le contrôle de la création monétaire est un enjeu de pouvoir fondamental.
## Trois leçons sur les monnaies-dette
### 1. La cavalerie
La monnaie-dette fonctionne comme une cavalerie : pour rembourser la dette d'hier, il faut créer de la dette aujourd'hui. Le système ne tient que par la croissance perpétuelle de l'endettement. Quand la musique s'arrête, tout s'effondre.
### 2. La machine à faillites
Dans un système où la monnaie est créée par le crédit, les intérêts ne sont jamais créés. Il y a donc **structurellement moins de monnaie en circulation que de dettes à rembourser**. Le système fabrique mécaniquement des perdants.
### 3. La pyramide arbitraire
Qui décide de la création monétaire ? Les banques. Et pour qui ? Pour ceux qui offrent des garanties. Le système monétaire conventionnel est une **pyramide d'accès arbitraire** à la monnaie : ceux qui sont proches de la source en bénéficient, les autres subissent la dilution.
## Une économie mal codée
L'analogie informatique est éclairante : notre économie est un programme dont le **code source est vicié**. Les monnaies-dette contiennent des bugs génétiques — cavalerie, faillites programmées, pyramide — qui ne sont pas des accidents mais des propriétés structurelles.
La monnaie libre propose de **recoder** la base : une monnaie dont la création est symétrique entre tous les membres, dans l'espace et dans le temps.
> *Chaque monnaie programme sa propre engeance. Ne t'y méprends pas, son pouvoir est immense.*

51
content/book/05-trm.md Normal file
View File

@@ -0,0 +1,51 @@
---
title: "La TRM"
description: "Invitation à découvrir la Théorie Relative de la Monnaie de Stéphane Laborde : symétrie, DU et relativité."
order: 5
readingTime: "30 min"
---
## Invitation à la TRM
Ce chapitre est une **invitation à lire la Théorie Relative de la Monnaie** (TRM) de Stéphane Laborde. Pas un résumé — la TRM se mérite, se travaille, se digère. Mais quelques clés pour en saisir la portée.
## Le flux monétaire
En monnaie libre, le flux monétaire est **corrélé à l'existence physique** de chaque être humain. Tant que tu vis, tu co-crées de la monnaie. Le Dividende Universel (DU) est cette part de création, identique pour chaque membre, chaque jour.
## La symétrie spatio-temporelle
Le principe fondamental de la TRM tient en deux symétries :
- **Symétrie spatiale** : aucun individu vivant n'est privilégié par rapport à un autre dans la création monétaire
- **Symétrie temporelle** : les individus présents ne sont pas privilégiés par rapport aux individus futurs (ni l'inverse)
Ces deux symétries éliminent les biais génétiques des monnaies-dette : plus de pyramide, plus de privilège d'ancienneté.
## La formule du DU
Le DU se calcule relativement à la masse monétaire existante et au nombre de membres. La formule garantit que, quels que soient le moment d'entrée et la durée de participation, chaque membre converge vers la **même part relative** de la masse monétaire.
## Relativité : penser en M/N
En monnaie libre, les montants absolus n'ont pas de sens. Ce qui compte, c'est la **part relative** : combien de DU ? Quel pourcentage de la masse monétaire moyenne par membre (M/N) ?
Cette relativité est contre-intuitive au début, mais libératrice : elle élimine le biais de genèse (les premiers arrivés ne sont pas structurellement plus riches) et le biais d'ancienneté.
## GrateWizard
L'outil **GrateWizard** permet de simuler et visualiser les flux monétaires en monnaie libre. Il donne à voir la convergence, les échanges, les équilibres — et aide à développer l'intuition de la relativité.
## Volume des offres et diversité
Une monnaie ne vaut que par ce qu'on peut obtenir avec. Le **volume des offres** et leur **diversité** sont les indicateurs clés de la santé d'une économie en monnaie libre. Plus il y a de producteurs, de services, de biens disponibles, plus la monnaie fait sens.
## Échelles de valeurs et convergence
Chacun a ses propres échelles de valeurs. La monnaie libre ne les uniformise pas : elle fournit un **référentiel commun** (le DU) qui permet à chacun d'exprimer ses valeurs et de les échanger. Avec le temps, les prix relatifs convergent vers une moyenne, signe que l'économie trouve ses équilibres.
## Le commun monétaire
La monnaie libre est un **commun** : elle n'appartient à personne et à tout le monde. Comme l'air ou l'eau, elle est un bien partagé dont chacun est co-producteur. Ce statut de commun est garanti par la symétrie de sa création.
> *Le DU est la part de chacun. Ni plus, ni moins.*

View File

@@ -0,0 +1,42 @@
---
title: "Créer une économie ?"
description: "Passer de la théorie à la pratique : produire, greffer une économie du don sur le tissu local, inverser les flux."
order: 6
readingTime: "25 min"
---
## Définition
Couvrir les besoins, pour vivre, nourrir les plaisirs de vivre. Avant tout : comment **produire**, comment **distribuer**, sans nuire. On en a déjà une économie. Alors pourquoi en créer une autre ? Pas « créer » au sens de partir de zéro — plutôt **greffer** une économie du don sur l'économie existante.
## Produire avant tout
Le message aux pionniers est clair : faire tourner la monnaie en rond, ce n'est pas créer. Se faire des virements autour d'une table, c'est juste du vent. L'équation de Fischer est formelle : si tu multiplies zéro production par mille transactions, ça fait toujours zéro. L'économie, c'est d'abord **produire et transformer**.
## Passer la seconde
L'analogie du régime moteur : la monnaie libre en est au « point mort ». Pour avancer, il faut **passer la seconde** — produire de vrais biens et services. Cinq vitesses de développement se dessinent, de la simple circulation monétaire à l'économie productive complète.
## L'économie de greffe
Le principe fondamental : **deux économies cohabitent**, l'économie classique (euros) et l'économie du don (monnaie libre). Il ne faut surtout pas les mélanger ni les confondre. On ne « quitte » pas l'euro — on développe une autonomie parallèle, progressive. La greffe prend quand l'économie locale du don couvre suffisamment de besoins réels.
## Connaître son bassin de vie
Avant de se lancer, il faut cartographier son territoire. Deux indicateurs clés :
- Les **mobiz** : les « monnaie-libristes biz » — ceux qui produisent et échangent activement
- Les **sherpiz** : les « sherpas biz » — ceux qui guident, forment, accompagnent
Le ratio mobiz/sherpiz sur un territoire donne une idée réaliste du potentiel de développement.
## La gestion à l'anglaise
Un concept emprunté à la mécanique : la **roue libre**. En monnaie libre, la « roue libre » est l'indicateur de rentabilité. Quand une activité tourne sans effort, en roue libre, c'est qu'elle est rentable en DU. Ce n'est pas la marge financière qui compte, c'est la **soutenabilité** du geste productif.
## L'économie de flux inversés
Le retournement fondamental : **je donne avant de recevoir**. En économie classique, le flux est conditionnel — je te donne si tu me payes. En économie du don, je donne d'abord, et la mesure (en DU) vient après, comme une gratitude.
Ce n'est pas de la naïveté : c'est un changement de posture qui transforme la relation économique. Le don est en amont. L'équipe offre le choix, le lieu, le son. Les références sont posées. Tu prends ou pas, tu gratifies selon ton estimation.
> *L'économie, c'est de l'énergie, de la chaleur c'est sûr. Je grave ma gratitude dans la chaîne.*

View File

@@ -0,0 +1,37 @@
---
title: "Échanger"
description: "Organiser les échanges : filières et boucles, distribution décentralisée, connexion avec l'existant et marchés Ğ1."
order: 7
readingTime: "18 min"
---
## Le PIB comme baromètre
L'échange est le **symptôme de la production**. Le PIB, malgré ses limites, est un baromètre : il mesure le volume des échanges. En monnaie libre, l'équivalent serait le volume de transactions réelles — pas les virements entre amis, mais les échanges qui témoignent d'une production effective.
## Filières et boucles
L'économie du don s'organise en **filières** — de la matière première au produit fini — et en **boucles** — des circuits qui relient producteurs, transformateurs et consommateurs.
Trois exemples concrets :
- **Le pain** : du blé au boulanger, une filière courte qui peut fonctionner entièrement en monnaie libre
- **Le bois** : de la forêt au menuisier, une filière qui demande de la coordination
- **Le photovoltaïque** : une filière plus complexe, qui montre les limites et les possibilités
L'enjeu : les boucles doivent **boucler** — chaque maillon doit trouver ce dont il a besoin dans le circuit — **et se croiser** — les filières doivent s'interconnecter pour créer de la résilience.
## Distribuer
La distribution doit être **décentralisée**. Pas de plateforme unique, pas de point de passage obligé. Chacun est un nœud du réseau, libre de ses connexions. Les outils numériques (Ğ1, applications mobiles) facilitent les échanges mais ne doivent pas devenir des points de contrôle.
## Connecter avec l'existant
L'économie du don ne vit pas en vase clos. Elle doit se **connecter avec l'existant** : les SEL (Systèmes d'Échange Local), l'ESS (Économie Sociale et Solidaire), les associations solidaires, les réseaux de producteurs. Ces ponts élargissent le bassin d'offres et renforcent la légitimité.
## Ğ(marchés)
Les **Ğ(marchés)** — marchés en monnaie libre — sont le lieu de rencontre physique entre producteurs et consommateurs. Différents formats existent, de la simple table de troc au marché organisé avec producteurs professionnels.
L'expérience montre qu'un marché en monnaie libre réussit quand il propose des **produits de qualité** portés par de **vrais producteurs**, pas uniquement des objets de seconde main. La professionnalisation de l'offre est un signe de maturité de l'économie locale.
> *Des cercles qui se croisent, sans les regards qui toisent.*

View File

@@ -0,0 +1,36 @@
---
title: "Relation institutionnelle"
description: "Naviguer dans le cadre légal et fiscal : impôts, TVA, cotisations sociales et financement de l'écosystème."
order: 8
readingTime: "20 min"
---
## Une relation délicate
La relation avec les institutions est **délicate**. L'institution est jalouse de son pouvoir, de son monopole monétaire. Il faut distinguer les **personnes** (souvent bienveillantes, curieuses) des **rôles institutionnels** (contraints par les cadres légaux).
## Impôts et taxes
Premier point, fondamental : **pas d'échappatoire fiscale**. La monnaie libre ne dispense pas des impôts. Toute activité économique, quel que soit le moyen de paiement, reste soumise aux obligations fiscales. On n'a pas besoin de la monnaie libre pour ça — et prétendre le contraire serait nuire au projet.
## Environnement légal
Le cadre évolue. Le **règlement PACTE**, **MiCA** (Markets in Crypto-Assets) et la directive **DAC8** dessinent un environnement légal pour les crypto-actifs en Europe. La June (Ğ1) doit se positionner dans ce paysage — ni en fuite, ni en confrontation, mais en **conformité intelligente**.
## TVA
La question de la TVA est **ambiguë**. Pour les transactions entre particuliers sous les seuils de franchise, la TVA ne s'applique pas. Pour les professionnels, les règles classiques s'appliquent, même si le paiement est en monnaie libre. Le flou actuel invite à la prudence et à la transparence.
## Bénévolat et cotisations sociales
Le bénévolat a un cadre légal précis. Recevoir une contrepartie en monnaie libre pour une activité bénévole peut requalifier cette activité en travail dissimulé. Il faut être vigilant sur la **frontière entre don et rémunération**, et sur les implications en termes de cotisations sociales.
## Financement de l'écosystème
L'écosystème technique (développement de Duniter, applications, infrastructure) a besoin de financement. Des dispositifs existent : **Ademe**, subventions innovation, mécénat. **Axiom Team**, qui développe le protocole Duniter, utilise un système de bounties pour financer le développement — un modèle hybride entre euros et monnaie libre.
## Symboles et sémantique
La relation institutionnelle passe aussi par les mots. **Inverser le flux sémantique** : ne pas dire « je paye en Ğ1 » mais « j'estime ma gratitude en DU ». Ce glissement change la perception : on n'est plus dans un système monétaire alternatif suspect, mais dans une pratique de mesure de la gratitude entre citoyens.
> *Ce n'est plus « Que la dette soit ». C'est « Que l'équilibre soit ».*

View File

@@ -0,0 +1,36 @@
---
title: "Autres greffes"
description: "Greffer l'économie du don dans divers secteurs : emploi, ESS, agriculture, artisanat, éducation."
order: 9
readingTime: "15 min"
---
## Décloisonnement et synergie
L'économie du don ne peut pas rester cantonnée à un cercle d'initiés. Elle doit **se greffer** sur les structures existantes, créer des ponts, des synergies. Chaque secteur est une opportunité de greffe — avec ses spécificités et ses précautions.
## Pôle Emploi et missions locales
Les demandeurs d'emploi et les jeunes accompagnés par les missions locales sont des publics naturels pour l'économie du don. La monnaie libre offre une **dignité économique** à ceux que le système conventionnel marginalise. Mais attention : il ne s'agit pas de remplacer les allocations par de la Ğ1, mais de **compléter** les parcours d'insertion avec une activité productive en monnaie libre.
## ESS — Économie Sociale et Solidaire
L'ESS partage des valeurs avec la monnaie libre : solidarité, utilité sociale, gouvernance démocratique. Mais vigilance sur l'**institutionnalisation** : l'ESS est parfois plus une étiquette qu'une pratique, et les financements publics peuvent créer des dépendances qui contredisent l'autonomie recherchée.
## Associations populaires et caritatives
Les secours populaires, diaconats et associations caritatives sont en première ligne du besoin. La monnaie libre peut leur offrir un outil complémentaire pour **valoriser les contributions bénévoles** et **fluidifier les échanges** au sein de leurs réseaux.
## Agriculture et maraîchage
Les producteurs agricoles et maraîchers sont des **cibles prioritaires** pour l'économie du don. Ils produisent des biens essentiels, travaillent localement, et sont souvent mal rémunérés par le système conventionnel. Une filière alimentaire en monnaie libre est le socle de toute économie du don qui se respecte.
## Artisanat, commerce, entreprise
La **roue libre** s'applique ici : quand un artisan ou un commerçant peut fonctionner en monnaie libre sans effort excessif, c'est que la greffe prend. Les boucles organiques — artisan → client → fournisseur → artisan — doivent se construire naturellement, pas par contrainte.
## Lycées et écoles
La tranche **15-25 ans** est une destination stratégique. Les jeunes adoptent plus facilement les nouveaux outils, questionnent plus naturellement les systèmes établis. Dans 10 ans, la monnaie libre fêtera sa majorité — et la génération qui aura grandi avec sera celle qui la portera.
> *Chaque greffe est une invitation. Chaque secteur est un terrain d'expérimentation.*

View File

@@ -0,0 +1,37 @@
---
title: "Et maintenant ?... action ?"
description: "L'appel à l'action : événementiel, coopérations ponctuelles et questions pratiques pour passer à l'acte."
order: 10
readingTime: "8 min"
---
## Événementiel
L'événementiel est un **outil de régénération**. Marchés, rencontres, ateliers, festivals — chaque événement en monnaie libre est une occasion de montrer que ça marche, de recruter de nouveaux producteurs, de créer du lien. L'événement n'est pas un but en soi : c'est un **catalyseur**.
## Coopérations ponctuelles plutôt que fédération
Le mouvement de la monnaie libre résiste à la structuration verticale — et c'est une force. Plutôt qu'une fédération avec statuts, président et congrès annuel, l'approche privilégie les **coopérations ponctuelles** : des collaborations sur des projets concrets, avec des acteurs qui se choisissent librement.
Cette horizontalité est cohérente avec les principes de la monnaie libre : pas de structure pyramidale, pas de point de contrôle central.
## Raison d'être
La raison d'être de tout cet effort tient en deux mots : **passer la seconde**. Sortir du stade expérimental pour construire une véritable **autonomie collective** — un bassin de vie qui couvre une part significative de ses besoins en monnaie libre.
## Questions pratiques
Trois questions à se poser avant de se lancer :
### Quelles ressources ?
Quels producteurs, quels savoir-faire, quelles infrastructures sont disponibles sur le territoire ? La cartographie du bassin de vie est le point de départ.
### Quel chemin ?
Pas de plan quinquennal. Le chemin se dessine en marchant, par essai et erreur, par greffe successive. L'important est de commencer petit et d'apprendre vite.
### Quel rythme ?
Le rythme cardiaque du mouvement — ni trop rapide (burnout), ni trop lent (essoufflement). Les protocoles de décision doivent rester légers pour maintenir l'élan.
## À vous de danser
> *Ce livre a seulement tenté de décrire quelques pas de danse possibles. À vous d'inventer les vôtres.*

View File

@@ -0,0 +1,48 @@
---
title: "Chapitres annexes"
description: "Approfondissements : cryptomonnaies, blockchain, logiciel libre, Duniter et questions techniques."
order: 11
readingTime: "25 min"
---
## Les cryptos
### La June est une crypto
Oui, la June (Ğ1) est une **cryptomonnaie** au sens technique : elle repose sur un réseau décentralisé, des algorithmes cryptographiques et une blockchain. Mais elle est fondamentalement différente des cryptos spéculatives par sa **co-création symétrique** (le DU) et l'absence de minage compétitif.
### DeX vs CeX
Deux modèles d'échange coexistent dans l'univers crypto :
- **CeX** (Centralized Exchange) : plateformes centralisées type Binance, Coinbase — rapides mais points de contrôle
- **DeX** (Decentralized Exchange) : échanges décentralisés, pair-à-pair — plus lents mais souverains
La June fonctionne nativement en **DeX** : chaque nœud du réseau est un point d'échange, sans intermédiaire centralisé.
### La question du bankrun
Un scénario souvent discuté : que se passerait-il si les monnaie-libristes convertissaient massivement leurs Ğ1 en euros ? L'analyse montre qu'avec une stratégie progressive (type **martingale**), une communauté de quelques milliers de membres pourrait théoriquement exercer une pression significative sur le marché des changes — mais ce n'est ni le but ni l'intérêt. L'objectif est l'**autonomie**, pas la conversion.
### Réseau monétaire
La June possède son **propre réseau monétaire**, contrairement aux tokens qui vivent sur des blockchains tierces. C'est un réseau complet : protocole, consensus, certification d'identité, création monétaire — tout est intégré. Cette souveraineté technique est un atout majeur, mais aussi une responsabilité.
## Le logiciel libre
### Linux, le modèle
Linux est la démonstration vivante que le **logiciel libre** peut produire des systèmes de qualité supérieure. Le noyau Linux fait tourner la majorité des serveurs mondiaux, Android, les supercalculateurs. La preuve que la collaboration ouverte surpasse la concurrence fermée.
### Duniter
**Duniter** est le logiciel libre qui fait tourner la June. Écrit initialement en TypeScript puis migré, il implémente la TRM dans un protocole blockchain. Sous licence **AGPL** (la plus libre des licences libres), il garantit que le code reste ouvert et modifiable par tous.
### Axiom Team
**Axiom Team** est l'équipe de développeurs qui maintient et fait évoluer Duniter. Financée par des bounties et des dons (en euros et en Ğ1), elle incarne le modèle hybride du développement en monnaie libre. La migration vers une nouvelle architecture blockchain en **Rust** (framework Substrate) est en cours — un chantier majeur pour la pérennité du réseau.
### CC-BY-NC
Le livre lui-même est publié sous licence **Creative Commons BY-NC** : libre de diffusion et d'adaptation, à condition de citer l'auteur et de ne pas en faire un usage commercial. Cohérent avec l'esprit du don.
> *Codeurs de rêve, vous décentralisez, vous open sourcez — un monde moins obscur.*

View File

@@ -1,80 +0,0 @@
---
title: "Chapitres annexes — sujets connexes"
description: "Approfondissements : cryptomonnaies, June et blockchain, logiciel libre, réseau monétique et questions techniques."
order: 11
readingTime: "25 min"
---
Ces chapitres annexes abordent des sujets techniques et connexes qui complètent le propos principal du livre. Ils sont destinés aux lecteurs qui souhaitent approfondir certaines questions ou clarifier des points techniques.
## Les cryptos
Le mot « crypto » est devenu un fourre-tout. Il désigne pêle-mêle le Bitcoin, l'Ethereum, les NFT, les memecoins, les stablecoins, les tokens de DeFi... Cette confusion est problématique, car elle amalgame des projets aux philosophies radicalement différentes.
Les cryptomonnaies « classiques » (Bitcoin, Ethereum) partagent un trait commun : elles reproduisent, voire amplifient, les asymétries du système financier traditionnel. Le Bitcoin, par exemple, est créé par le **minage** — un processus qui favorise ceux qui disposent du plus de puissance de calcul (et donc du plus de capital). Les premiers mineurs ont accumulé des quantités astronomiques de bitcoins à moindre coût, créant une oligarchie monétaire encore plus concentrée que celle du système fiat.
La spéculation est le moteur principal de l'écosystème crypto classique. On achète des tokens non pas pour les utiliser, mais pour les revendre plus cher. C'est un casino déguisé en innovation technologique.
## La June est-elle une crypto ?
Techniquement, oui : la Ğ1 utilise une blockchain (Duniter) pour enregistrer les transactions. Mais philosophiquement, elle est aux antipodes des cryptos spéculatives.
Les différences fondamentales :
- **Création monétaire** : dans le Bitcoin, la monnaie est créée par le minage (asymétrique). Dans la Ğ1, elle est créée par le Dividende Universel (symétrique).
- **Objectif** : le Bitcoin vise à être une « réserve de valeur » (une forme d'or numérique). La Ğ1 vise à être un **outil d'échange** au service d'une économie du don.
- **Identité** : dans le Bitcoin, les utilisateurs sont anonymes. Dans la Ğ1, chaque compte est lié à une **personne réelle** via la toile de confiance.
- **Spéculation** : le Bitcoin est conçu pour prendre de la valeur avec le temps (déflation). La Ğ1 est conçue pour maintenir un **équilibre** entre les membres (convergence à la moyenne).
- **Énergie** : le Bitcoin consomme autant d'électricité qu'un pays de taille moyenne. La Ğ1, qui utilise un consensus par toile de confiance (et non par preuve de travail), a une empreinte énergétique négligeable.
Dire que la Ğ1 est une crypto est donc techniquement correct mais sémantiquement trompeur. C'est comme dire qu'un vélo et un char d'assaut sont tous les deux des véhicules : c'est vrai, mais ça ne dit pas grand-chose d'utile.
## Introduction sur un DeX vs. CeX
Dans l'univers crypto, on distingue les **CeX** (Centralized Exchanges) et les **DeX** (Decentralized Exchanges).
Un **CeX** est une plateforme centralisée (comme Binance ou Coinbase) où un intermédiaire gère les ordres d'achat et de vente, détient les fonds des utilisateurs, et applique ses propres règles. C'est pratique, mais c'est un point de centralisation et de vulnérabilité : si la plateforme fait faillite ou se fait pirater, les utilisateurs perdent tout (cf. l'affaire FTX).
Un **DeX** est un protocole décentralisé où les échanges se font directement entre utilisateurs, via des smart contracts, sans intermédiaire de confiance. C'est plus lent, parfois plus complexe, mais c'est plus cohérent avec l'esprit de décentralisation.
La Ğ1 n'est pas cotée sur les exchanges crypto classiques (ni CeX ni DeX). C'est un choix délibéré : la June n'est pas un actif spéculatif. Elle ne doit pas être achetée et revendue comme un token. Elle doit être **co-créée** par ses membres et **utilisée** dans l'économie réelle.
## Question du « bankrun »
Le « bankrun » est un scénario dans lequel tous les détenteurs d'une monnaie cherchent simultanément à la convertir en une autre, provoquant l'effondrement de sa valeur.
Ce scénario est pertinent pour les monnaies adossées à une réserve (comme les stablecoins ou les monnaies locales convertibles). Si la réserve est insuffisante pour couvrir toutes les conversions, le système s'effondre.
La Ğ1 n'est **pas** sujette au bankrun, pour une raison simple : elle n'est adossée à rien. Il n'y a pas de réserve en euro, pas de promesse de conversion, pas de « prix plancher ». La valeur de la Ğ1 repose uniquement sur la **confiance** des membres dans le réseau et sur l'**utilité** de la monnaie dans l'économie réelle.
Si tous les membres cessaient d'utiliser la Ğ1 demain, elle perdrait effectivement toute valeur. Mais ce scénario est le même pour n'importe quelle monnaie, y compris l'euro : une monnaie vaut quelque chose parce que des gens l'acceptent. Si plus personne ne l'accepte, elle ne vaut plus rien.
La meilleure protection contre le « bankrun » de la June est le développement de l'économie réelle en monnaie libre. Plus il y a de biens et services disponibles en June, plus la monnaie est utile, plus les membres ont intérêt à la conserver et à l'utiliser.
## Réseau monétique
Le **réseau monétique** de la Ğ1 est l'ensemble des outils techniques qui permettent d'effectuer des transactions en monnaie libre.
L'infrastructure repose sur **Duniter**, le logiciel qui gère la blockchain de la Ğ1. Duniter est un logiciel libre, développé par la communauté, qui implémente les règles de la TRM : Dividende Universel, toile de confiance, consensus décentralisé.
Côté utilisateur, plusieurs applications permettent d'interagir avec la Ğ1 :
- **Cesium** : l'application historique (web et mobile) pour gérer son compte, envoyer et recevoir des Ğ1
- **Tikka** : une application mobile plus récente et plus ergonomique
- **Gchange** : une place de marché en ligne pour publier des annonces de vente/achat en Ğ1
Le réseau monétique de la Ğ1 est encore jeune et en développement actif. L'ergonomie et la fiabilité des outils s'améliorent continuellement. C'est l'un des chantiers les plus importants de la communauté : des outils simples et fiables sont indispensables pour l'adoption à grande échelle.
La transition de Duniter v1 vers v2 est en cours, avec des améliorations significatives en termes de performance, de scalabilité et de fonctionnalités.
## Le logiciel libre
La monnaie libre et le logiciel libre partagent un ADN commun. Les quatre libertés du logiciel libre (utiliser, étudier, modifier, redistribuer) font écho aux quatre libertés économiques de la TRM.
Richard Stallman, le fondateur du mouvement du logiciel libre, a montré dès les années 1980 qu'un commun numérique — le code source — pouvait être géré de manière coopérative, sans propriétaire exclusif, par une communauté de contributeurs bénévoles. Le résultat est impressionnant : Linux, Firefox, WordPress, LibreOffice, et des milliers d'autres logiciels libres sont aujourd'hui utilisés par des milliards de personnes.
La monnaie libre est au système monétaire ce que le logiciel libre est au système informatique : une alternative fondée sur la **liberté**, la **transparence** et la **coopération**.
Toute l'infrastructure technique de la Ğ1 est en logiciel libre. Le code est ouvert, auditable, modifiable par quiconque. Les développeurs contribuent bénévolement (souvent rémunérés en June par la communauté). C'est un commun numérique au service d'un commun monétaire.
> Coder la liberté, ce n'est pas seulement écrire du logiciel libre. C'est aussi coder les règles d'une monnaie libre — une monnaie dont le « code source » est ouvert, compréhensible et juste.
Cette convergence entre logiciel libre et monnaie libre n'est pas un hasard. C'est le même mouvement de fond : la conviction que les infrastructures essentielles de la société — le code informatique, le code monétaire — doivent être des **communs**, gérés démocratiquement, au bénéfice de tous.

View File

@@ -1,73 +0,0 @@
---
title: "Autres greffes"
description: "Applications concrètes de l'économie du don dans divers secteurs : emploi, ESS, agriculture, artisanat, éducation."
order: 9
readingTime: "15 min"
---
L'économie de greffe ne se limite pas aux marchés et aux circuits alimentaires. Elle peut se déployer dans de nombreux secteurs de la vie économique et sociale. Ce chapitre explore quelques pistes de greffes, certaines déjà amorcées, d'autres encore en gestation.
## Pôle Emploi et mission locale
Les demandeurs d'emploi sont parmi les premières victimes de l'asymétrie monétaire. Sans revenu en euro, ils sont exclus de l'économie — alors même qu'ils ont des compétences, du temps et de l'énergie à offrir.
La monnaie libre offre une issue à cette impasse. Un demandeur d'emploi peut produire et échanger en June, développer ses compétences, entretenir son réseau, et maintenir une activité économique réelle — même en l'absence de « travail » au sens classique.
Les Pôles Emploi et les missions locales pourraient jouer un rôle de relais en orientant les demandeurs d'emploi vers les communautés June locales. Non pas comme un substitut à l'emploi salarié, mais comme un **complément** qui maintient le lien social et économique pendant les périodes de transition.
Certaines expériences locales vont dans ce sens : des ateliers de présentation de la monnaie libre organisés en partenariat avec des structures d'insertion, des Ğmarchés accueillant des personnes en réinsertion professionnelle.
## ESS (Économie Sociale et Solidaire)
L'Économie Sociale et Solidaire partage de nombreuses valeurs avec la monnaie libre : solidarité, gouvernance démocratique, primauté de l'humain sur le capital, ancrage territorial.
Les structures de l'ESS — coopératives, mutuelles, associations, fondations — sont des partenaires naturels pour le développement de l'économie en monnaie libre. Elles disposent de réseaux, de compétences juridiques, d'une légitimité institutionnelle.
Les greffes possibles sont nombreuses :
- Des **AMAP** (Associations pour le Maintien d'une Agriculture Paysanne) qui acceptent la June
- Des **ressourceries** et **recycleries** qui pratiquent le double pricing
- Des **coopératives d'activité** qui accompagnent des entrepreneurs en monnaie libre
- Des **tiers-lieux** qui accueillent des Ğmarchés et des ateliers
Le dialogue avec l'ESS est aussi l'occasion de faire connaître la monnaie libre à un public plus large, et de montrer qu'elle n'est pas un gadget technologique mais un outil de transformation sociale.
## Associations populaires et caritatives
Les associations caritatives — Restos du Cœur, Secours Populaire, Emmaüs, etc. — distribuent des biens aux plus démunis. Leur action est indispensable, mais elle maintient une logique d'**assistance** : les bénéficiaires reçoivent, mais ne participent pas en tant qu'acteurs économiques.
La monnaie libre propose un changement de paradigme. Plutôt que de distribuer des biens, on peut distribuer du **pouvoir d'achat** en June. Le bénéficiaire n'est plus un assisté passif : il devient un **acteur économique** qui choisit librement ce qu'il achète, à qui, et à quel moment.
Ce passage de l'assistance à l'**autonomie** est fondamental. Il restaure la dignité des personnes en situation de précarité. Il les intègre dans un réseau d'échange où elles sont traitées comme des égales — pas comme des bénéficiaires de charité.
Des expériences pilotes associant monnaie libre et action caritative pourraient ouvrir des perspectives considérables.
## Productions agricoles, maraîchages
L'agriculture est le secteur le plus naturellement adapté à la monnaie libre. Les maraîchers produisent des biens essentiels, en circuit court, à une échelle compatible avec les communautés locales.
De nombreux maraîchers acceptent déjà la June, en tout ou en partie. Certains vont plus loin : ils achètent des semences, du matériel, des services en June. Ils créent ainsi des mini-filières en monnaie libre, depuis la semence jusqu'à l'assiette.
Le défi pour l'agriculture en monnaie libre est celui de la **viabilité économique**. Un maraîcher doit payer ses charges en euro (foncier, matériel, assurances, cotisations). Tant que ces charges ne sont pas couvertes en June, la part de l'activité en monnaie libre reste limitée.
La solution passe par la densification du réseau : plus il y a de producteurs et de prestataires qui acceptent la June, plus chacun peut couvrir ses besoins en monnaie libre, et moins il dépend de l'euro.
## Artisanat — Commerce — Entreprise
L'artisanat offre un terrain fertile pour la monnaie libre. Les artisans travaillent souvent en solo ou en petite équipe, ils sont proches de leurs clients, et leur production est locale par nature.
Menuisiers, couturiers, réparateurs, électriciens, plombiers, boulangers... autant de métiers qui peuvent intégrer la June dans leur activité. Le modèle le plus courant est le **double pricing** : une partie en euro (pour couvrir les charges incompressibles) et une partie en June.
Pour les commerces et les entreprises de taille plus importante, l'intégration de la June demande une réflexion comptable et organisationnelle plus poussée. Mais les exemples existent : des boutiques qui acceptent la June, des prestataires de services informatiques qui facturent en DU.
## Lycées — Écoles
L'éducation est un terrain d'expérimentation passionnant pour la monnaie libre. Apprendre aux jeunes comment fonctionne la monnaie — pas seulement comment la gagner et la dépenser, mais comment elle est créée, par qui, selon quelles règles — est un enjeu civique majeur.
Des initiatives existent : des ateliers sur la monnaie libre dans des lycées, des projets pédagogiques autour de la Ğ1, des simulations d'économie en monnaie libre avec des classes.
L'intérêt pédagogique est triple :
- **Économique** : comprendre la création monétaire, l'inflation, les systèmes monétaires
- **Mathématique** : manipuler les notions de croissance, de convergence, de symétrie
- **Civique** : réfléchir à la gouvernance des communs, à la démocratie économique
Les jeunes qui découvrent la monnaie libre réagissent souvent avec enthousiasme. L'idée qu'une autre monnaie est possible — et qu'elle existe déjà — ouvre des horizons que l'enseignement classique de l'économie tend à fermer.

View File

@@ -1,81 +0,0 @@
---
title: "Créer une économie ?"
description: "Passer de la théorie à la pratique : produire, greffer une économie du don sur le tissu local, inverser les flux."
order: 6
readingTime: "25 min"
---
Après la théorie, la pratique. Après avoir compris *pourquoi* une autre monnaie est nécessaire et *comment* elle fonctionne, la question qui brûle est : **comment construire concrètement une économie du don ?**
La réponse n'est pas de table rase. On ne détruit pas l'économie existante pour en construire une autre à la place. On **greffe**. On crée des passerelles. On développe des circuits parallèles qui, petit à petit, deviennent des alternatives crédibles.
## Produire
Toute économie commence par la **production**. Pas de production, pas d'échange. Pas d'échange, pas d'économie. La question première est donc : que produire en monnaie libre ?
La réponse est : tout ce dont la communauté a besoin. Des légumes, du pain, des vêtements, des réparations, des cours, des soins, des spectacles, des logiciels, des hébergements... La production en monnaie libre n'est pas cantonnée à un secteur : elle couvre potentiellement tous les besoins humains.
En pratique, les premiers producteurs en monnaie libre sont souvent des **artisans et des maraîchers** — des gens qui produisent à petite échelle, en circuit court, et qui sont proches de leur communauté. Mais on trouve aussi des informaticiens, des thérapeutes, des enseignants, des artistes.
Le point commun de ces producteurs, c'est qu'ils acceptent d'être rémunérés (en partie ou en totalité) en June. Ils font confiance à la communauté pour que cette monnaie ait de la valeur — c'est-à-dire pour que d'autres producteurs acceptent aussi la June, et qu'on puisse l'échanger contre des biens et services utiles.
## « Passer la seconde »
L'expression « passer la seconde » décrit le moment où une communauté monnaie-libre passe du stade de l'expérimentation au stade de l'**économie réelle**. C'est le moment où la June cesse d'être un jeu ou une curiosité pour devenir un outil économique fonctionnel.
Ce passage se caractérise par plusieurs marqueurs :
- Des producteurs **réguliers** (pas seulement occasionnels) acceptent la June
- Des **circuits d'échange** stables se forment entre producteurs et consommateurs
- La June commence à circuler « en boucle » : A paie B en June, B paie C, C paie A
- Les membres commencent à couvrir une partie significative de leurs besoins en June
« Passer la seconde » n'est pas un événement ponctuel. C'est un processus graduel, qui nécessite patience, persévérance et organisation collective.
## Économie de greffe
Le concept d'**économie de greffe** est central dans notre approche. Plutôt que de construire une économie alternative isolée, nous proposons de *greffer* l'économie en monnaie libre sur l'économie existante.
Concrètement, cela signifie que la plupart des producteurs en June acceptent aussi l'euro. Ils pratiquent un **double pricing** : un prix en euro et un prix en June (exprimé en DU). Le client choisit son moyen de paiement.
Cette approche présente plusieurs avantages :
- Elle ne demande pas aux producteurs de renoncer à l'euro du jour au lendemain
- Elle permet une transition progressive
- Elle expose de nouveaux publics à la monnaie libre
- Elle crée des ponts entre les deux économies
La greffe n'est pas un compromis ou une demi-mesure. C'est une **stratégie** de transition. L'objectif à long terme est que la part de l'économie en monnaie libre croisse naturellement, à mesure que la communauté grandit et que les circuits d'échange se multiplient.
## Connaître son bassin de vie
Pour greffer efficacement, il faut connaître son **bassin de vie** : les personnes, les activités, les ressources, les besoins du territoire. C'est un travail d'enquête et de cartographie qui peut sembler fastidieux, mais qui est indispensable.
Quels producteurs locaux pourraient accepter la June ? Quels services manquent ? Quels besoins ne sont pas satisfaits par l'économie classique ? Quelles compétences sont disponibles ? Où sont les forces vives ?
Cette connaissance du terrain permet de cibler les efforts : développer les circuits où la demande est forte, attirer les producteurs dont l'offre correspond aux besoins, organiser des événements qui rassemblent la communauté.
Les Groupes Locaux June (GLJ) jouent un rôle essentiel dans cette cartographie. Ce sont des collectifs informels de monnaie-libristes qui se réunissent régulièrement sur un territoire donné. Ils organisent des marchés, des rencontres, des ateliers. Ils sont les chevilles ouvrières de l'économie de greffe.
## Gestion « à l'anglaise »
La « gestion à l'anglaise » est une métaphore jardinière. En jardinage anglais, on ne cherche pas à tout contrôler comme dans un jardin à la française. On plante, on observe, on accompagne la croissance, on taille quand c'est nécessaire — mais on laisse la nature faire son travail.
L'économie du don fonctionne de la même manière. On ne peut pas la planifier de manière centralisée. On ne peut pas décider d'en haut qui doit produire quoi, qui doit échanger avec qui, à quel prix. Ce serait contradictoire avec l'esprit même de la liberté.
En revanche, on peut **créer les conditions** favorables :
- Organiser des marchés où les producteurs se rencontrent
- Faciliter la certification de nouveaux membres
- Animer la communauté (forums, événements, communication)
- Documenter et partager les bonnes pratiques
- Résoudre les problèmes techniques (outils informatiques, applications)
Le rôle des organisateurs n'est pas de diriger, mais de **faciliter**. Ils créent le cadre, et la communauté remplit le cadre selon ses propres dynamiques.
## Économie de flux — inversés
Dans l'économie classique, les flux vont du bas vers le haut : l'argent remonte des consommateurs vers les producteurs, puis vers les actionnaires, puis vers les marchés financiers. C'est une économie d'**extraction** : la valeur est extraite des territoires et concentrée dans les centres de pouvoir financier.
L'économie du don propose d'**inverser les flux**. La monnaie naît en bas — chez les individus, par le Dividende Universel — et circule horizontalement entre pairs. Il n'y a pas de centre d'accumulation. La richesse reste dans le territoire, circule entre les membres, fertilise l'économie locale.
Cette inversion des flux n'est pas une redistribution. La redistribution suppose qu'on prend aux riches pour donner aux pauvres — ce qui maintient la logique d'accumulation, simplement corrigée a posteriori. L'inversion des flux change la **source** même de la monnaie. On ne corrige pas les inégalités : on supprime le mécanisme qui les crée.
> Inverser les flux, ce n'est pas redistribuer la richesse. C'est changer l'endroit où la richesse naît.

View File

@@ -1,53 +0,0 @@
---
title: "De quel don parlons-nous ?"
description: "Exploration philosophique et sociologique du don, des asymétries communautaires aux expériences concrètes."
order: 2
readingTime: "20 min"
---
Le mot « don » est piégé. Il charrie des siècles de connotations religieuses, morales, sentimentales. Il évoque la charité, le sacrifice, la générosité — autant de notions nobles mais qui obscurcissent ce dont nous voulons parler. Alors, de quel don parlons-nous ?
Nous parlons d'un don **économique**. Un don qui circule, qui se mesure, qui s'organise. Pas un don qui s'oppose à l'économie, mais un don qui *est* l'économie — ou du moins qui pourrait en devenir le principe organisateur.
Pour comprendre cette proposition, il faut d'abord déconstruire quelques idées reçues. C'est l'objet de ce chapitre.
## Trois mots de philo et de socio
Trois penseurs sont incontournables quand on parle du don : **Marcel Mauss**, **Jacques Derrida** et **Alain Caillé**.
Marcel Mauss, dans son *Essai sur le don* (1925), a montré que dans les sociétés dites « archaïques », le don n'est jamais gratuit. Il obéit à une triple obligation : **donner, recevoir, rendre**. Le don crée du lien social. Il engage des relations de réciprocité. Il structure la communauté. Le don maussien n'est pas un acte isolé de générosité : c'est un système social complet.
Jacques Derrida, quant à lui, a posé une question vertigineuse : le don est-il seulement possible ? Pour qu'il y ait don véritable, il faudrait que le donateur n'attende rien en retour — pas même de la reconnaissance. Dès qu'on identifie un don comme tel, il cesse d'être un don pour devenir un échange. Le don pur serait donc impossible, ou du moins impensable. Cette aporie derridienne n'est pas un obstacle pour nous : elle est une boussole. Elle nous rappelle que le don n'est jamais simple, jamais acquis, jamais achevé.
Alain Caillé et le mouvement du MAUSS (Mouvement Anti-Utilitariste en Sciences Sociales) proposent une troisième voie. Pour eux, le don est un **paradigme** — une manière de penser les relations humaines qui ne se réduit ni à l'intérêt (utilitarisme) ni au devoir (moralisme). Le don est un acte libre, mais pas arbitraire. Il est conditionnel, mais pas calculé. Il crée de l'obligation, mais pas de la dette.
Ces trois perspectives éclairent notre propos :
- Avec Mauss, nous comprenons que le don est **structurant** : il crée de la société.
- Avec Derrida, nous comprenons que le don est **exigeant** : il résiste à la réduction.
- Avec Caillé, nous comprenons que le don est **possible** : il constitue un paradigme viable.
## Asymétries et Communautés
L'un des problèmes fondamentaux de toute économie est la question de l'**asymétrie**. Dans notre système actuel, les asymétries sont partout : asymétrie de pouvoir entre employeur et employé, asymétrie d'information entre producteur et consommateur, asymétrie de création monétaire entre banques et citoyens.
Ces asymétries ne sont pas des accidents. Elles sont **constitutives** du système. Le capitalisme ne fonctionne pas *malgré* les asymétries — il fonctionne *par* les asymétries. Le profit naît de la différence. L'accumulation naît de l'inégalité.
Dans une économie du don, les asymétries ne disparaissent pas — ce serait naïf de le prétendre. Mais elles changent de nature. L'asymétrie n'est plus un levier d'extraction, mais un moteur de circulation. Celui qui a plus donne plus. Celui qui sait transmet. Celui qui peut aide. Non pas par obligation morale, mais parce que le système rend cette circulation **naturelle et avantageuse pour tous**.
La notion de **communauté** est centrale ici. Le don ne fonctionne qu'au sein d'un groupe qui se reconnaît comme tel. Ce n'est pas nécessairement une communauté géographique ou ethnique — c'est une communauté de **confiance**. Les membres se font suffisamment confiance pour donner sans garantie immédiate de retour. Cette confiance n'est pas aveugle : elle est construite, entretenue, vérifiée par la pratique.
Dans la communauté June, cette confiance se construit par la **toile de confiance** (Web of Trust) : chaque nouveau membre est certifié par des membres existants qui le connaissent personnellement. Ce mécanisme garantit que chaque compte correspond à un être humain réel et vivant. Pas de bots, pas de comptes fictifs, pas de manipulation.
## Le cas emblématique « eco si nuestra »
L'Espagne nous offre un exemple remarquable avec le réseau **« eco si nuestra »** (notre éco). Ce mouvement, né dans le sillage de la crise de 2008, a développé des réseaux d'échange basés sur des monnaies locales et complémentaires.
Le principe est simple : des communautés créent leur propre monnaie pour faciliter les échanges locaux. Quand l'euro se fait rare — parce que le chômage explose, parce que les banques ne prêtent plus — ces monnaies locales permettent aux gens de continuer à échanger, à produire, à vivre.
Ce qui est remarquable dans l'expérience espagnole, c'est la vitesse à laquelle ces initiatives se sont développées face à la crise. Quand le système officiel faillit, les gens inventent spontanément des alternatives. Le don et l'échange non-monétaire ne sont pas des curiosités anthropologiques : ce sont des reflexes de survie et de solidarité.
## L'expérience « made in zion »
Plus proche de nous, l'expérience « made in zion » illustre une autre facette du don organisé. Ici, c'est la dimension **culturelle et identitaire** du don qui est mise en avant. Le don devient un acte de résistance, une affirmation d'autonomie face aux circuits économiques dominants.
Ces expériences montrent que le don n'est pas une abstraction théorique. Il se pratique, il s'organise, il se vit — dans des contextes très divers, face à des défis très concrets. Ce qui manque, ce n'est pas la volonté ni l'imagination. C'est un **cadre théorique solide** et des **outils techniques adaptés** pour passer à l'échelle. C'est précisément ce que proposent la TRM et la monnaie libre.

View File

@@ -1,69 +0,0 @@
---
title: "Échanger"
description: "Organiser les échanges dans une économie du don : filières, boucles, distribution et marchés Ğ1."
order: 7
readingTime: "18 min"
---
Produire ne suffit pas. Encore faut-il que la production **circule** — qu'elle atteigne ceux qui en ont besoin, au bon moment, au bon endroit. C'est la question de l'échange, qui est au cœur de toute économie.
Dans l'économie classique, l'échange est organisé par le marché et médié par le prix. Offre et demande se rencontrent, le prix s'ajuste, les biens circulent. Ce mécanisme est efficace, mais il a un coût : il exclut ceux qui n'ont pas de monnaie, il uniformise les valeurs, il favorise les intermédiaires.
Dans l'économie du don, l'échange prend d'autres formes. Il est plus direct, plus personnel, plus ancré dans la relation.
## Filières et boucles
L'un des objectifs de l'économie de greffe est de créer des **filières** et des **boucles** d'échange en monnaie libre.
Une **filière** est une chaîne de production-distribution : le maraîcher produit des légumes, le boulanger achète des légumes au maraîcher et vend du pain, le client achète du pain et des légumes. Si toute la filière fonctionne en June, la monnaie circule en interne sans avoir besoin d'être convertie en euro.
Une **boucle** va plus loin : c'est un circuit fermé où la monnaie revient à son point de départ. A paie B, B paie C, C paie A. La boucle est le Graal de l'économie locale : elle garantit que la monnaie reste dans le territoire et qu'elle profite à tous les participants.
Construire des filières et des boucles demande de la coordination. Il faut identifier les maillons manquants (quels producteurs manquent pour boucler la boucle ?) et les inciter à rejoindre l'aventure. C'est un travail de **tissage économique**, patient mais gratifiant.
En pratique, les premières boucles qui se forment sont souvent alimentaires : maraîcher → marché → consommateur → maraîcher. L'alimentation est le besoin le plus universel et le plus fréquent, ce qui en fait le meilleur point de départ pour construire des circuits en monnaie libre.
## Distribuer
La question de la **distribution** est souvent négligée dans les projets de monnaie alternative. On se concentre sur la production et l'échange, mais on oublie la logistique : comment acheminer les produits du producteur au consommateur ?
Dans l'économie classique, la distribution est assurée par un réseau dense et efficace de supermarchés, de plateformes de livraison, de grossistes. Concurrencer ce réseau est illusoire. Mais le compléter — offrir des alternatives pour ceux qui veulent consommer autrement — est tout à fait possible.
Les formes de distribution en monnaie libre sont variées :
- **Marchés physiques** : les marchés June sont des événements réguliers où producteurs et consommateurs se retrouvent
- **Vente directe** : de la ferme à l'assiette, sans intermédiaire
- **Groupements d'achat** : des consommateurs se regroupent pour commander ensemble
- **Plateformes en ligne** : des annonces de produits et services en June
Chaque forme a ses avantages et ses limites. Le marché physique crée du lien social mais demande de l'organisation. La vente directe est simple mais géographiquement limitée. La plateforme en ligne est accessible mais impersonnelle.
## Connecter avec l'existant
L'économie du don ne vit pas en vase clos. Elle coexiste avec l'économie classique, et elle doit **s'y connecter** intelligemment.
La connexion prend plusieurs formes :
- **Double pricing** : les producteurs affichent un prix en euro et un prix en DU
- **Paiement mixte** : le client paie une partie en June et une partie en euro
- **Passerelles comptables** : les entreprises qui acceptent la June tiennent une comptabilité qui intègre les deux monnaies
Cette connexion est pragmatique. Elle reconnaît que, pour l'instant, personne ne peut vivre à 100% en monnaie libre. Les charges fixes (loyer, impôts, assurances) se paient en euro. Le lait, le carburant, l'électricité aussi — du moins tant que ces filières ne sont pas développées en June.
Mais chaque nouveau producteur qui accepte la June, chaque nouvelle filière qui se crée, réduit la dépendance à l'euro. C'est une transition, pas une révolution. Et les transitions réussies sont celles qui avancent pas à pas, sans brûler les ponts.
## Ğ(marchés)
Les **Ğmarchés** (prononcer « Junmarchés ») sont les marchés physiques en monnaie libre. Ce sont des lieux de rencontre, d'échange et de convivialité où producteurs et consommateurs se retrouvent régulièrement.
Un Ğmarché typique rassemble une dizaine à une trentaine de stands : légumes, fruits, pain, miel, savons, vêtements, artisanat, services de massage, couture, informatique... Les prix sont affichés en DU. Les paiements se font via l'application Ğ1 sur smartphone ou par un système de bons papier pour les moins connectés.
Les Ğmarchés ne sont pas seulement des lieux d'échange économique. Ce sont des lieux de **vie communautaire**. On y vient aussi pour discuter, partager un repas, apprendre, s'entraider. L'aspect social est au moins aussi important que l'aspect économique.
La fréquence des Ğmarchés varie selon les territoires : hebdomadaire dans les zones les plus actives, mensuel ailleurs. Certains sont itinérants, d'autres ont un lieu fixe. Certains sont couplés à des événements culturels (concerts, conférences, ateliers).
Le succès d'un Ğmarché dépend de plusieurs facteurs :
- La **diversité de l'offre** : plus les stands sont variés, plus les visiteurs trouvent ce qu'ils cherchent
- La **régularité** : un marché prévisible fidélise les clients
- L'**ambiance** : un marché convivial attire et retient
- La **communication** : faire connaître le marché au-delà de la communauté existante
Les Ğmarchés sont la vitrine de l'économie du don. Ils montrent concrètement que cette économie fonctionne, qu'elle produit de la valeur, qu'elle crée du lien. Pour beaucoup de nouveaux venus, le premier Ğmarché est le déclic qui les convainc de rejoindre l'aventure.

View File

@@ -1,51 +0,0 @@
---
title: "Et maintenant ?… action ?"
description: "L'appel à l'action : comment rejoindre le mouvement, participer et construire ensemble une économie du don."
order: 10
readingTime: "8 min"
---
Vous avez lu ce livre — ou du moins une partie. Vous avez écouté les chansons — ou du moins certaines. Et maintenant ?
La question n'est pas rhétorique. Ce livre n'est pas un traité académique destiné à rester sur une étagère. C'est un **appel à l'action**. Un appel à rejoindre, à construire, à expérimenter.
## Par où commencer ?
Si vous êtes convaincu — ou même simplement curieux — voici quelques pistes concrètes :
**1. Créer son compte Ğ1.** C'est la première étape. Rejoindre la toile de confiance, recevoir sa certification, et commencer à co-créer de la monnaie. Le processus prend quelques semaines (le temps de rencontrer des membres existants et d'être certifié), mais il est gratuit et ouvert à tous.
**2. Rejoindre un Groupe Local June (GLJ).** Les GLJ sont les cellules vivantes de la communauté. Ils organisent des rencontres, des marchés, des ateliers. C'est le meilleur endroit pour rencontrer des monnaie-libristes, poser des questions, et découvrir l'économie en June de l'intérieur.
**3. Participer à un Ğmarché.** Même sans rien à vendre, allez voir un Ğmarché. Observez comment ça fonctionne. Goûtez un pain fait maison payé en DU. Discutez avec les producteurs. Sentez l'ambiance.
**4. Proposer un bien ou un service.** Vous savez faire quelque chose ? Proposez-le en June. Cours de guitare, réparation d'ordinateur, traduction, baby-sitting, confection de confitures... Toute compétence a de la valeur.
**5. Parler autour de vous.** Le bouche-à-oreille est le premier vecteur de développement de la communauté. Parlez de la monnaie libre à vos proches, à vos collègues, à vos voisins. Prêtez ce livre. Partagez les chansons.
## Les RML et événements
Les **Rencontres de la Monnaie Libre** (RML) sont des événements régionaux et nationaux qui rassemblent la communauté. Pendant plusieurs jours, les participants échangent, débattent, présentent leurs projets, et font vivre l'économie en June à grande échelle.
Les RML sont des moments forts de la vie communautaire. On y fait des rencontres, on y noue des liens, on y prend de l'énergie. C'est souvent lors d'une RML qu'on passe du statut de « curieux » à celui de « monnaie-libriste convaincu ».
D'autres événements ponctuent l'année : les Journées Inter-Nodales (JIN), les ateliers techniques, les formations, les fêtes locales avec Ğmarché intégré.
## Questions ouvertes
Ce livre ne prétend pas avoir toutes les réponses. Beaucoup de questions restent ouvertes :
- **L'échelle** : la monnaie libre peut-elle fonctionner à l'échelle d'un pays, d'un continent ? Ou est-elle condamnée à rester locale ?
- **La transition** : comment articuler la coexistence euro/June sur le long terme ? L'objectif est-il de remplacer l'euro ou de le compléter ?
- **La gouvernance** : comment prendre des décisions collectives dans une communauté décentralisée ? Comment éviter les dérives ?
- **La technologie** : les outils actuels (blockchain Duniter, applications Cesium/Tikka) sont-ils suffisamment robustes et accessibles ?
Ces questions ne sont pas des obstacles. Ce sont des **chantiers** — des invitations à la réflexion et à l'expérimentation collectives.
## Ce que nous pouvons ensemble
> Une économie du don — enfin concevable.
Le titre de ce livre est une promesse. Pas une promesse de résultat, mais une promesse de **possibilité**. Les outils existent. La théorie est solide. Les expériences sont encourageantes. Ce qui manque, c'est le nombre. Plus nous serons nombreux à expérimenter, à produire, à échanger, à imaginer — plus l'économie du don deviendra non seulement concevable, mais réelle.
Ce livre est un don. Les chansons sont un don. Faites-en ce que vous voulez. Partagez-les, discutez-les, critiquez-les, prolongez-les. C'est ainsi que le don circule.

View File

@@ -1,36 +0,0 @@
---
title: "Introduction"
description: "Un livre et des chansons pour rendre concevable une économie du don, portée par la communauté monnaie-libriste."
order: 1
readingTime: "15 min"
---
Ce livre est une façon de raconter ce que nous vivons, ce que nous expérimentons, et ce que nous pensons être possible. Il ne prétend pas être un manuel d'économie, encore moins un traité de philosophie. C'est un récit collectif, un témoignage d'expérience, un partage de convictions.
Nous sommes un petit groupe de personnes qui, depuis plusieurs années, explorons une idée simple mais radicale : **une économie fondée sur le don est non seulement souhaitable, mais concevable**. Et nous pensons que les outils pour la construire existent déjà.
Ce livre est accompagné de neuf chansons. Elles ne sont pas là pour décorer. Elles racontent le livre autrement, par la musique et la poésie. Chaque chanson éclaire un aspect du propos, ouvre une porte émotionnelle là où le texte reste analytique. Elles sont aussi un don — librement accessibles, librement partageables.
## Le projet
L'économie du don n'est pas une utopie lointaine. C'est une pratique quotidienne que nous connaissons tous : le repas préparé pour sa famille, le coup de main donné au voisin, le logiciel libre partagé sur Internet, le savoir transmis à un élève. Ce qui est nouveau, ce n'est pas le don. C'est l'idée qu'on puisse en faire le **fondement** d'un système économique entier.
Pour cela, il faut répondre à des questions difficiles. Comment mesurer sans dénaturer ? Comment échanger sans exploiter ? Comment produire sans détruire ? Comment organiser sans dominer ?
Ce livre propose des pistes. Il s'appuie sur des travaux théoriques rigoureux — notamment la Théorie Relative de la Monnaie (TRM) de Stéphane Laborde — et sur des expériences concrètes menées par des communautés en France et ailleurs.
## Monnaie-libristes
Nous nous appelons « monnaie-libristes ». Ce néologisme désigne les personnes qui utilisent, promeuvent ou contribuent au développement de monnaies libres — c'est-à-dire de monnaies dont la création est symétrique entre tous les membres, présents et futurs.
La monnaie libre la plus aboutie aujourd'hui est la **Ğ1** (prononcer « June »). Créée en 2017, elle est fondée sur les principes de la TRM. Chaque membre co-crée la même quantité de monnaie, chaque jour, par un Dividende Universel (DU). Personne ne contrôle la création monétaire. Personne n'en est exclu.
Les monnaie-libristes forment une communauté diverse : informaticiens, agriculteurs, artisans, enseignants, artistes, retraités. Ce qui les rassemble, c'est la conviction qu'une monnaie juste est le socle d'une économie juste. Et qu'une économie juste rend le don non seulement possible, mais naturel.
La communauté June compte aujourd'hui plusieurs milliers de membres en France et dans le monde francophone. Des marchés en June se tiennent régulièrement. Des producteurs vendent en June. Des services s'échangent en June. Une économie réelle, encore modeste mais vivante, se construit jour après jour.
Ce livre raconte cette aventure. Il tente d'en expliquer les fondements théoriques, d'en montrer les réalisations pratiques, et d'en dessiner les perspectives. Ce n'est pas un livre de certitudes. C'est un livre de convictions partagées, ouvert à la discussion et à la critique.
> « Une économie du don — enfin concevable » : le titre de ce livre est à la fois un constat et un programme. Le constat que les outils existent. Le programme qu'il reste à les déployer.
Bonne lecture. Et si le cœur vous en dit, bonne écoute.

View File

@@ -1,26 +0,0 @@
---
title: "La mesure du don"
description: "Comment mesurer le don sans le dénaturer ? Le retournement sémantique qui rend la mesure possible."
order: 3
readingTime: "8 min"
---
Mesurer le don : l'expression semble contradictoire. Si le don est libre, spontané, désintéressé, comment peut-on le mesurer sans le trahir ? Et pourtant, si nous voulons construire une *économie* du don — c'est-à-dire un système organisé et durable — il faut bien des repères, des unités, des mesures.
Le piège, c'est de confondre **mesurer** et **tarifer**. Tarifer, c'est fixer un prix, c'est-à-dire une condition d'accès. Mesurer, c'est observer une grandeur, c'est-à-dire un état de fait. On peut mesurer la température sans la contrôler. On peut mesurer le don sans le commander.
Dans une économie du don, la mesure sert à **informer**, pas à **conditionner**. Elle permet de savoir où sont les besoins, où sont les ressources, comment les flux circulent. Elle n'impose pas de comportement. Elle éclaire les choix.
## Retournement sémantique
Voici le retournement sémantique fondamental que ce livre propose : dans notre économie actuelle, la monnaie mesure le **prix** des choses — c'est-à-dire ce qu'il faut *sacrifier* pour les obtenir. Dans une économie du don, la monnaie mesure le **don** — c'est-à-dire ce qui circule *librement* entre les personnes.
Ce retournement change tout. Quand la monnaie mesure le prix, elle crée de la rareté : il faut posséder de la monnaie pour accéder aux biens. Quand la monnaie mesure le don, elle crée de la **fluidité** : la monnaie accompagne la circulation des biens, elle ne la bloque pas.
Dans le système de la monnaie libre Ğ1, chaque personne co-crée la même quantité de monnaie chaque jour — le Dividende Universel. Cette création monétaire n'est pas un « revenu » au sens classique : c'est une **unité de mesure** distribuée symétriquement. Elle permet à chacun de mesurer ses dons et ses réceptions, sans que personne ne soit exclu faute de monnaie.
La mesure du don n'est donc pas une contradiction. C'est une **nécessité** pour organiser le don à grande échelle. Et le retournement sémantique — passer de la mesure du prix à la mesure du don — est la clé conceptuelle qui rend l'opération possible.
> Quand on change ce que la monnaie mesure, on change la société qu'elle organise.
Ce retournement n'est pas seulement théorique. Il a des conséquences pratiques considérables, que les chapitres suivants vont explorer : sur la nature même de la monnaie, sur la manière de produire, d'échanger, de s'organiser collectivement.

View File

@@ -1,93 +0,0 @@
---
title: "La TRM — Théorie Relative de la Monnaie"
description: "Les principes fondamentaux de la Théorie Relative de la Monnaie de Stéphane Laborde : symétrie, relativité et Dividende Universel."
order: 5
readingTime: "35 min"
---
La **Théorie Relative de la Monnaie** (TRM), formulée par Stéphane Laborde en 2010, est le socle théorique de la monnaie libre. Elle ne propose pas un modèle économique parmi d'autres : elle pose les **conditions mathématiques** qu'une monnaie doit remplir pour respecter les libertés fondamentales de ses utilisateurs.
La TRM est à la monnaie ce que la théorie de la relativité est à la physique : un changement de référentiel qui transforme radicalement notre compréhension. Tout comme Einstein a montré qu'il n'y a pas d'observateur privilégié dans l'univers physique, la TRM montre qu'il ne devrait pas y avoir de créateur privilégié dans l'univers monétaire.
## Flux monétaire et vie humaine
Le point de départ de la TRM est une observation simple mais fondamentale : **les êtres humains naissent, vivent et meurent**. Ils ne sont pas éternels. Toute théorie monétaire qui ignore cette réalité biologique est incomplète.
Dans le système actuel, la monnaie peut s'accumuler indéfiniment. Des patrimoines se transmettent sur des siècles. La monnaie survit aux individus qui l'ont créée ou gagnée. Cela crée une asymétrie fondamentale entre les générations : les premiers arrivés captent une part disproportionnée de la monnaie, et les suivants doivent se battre pour les miettes.
La TRM intègre la durée de vie humaine comme paramètre fondamental. Elle prend en compte le renouvellement des générations — en moyenne, une demi-vie de 40 ans dans une communauté donnée. Ce paramètre détermine le taux de création monétaire nécessaire pour maintenir la symétrie entre les membres.
Le flux monétaire doit être pensé en rapport avec le flux de la vie humaine. Quand un nouveau membre rejoint la communauté, il doit pouvoir participer à l'économie au même titre que les membres les plus anciens. Quand un membre quitte la communauté (par décès ou départ), la monnaie qu'il a co-créée se dilue naturellement dans le temps, sans mécanisme d'héritage ou de transmission forcée.
## Symétrie dans l'espace-temps
La TRM définit quatre libertés économiques, par analogie avec les libertés du logiciel libre :
1. **Liberté de choix du système monétaire** : tout individu est libre d'utiliser la monnaie de son choix
2. **Liberté d'accès aux ressources** : la monnaie ne doit pas créer de barrière artificielle
3. **Liberté de production** : chacun peut produire toute valeur qu'il estime utile
4. **Liberté d'échange** : chacun peut échanger librement avec qui il veut
Pour que ces quatre libertés soient respectées, la TRM démontre qu'une seule forme de création monétaire est compatible : le **Dividende Universel** (DU). Chaque membre de la communauté crée la même quantité de monnaie, à chaque période, quels que soient son ancienneté, son âge, son activité ou sa situation.
La symétrie est **spatiale** (tous les membres créent la même chose au même moment) et **temporelle** (les membres futurs auront le même pouvoir de création que les membres présents). C'est cette double symétrie qui garantit la liberté.
## Relativité
La TRM introduit un concept crucial : la **relativité de la valeur**. Il n'existe pas de valeur absolue. Un bien n'a pas de prix intrinsèque — il a un prix *relatif* à l'observateur, au contexte, au moment.
Dans le système actuel, on traite les prix comme s'ils étaient absolus : « cette maison vaut 200 000 euros ». Mais ce prix n'a de sens que dans un contexte donné : un contexte de taux d'intérêt, de masse monétaire, d'offre et de demande locales. Changez le contexte, et le prix change.
La TRM propose de raisonner en **parts relatives** plutôt qu'en unités absolues. Au lieu de dire « j'ai 1000 Ğ1 », on dit « j'ai X % de la masse monétaire moyenne par membre ». Cette approche relative permet de comparer des situations dans le temps et dans l'espace, indépendamment de la masse monétaire totale.
En pratique, dans la Ğ1, on exprime souvent les prix en DU plutôt qu'en unités absolues. Un objet qui « vaut 10 DU » vaut l'équivalent de 10 jours de création monétaire d'un individu. Cette unité relative est beaucoup plus stable et significative qu'un nombre absolu.
## Ancienneté
Un aspect souvent mal compris de la monnaie libre est la question de l'**ancienneté**. Dans le système actuel, ceux qui sont arrivés en premier ont accumulé plus de monnaie. Dans la monnaie libre, est-ce différent ?
Oui et non. Oui, à un instant donné, un membre ancien a co-créé plus de DU qu'un membre récent. Mais non, cette différence n'est pas permanente ni croissante. Elle converge vers une valeur maximale déterminée par la durée de vie humaine.
Mathématiquement, le solde d'un membre qui ne fait qu'accumuler ses DU sans rien dépenser converge vers une valeur finie. Et la part relative de chaque ancien membre dans la masse monétaire totale diminue à mesure que de nouveaux membres arrivent et créent leur propre monnaie.
C'est la grande différence avec le système actuel : dans la monnaie libre, le temps joue en faveur de l'**égalisation**, pas de la concentration. Les écarts se résorbent naturellement, sans intervention politique ni redistribution forcée.
## Volume des offres
La TRM a aussi des implications sur le **volume des offres** — c'est-à-dire la quantité de biens et services disponibles dans l'économie.
Dans le système fiat, le volume des offres est contraint par la disponibilité du crédit. Si les banques ne prêtent pas, les entreprises ne peuvent pas investir, les consommateurs ne peuvent pas acheter, et la production stagne — même si les ressources physiques et humaines sont disponibles. C'est le paradoxe d'une économie riche en capacités mais pauvre en monnaie.
Dans une économie en monnaie libre, le volume des offres est libéré de cette contrainte. Chaque membre dispose en permanence de sa création monétaire pour participer à l'économie. La demande n'est plus rationnée par le crédit. Le volume des offres peut ainsi s'ajuster aux besoins réels plutôt qu'aux décisions des banques.
## Diversité des offres
La **diversité des offres** est un autre avantage structurel de la monnaie libre. Dans le système actuel, le crédit bancaire favorise les projets les plus « rentables » au sens financier — c'est-à-dire ceux qui génèrent le plus de profit monétaire. Les projets sociaux, culturels, environnementaux ou simplement atypiques peinent à trouver un financement.
Dans la monnaie libre, chaque membre décide librement comment utiliser sa création monétaire. Il n'y a pas de filtre bancaire, pas de business plan à soumettre, pas de rentabilité à prouver. Un artisan peut vendre son travail en June. Un artiste peut être rémunéré en June. Un voisin peut rendre service et recevoir des June en échange.
Cette liberté d'usage favorise la **diversité** des offres. On voit apparaître dans les marchés June des produits et services qu'on ne trouverait jamais dans l'économie classique : cours de musique, massages, légumes du jardin, réparation de vélos, conseils juridiques, cours de langue, travaux de couture...
## Échelles de valeurs
Un concept fondamental de la TRM est que **chaque individu a sa propre échelle de valeurs**. Ce qui est précieux pour l'un ne l'est pas nécessairement pour l'autre. Et cette diversité des échelles de valeurs est non seulement normale, mais **souhaitable**.
Le système de prix unique du marché impose une échelle de valeurs commune : le prix en euros. Mais cette uniformité est une illusion. Derrière un même prix, deux acheteurs peuvent avoir des motivations totalement différentes. Le prix ne reflète pas la valeur — il reflète le pouvoir de négociation.
Dans la monnaie libre, les échanges se font de gré à gré, à des « prix » librement négociés entre les parties. Il n'y a pas de référentiel de prix centralisé. Chacun évalue selon ses propres critères. Cette approche peut sembler chaotique, mais elle est en réalité plus honnête : elle reconnaît que la valeur est **subjective et contextuelle**.
## Convergence à la moyenne
L'un des résultats les plus remarquables de la TRM est la démonstration de la **convergence à la moyenne**. Dans un système à Dividende Universel, les comptes des membres convergent naturellement vers une valeur moyenne, exprimée en proportion de la masse monétaire par membre.
Concrètement : même si des écarts importants existent à un moment donné entre les comptes des membres, ces écarts se réduisent inévitablement avec le temps. Un membre très riche en Ğ1 voit sa part relative diminuer à mesure que de nouvelles créations monétaires arrivent. Un membre pauvre voit sa part relative augmenter.
Cette convergence n'est pas le résultat d'un impôt ou d'une redistribution. C'est une propriété **mathématique** du Dividende Universel. Elle est automatique, impartiale et irrésistible. C'est la symétrie en action.
## Commun monétaire
Le concept de **commun monétaire** résume l'ambition de la TRM. La monnaie libre est un commun : une ressource partagée, co-produite par tous, gouvernée par des règles transparentes et immuables.
Contrairement aux monnaies fiat (gouvernées par des banques centrales) ou aux cryptomonnaies classiques (gouvernées par des algorithmes de minage qui favorisent les plus puissants), la monnaie libre est gouvernée par un principe simple et universel : chaque être humain crée la même part de monnaie.
Ce commun monétaire est le fondement de l'économie du don. En garantissant à chacun un accès égal et symétrique à la monnaie, il rend possible une économie où le don circule librement — non pas par charité, mais par structure.

View File

@@ -1,76 +0,0 @@
---
title: "Raison d'être d'une monnaie"
description: "Au-delà des trois fonctions classiques, explorer pourquoi nous avons besoin d'une monnaie — et pourquoi la monnaie actuelle est mal codée."
order: 4
readingTime: "30 min"
---
Pourquoi une monnaie ? La question semble triviale — tout le monde utilise de la monnaie, donc tout le monde sait à quoi elle sert. Mais cette familiarité est trompeuse. Elle masque des choix fondamentaux, des hypothèses cachées, des asymétries profondes.
Pour comprendre pourquoi nous avons besoin d'une *autre* monnaie, il faut d'abord comprendre ce qu'est vraiment la monnaie — au-delà de ce qu'on nous en enseigne.
## Au-delà de ses 3 fonctions
Les manuels d'économie définissent la monnaie par trois fonctions :
1. **Unité de compte** : elle permet de mesurer la valeur des biens et services
2. **Intermédiaire des échanges** : elle facilite les transactions
3. **Réserve de valeur** : elle permet d'épargner du pouvoir d'achat
Ces trois fonctions sont réelles, mais elles sont **insuffisantes** pour comprendre la monnaie. Elles décrivent ce que la monnaie *fait*, pas ce qu'elle *est*. Or, ce qu'elle est — c'est-à-dire la manière dont elle est créée, distribuée et gouvernée — détermine le type de société qu'elle engendre.
Une monnaie créée par le crédit bancaire (comme l'euro) produit une société de débiteurs et de créanciers. Une monnaie créée par un État produit une société de contribuables et de bénéficiaires. Une monnaie créée symétriquement par tous ses membres produit une société de co-créateurs égaux.
La question « à quoi sert une monnaie ? » doit donc être complétée par une autre question, plus profonde : **« qui crée la monnaie, et selon quelles règles ? »**
## Bassin économique
Un concept fondamental pour penser la monnaie est celui de **bassin économique**. Un bassin économique est l'ensemble des personnes et des activités qui utilisent une même monnaie. Ses frontières ne sont pas nécessairement géographiques : elles sont définies par les flux d'échange.
L'euro, par exemple, définit un bassin économique qui couvre vingt pays. Mais ce bassin est loin d'être homogène : les économies de l'Allemagne et de la Grèce sont très différentes, et le fait qu'elles partagent la même monnaie crée des tensions structurelles.
À l'inverse, une monnaie locale comme la June crée un bassin économique plus restreint mais plus cohérent. Les membres se connaissent, les besoins sont identifiés, les circuits sont courts. La monnaie circule là où elle est utile.
Le choix du bassin économique n'est pas neutre. Il détermine qui participe à l'économie et qui en est exclu. Un bassin trop large dilue les liens. Un bassin trop étroit limite les échanges. L'art de la monnaie, c'est aussi l'art de dessiner le bon bassin.
## Problèmes génétiques des monnaies fiat
Les monnaies « fiat » — c'est-à-dire les monnaies créées par décret, comme l'euro ou le dollar — souffrent de ce qu'on peut appeler des **problèmes génétiques**. Ces problèmes ne sont pas des bugs : ce sont des *features* du système, inscrites dans son code même.
## Leçon #1 : La création monétaire est asymétrique
Premier problème génétique : la monnaie fiat est créée par le **crédit bancaire**. Quand une banque accorde un prêt, elle ne prête pas de l'argent qu'elle possède — elle *crée* l'argent du prêt. Ce mécanisme, connu sous le nom de « création monétaire ex nihilo », signifie que toute la monnaie en circulation est née d'une dette.
Les conséquences sont profondes :
- Pour qu'il y ait de la monnaie, il faut qu'il y ait de la dette
- Les intérêts sur la dette nécessitent une croissance permanente de la masse monétaire
- Les banques ont un pouvoir considérable : elles décident *qui* a accès à la monnaie et *à quelles conditions*
Cette asymétrie de création monétaire est le péché originel du système fiat. Elle signifie que certains acteurs — les banques et leurs clients privilégiés — ont un accès à la monnaie que les autres n'ont pas. Ce n'est pas une question de mérite ou de productivité : c'est une question de **position dans le réseau** de création monétaire.
## Leçon #2 : La monnaie n'est pas neutre
Deuxième problème : la monnaie fiat prétend être un instrument neutre, un simple voile jeté sur les échanges « réels ». Cette fiction de la neutralité monétaire, héritée de l'économie classique, est contredite par l'expérience.
La monnaie **oriente** les comportements. Une monnaie fondée sur la dette encourage l'endettement. Une monnaie qui se raréfie encourage la thésaurisation. Une monnaie contrôlée par un petit nombre encourage la concentration du pouvoir.
Les politiques monétaires des banques centrales — taux directeurs, quantitative easing, etc. — sont la preuve flagrante que la monnaie n'est pas neutre. Chaque décision de politique monétaire redistribue la richesse, favorise certains acteurs au détriment d'autres, oriente l'économie dans une direction plutôt qu'une autre.
Si la monnaie n'est pas neutre, alors le choix de la monnaie est un **choix politique**. Et comme tout choix politique, il devrait être **démocratique** — ce qui est loin d'être le cas aujourd'hui.
## Leçon #3 : L'espace-temps monétaire
Troisième problème, et peut-être le plus subtil : la monnaie fiat ignore la **dimension temporelle** de l'économie. Elle traite les êtres humains comme des entités permanentes, alors qu'ils naissent, vivent et meurent.
Dans le système actuel, ceux qui possèdent de la monnaie la conservent indéfiniment (hors inflation). Les patrimoines se transmettent de génération en génération, créant des dynasties économiques. Les jeunes arrivent dans un monde où la monnaie est déjà distribuée — souvent très inégalement — et doivent « gagner » leur accès à des ressources que d'autres ont accumulées avant eux.
La TRM appelle ce problème l'absence de **symétrie temporelle**. Une monnaie juste devrait traiter de manière égale les individus présents et futurs. Elle devrait intégrer le fait que les êtres humains ont une durée de vie limitée, et que chaque génération devrait disposer du même pouvoir monétaire que les précédentes.
C'est précisément ce que fait le Dividende Universel de la Ğ1 : en créant de la monnaie de manière continue et symétrique, il garantit que chaque nouveau membre dispose de la même opportunité monétaire que ceux qui l'ont précédé. Avec le temps, les différences de solde entre les membres convergent vers la moyenne — non pas par redistribution forcée, mais par le mécanisme même de la création monétaire.
## Une économie mal codée
Pour résumer ce chapitre par une métaphore informatique : notre économie actuelle est **mal codée**. Elle repose sur un code monétaire qui produit structurellement de l'inégalité, de l'endettement et de l'exclusion. Ce n'est pas que les gens sont mauvais. C'est que les règles du jeu sont mauvaises.
Changer les règles du jeu monétaire, c'est changer le code de l'économie. C'est passer d'un code qui concentre le pouvoir à un code qui le distribue. D'un code qui mesure le prix à un code qui mesure le don. D'un code qui exclut à un code qui inclut.
La TRM propose précisément cela : un nouveau code monétaire, fondé sur la symétrie et la liberté. Le chapitre suivant en détaille les principes.

View File

@@ -1,76 +0,0 @@
---
title: "Relation institutionnelle"
description: "Naviguer dans le cadre légal et fiscal : impôts, TVA, cotisations sociales et financement de l'écosystème."
order: 8
readingTime: "20 min"
---
Développer une économie parallèle en monnaie libre ne se fait pas hors-sol. Il existe un cadre légal, fiscal et réglementaire qu'il faut connaître, respecter, et parfois interroger. Ce chapitre aborde les questions institutionnelles que rencontrent les monnaie-libristes dans leur pratique quotidienne.
## Impôts, taxes et cotisations
Question récurrente : faut-il déclarer ses revenus en June aux impôts ? La réponse est **oui**. En droit français, tout revenu est imposable, quelle que soit la forme sous laquelle il est perçu. Un producteur qui vend des légumes en June réalise un chiffre d'affaires au même titre que s'il vendait en euro.
En pratique, la valorisation en euro des transactions en June pose des questions techniques. Comment convertir des DU en euros pour la déclaration fiscale ? Il n'y a pas de taux de change officiel. La pratique la plus courante est de se référer aux prix pratiqués dans les Ğmarchés, ou d'établir une équivalence DU/euro basée sur les prix de produits comparables.
Les cotisations sociales suivent la même logique : un professionnel qui exerce en June est soumis aux mêmes obligations qu'un professionnel qui exerce en euro. Le statut juridique (auto-entrepreneur, association, coopérative) détermine le régime applicable.
Il est important de ne pas tomber dans le piège du « c'est de la monnaie alternative, donc c'est hors système ». Ce raisonnement est juridiquement faux et stratégiquement dangereux. L'économie du don n'a pas vocation à se soustraire à l'impôt. Elle a vocation à **transformer** le système de l'intérieur, en construisant une économie plus juste au sein du cadre légal existant.
## Environnement légal
La monnaie libre Ğ1 n'est pas une monnaie « officielle » au sens du Code monétaire et financier. Elle n'est ni émise ni garantie par une banque centrale. Juridiquement, elle s'apparente à un **système d'échange local** (SEL) ou à une **monnaie complémentaire**.
Le cadre légal français est relativement tolérant envers les monnaies complémentaires, à condition qu'elles respectent certaines règles :
- Pas de convertibilité automatique en euro (ce qui n'est pas le cas de la Ğ1 par design)
- Transparence des règles de fonctionnement
- Respect des obligations fiscales et sociales
- Pas de publicité mensongère ou de promesses de rendement
La loi ESS (Économie Sociale et Solidaire) de 2014 a donné un cadre juridique aux monnaies locales complémentaires (MLC), mais la Ğ1 ne rentre pas exactement dans cette catégorie. Elle n'est pas adossée à l'euro, elle n'est pas gérée par une association locale, elle est décentralisée et numérique.
Cette situation juridique « en marge » n'est pas un handicap. Elle laisse une liberté d'action importante, tant que les participants respectent le droit commun (déclaration des revenus, respect des normes sanitaires pour l'alimentaire, etc.).
## TVA
La question de la **TVA** (Taxe sur la Valeur Ajoutée) est l'une des plus complexes. En théorie, toute vente de bien ou service est soumise à la TVA si le vendeur est assujetti. Le fait que le paiement soit en June ne change rien.
En pratique, la grande majorité des producteurs en June sont des particuliers ou des micro-entrepreneurs en dessous du seuil de franchise de TVA. La question ne se pose donc pas pour eux.
Pour les structures plus importantes (associations, coopératives, entreprises), la TVA doit être collectée et reversée sur les ventes en June, exactement comme sur les ventes en euro. La base taxable est la valeur en euro de la transaction.
## Bénévolat et cotisations sociales
Une question délicate concerne le **bénévolat**. Beaucoup d'activités dans l'économie June s'apparentent à du bénévolat : on donne de son temps, de son énergie, de ses compétences, et on reçoit des June en échange. Mais juridiquement, le bénévolat est par définition non rémunéré. Si le bénévole reçoit une contrepartie (même en monnaie alternative), l'activité peut être requalifiée en **travail**, avec les obligations sociales qui s'y attachent.
Cette zone grise est source de confusion et d'inquiétude. La frontière entre le coup de main amical (qui n'a pas de valeur économique) et le service rémunéré (qui en a) n'est pas toujours claire.
La prudence recommande de distinguer clairement :
- Les **dons** purs (sans contrepartie attendue) — pas de problème juridique
- Les **échanges de services** occasionnels entre particuliers — tolérance administrative
- Les **activités régulières et organisées** — nécessitent un cadre juridique (auto-entrepreneur, association, etc.)
## Le financement de notre écosystème
Comment financer le développement de l'infrastructure de la monnaie libre ? Les serveurs, les développeurs, les événements, la communication — tout cela a un coût, souvent en euro.
Plusieurs mécanismes coexistent :
- **Dons en euro** : la communauté finance les projets par des dons via des plateformes comme Liberapay
- **Cotisations associatives** : certaines structures locales (GLJ, associations) collectent des cotisations
- **Contributions en nature** : des développeurs contribuent au code, des graphistes au design, des rédacteurs au contenu
- **Auto-financement en June** : à mesure que l'économie June se développe, une part croissante des coûts peut être couverte en monnaie libre
Le financement de l'écosystème est lui-même un exercice d'économie du don. On ne paie pas un « service » : on contribue à un **commun**. Et cette contribution est libre — dans son montant, sa forme et sa fréquence.
## Symboles et sémantique
Les mots comptent. Les symboles comptent. La manière dont on nomme et représente les choses influence la manière dont on les pense.
La Ğ1 a fait des choix symboliques forts :
- Le nom « June » évoque la chaleur, le mois de juin, le soleil — une image positive et accessible
- Le symbole Ğ1 (Ğ avec un 1) rappelle la singularité de chaque être humain dans le système
- Le Dividende Universel se mesure en « DU » — un acronyme simple qui évoque l'universalité
Ces choix ne sont pas cosmétiques. Ils sont **stratégiques**. Une monnaie qui porte un nom compliqué ou un symbole obscur aura du mal à se démocratiser. La June a réussi à créer une identité reconnaissable et sympathique, ce qui facilite son adoption.
La sémantique est aussi un enjeu politique. Parler de « don » plutôt que de « transaction », de « co-création monétaire » plutôt que de « revenu universel », de « commun » plutôt que de « propriété » — ces choix de mots reflètent et renforcent une vision du monde. Ils invitent à penser autrement.

View File

@@ -1,23 +1,23 @@
---
title: "À propos"
description: "Le Librodrome — une plateforme coopérative pour les productions collectives."
description: "Le librodrome — une plateforme coopérative pour les productions collectives."
---
# À propos du Librodrome
Le Librodrome est un espace coopératif dédié à la production collective, afin de couvrir nos besoins et nourrir nos plaisirs de vivre.
Ce site est voué à devenir une plateforme support pour les équipes qui se lanceront dans de telles productions, afin de pouvoir coopérer au sein des équipes et avec les autres initiatives.
Le librodrome sera également un événement, afin de réunir pysiquement tous les acteurs et faciliter les synergies. Cet événement sera l'occasion de présenter le logiciel libre et la monnaie libre, les deux outils stratégiques qui rendent possible la conception et la réalisation d'une économie alternative.
Le librodrome sera également un événement, afin de réunir pysiquement les acteurs d'un bassin de vie et faciliter les synergies. Cet événement sera l'occasion de présenter le logiciel libre et la monnaie libre, les deux outils stratégiques qui rendent possibles la conception et la réalisation d'une économie alternative.
Pour commencer : **« Une économie du don — enfin concevable »** accompagné de **9 chansons** qui racontent le livre.
Pour commencer : **« Une économie du don — enfin concevable »** accompagné de **chansons** qui racontent le livre.
## La vision
Nous croyons qu'une économie fondée sur le don est une possibilité concrète. Ce livre et ces chansons sont une invitation à repenser nos rapports économiques, en inversant les flux.
## L'expérience de lecture
## La proposition d'un modèle économique
Le Librodrome propose une expérience unique : une **lecture guidée synchronisée** où chaque chapitre est accompagné de sa chanson, et une **écoute libre** pour savourer la musique indépendamment.
Le bookPlayer : une **découverte musicale** où les chansons qui se succèdent racontent un ou deux chapitres.
## L'équipe
@@ -25,4 +25,4 @@ Le Librodrome est un projet coopératif. Si vous en avez le coeur, si vous avez
## La suite
Cette plateforme n'est qu'un début. Nous construisons un espace où d'autres projets de production collective pourront naître et grandir.
Cette plateforme n'est qu'un début. Nous construisons un espace où les projets de production collective pourront naître et grandir. La plateforme deviendra le prolongement des événements et autres rendez-vous opérationnels.

View File

@@ -11,13 +11,15 @@ WORKDIR /app
# Build
FROM base AS build
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN npm install
RUN npm rebuild sharp
RUN pnpm install --frozen-lockfile
RUN pnpm rebuild sharp
COPY . .
RUN npm run build
RUN pnpm run build
# Production
FROM base AS production
@@ -40,4 +42,5 @@ CMD [ "node", ".output/server/index.mjs" ]
FROM base AS development
WORKDIR /app
ENTRYPOINT [ "npm", "run", "dev" ]
RUN corepack enable
ENTRYPOINT [ "pnpm", "run", "dev" ]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

View File

@@ -4,6 +4,10 @@ export default defineNuxtConfig({
devtools: { enabled: true },
devServer: {
port: 3000,
},
modules: [
'@nuxt/content',
'@pinia/nuxt',

View File

@@ -17,9 +17,9 @@
"@vueuse/nuxt": "^14.2.1",
"better-sqlite3": "^12.6.2",
"nuxt": "^4.3.1",
"yaml": "^2.8.2",
"vue": "^3.5.28",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"yaml": "^2.8.2"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.91",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +0,0 @@
1:"$Sreact.fragment"
2:I[47257,["/gratewizard-app/_next/static/chunks/4392189f08a7af14.js"],"ClientPageRoot"]
3:I[31713,["/gratewizard-app/_next/static/chunks/a909fda476c687d1.js","/gratewizard-app/_next/static/chunks/7bf35e7250cf07fd.js","/gratewizard-app/_next/static/chunks/ffaecfd52c45f81a.js"],"default"]
6:I[97367,["/gratewizard-app/_next/static/chunks/4392189f08a7af14.js"],"OutletBoundary"]
7:"$Sreact.suspense"
0:{"buildId":"F7XQu-2bFPCrYCJiAbhKz","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/gratewizard-app/_next/static/chunks/a909fda476c687d1.js","async":true}],["$","script","script-1",{"src":"/gratewizard-app/_next/static/chunks/7bf35e7250cf07fd.js","async":true}],["$","script","script-2",{"src":"/gratewizard-app/_next/static/chunks/ffaecfd52c45f81a.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
8:null

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