Refactoring complet : contenu livre, config unique, routes, admin et light mode

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-26 20:20:52 +01:00
parent 4fce862df6
commit 2f438d9d7a
70 changed files with 2125 additions and 1385 deletions

View File

@@ -8,8 +8,8 @@ export default defineAppConfig({
height: '4rem',
nav: [
{ label: 'Autonomie', to: '/autonomie' },
{ label: 'Modèle éco', to: '/lire' },
{ label: 'En musique', to: '/ecouter' },
{ label: 'Modèle éco', to: '/modele-eco' },
{ label: 'En musique', to: '/en-musique' },
{ label: 'Évènement', to: '/evenement' },
{ label: 'À propos', to: '/a-propos' },
],

View File

@@ -22,7 +22,7 @@ useHead({
return title ? `${title} — Le Librodrome` : 'Le librodrome'
},
meta: [
{ name: 'description', content: 'Une économie du don — enfin concevable. Un livre et 9 chansons, lecture guidée et écoute libre.' },
{ name: 'description', content: 'Une économie du don — enfin concevable. Un livre et des chansons, lecture guidée et écoute libre.' },
],
})
</script>

View File

@@ -97,24 +97,24 @@ a {
color: hsl(var(--color-text)) !important;
}
/* white with opacity → dark text with same opacity */
.palette-light .text-white\/20 { color: hsl(var(--color-text) / 0.2) !important; }
.palette-light .text-white\/30 { color: hsl(var(--color-text) / 0.3) !important; }
.palette-light .text-white\/40 { color: hsl(var(--color-text) / 0.4) !important; }
.palette-light .text-white\/45 { color: hsl(var(--color-text) / 0.45) !important; }
.palette-light .text-white\/50 { color: hsl(var(--color-text) / 0.5) !important; }
.palette-light .text-white\/60 { color: hsl(var(--color-text) / 0.6) !important; }
.palette-light .text-white\/70 { color: hsl(var(--color-text) / 0.7) !important; }
.palette-light .text-white\/80 { color: hsl(var(--color-text) / 0.8) !important; }
.palette-light .text-white\/85 { color: hsl(var(--color-text) / 0.85) !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 */
.palette-light .bg-white\/5 { background-color: hsl(var(--color-text) / 0.04) !important; }
.palette-light .bg-white\/8 { background-color: hsl(var(--color-text) / 0.06) !important; }
.palette-light .bg-white\/10 { background-color: hsl(var(--color-text) / 0.07) !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 */
.palette-light .border-white\/8 { border-color: hsl(var(--color-text) / 0.1) !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,
@@ -123,42 +123,43 @@ a {
color: hsl(var(--color-text)) !important;
}
.palette-light .hover\:text-white\/60:hover {
color: hsl(var(--color-text) / 0.6) !important;
color: hsl(var(--color-text) / 0.7) !important;
}
.palette-light .hover\:bg-white\/5:hover {
background-color: hsl(var(--color-text) / 0.04) !important;
background-color: hsl(var(--color-primary) / 0.08) !important;
}
.palette-light .hover\:bg-white\/10:hover {
background-color: hsl(var(--color-text) / 0.07) !important;
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.6) !important;
color: hsl(var(--color-primary) / 0.7) !important;
}
/* placeholder overrides */
.palette-light .placeholder\:text-white\/30::placeholder {
color: hsl(var(--color-text) / 0.3) !important;
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 in light mode — needs transparent fill override */
/* text-gradient in light mode — vivid gradient */
.palette-light .text-gradient {
background-image: linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-accent)));
background-image: linear-gradient(135deg, hsl(var(--color-primary)), hsl(var(--color-accent)));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent !important;
}
/* card surfaces */
/* card surfaces — subtle shadow for depth */
.palette-light .card-surface {
background: hsl(var(--color-surface)) !important;
border-color: hsl(var(--color-text) / 0.1) !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 */
@@ -166,17 +167,36 @@ a {
color: white !important;
}
/* input fields */
/* input fields — cleaner contrast */
.palette-light input,
.palette-light textarea {
color: hsl(var(--color-text));
background-color: hsl(var(--color-surface));
border-color: hsl(var(--color-text) / 0.12);
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-text) / 0.1) !important;
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 */

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'>('edit')
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,13 +27,13 @@
<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" />

View File

@@ -217,17 +217,17 @@ const activeChapter = computed(() => {
// ── 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' },
{ slug: '01-introduction', title: 'Introduction' },
{ slug: '02-don', title: 'De quel don parlons-nous ?' },
{ slug: '03-mesure', title: 'La mesure du don' },
{ slug: '04-monnaie', title: 'Raison d\'être d\'une monnaie' },
{ slug: '05-trm', title: 'La TRM' },
{ slug: '06-economie', title: 'Créer une économie ?' },
{ slug: '07-echange', title: 'Échanger' },
{ slug: '08-institution', title: 'Relation institutionnelle' },
{ slug: '09-greffes', title: 'Autres greffes' },
{ slug: '10-maintenant', title: 'Et maintenant ?… action ?' },
{ slug: '11-annexes', title: 'Chapitres annexes' },
]
// ── Per-chapter color hues ──
@@ -424,7 +424,7 @@ 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')
const first = getSongs().find(s => s.id === 'ce-livre-est-une-facon')
if (first) {
_skipSongWatch = true
audioPlayer.loadAndPlay(first)

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}`"
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

@@ -29,6 +29,28 @@
<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 -->
@@ -135,7 +157,24 @@ const { data: content } = await usePageContent('home')
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

@@ -23,6 +23,25 @@
<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">
@@ -91,7 +110,25 @@ const { data: content } = await usePageContent('home')
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

@@ -124,6 +124,7 @@ const store = usePlayerStore()
const { setVolume, togglePlayPause, playNext } = useAudioPlayer()
useMediaSession()
useKeyboardShortcuts()
const widgetRef = ref<HTMLElement>()
const isExpanded = ref(false)

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

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

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

@@ -6,24 +6,36 @@
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="Contenu" open>
<AdminMarkdownEditor v-model="body" :rows="35" />
</AdminFormSection>
</template>
</div>
@@ -40,16 +52,33 @@ const slug = computed(() => route.params.slug as string)
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
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 ?? ''
// Parse frontmatter fields
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() : ''
}
const saving = ref(false)
const saved = ref(false)
@@ -57,12 +86,17 @@ async function save() {
saving.value = true
saved.value = false
try {
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 },
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
@@ -74,20 +108,24 @@ 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);
}

View File

@@ -1,18 +1,58 @@
<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>
<NuxtLink
:to="`/admin/book/${chapter.slug}`"
class="chapter-title"
>
{{ chapter.title }}
</NuxtLink>
<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 +63,74 @@ definePageMeta({
middleware: 'admin',
})
const { data: chapters } = await useFetch('/api/admin/chapters')
const { data: chapters, refresh } = await useFetch<any[]>('/api/admin/chapters')
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 +141,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 +149,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;
@@ -55,5 +180,64 @@ const { data: chapters } = await useFetch('/api/admin/chapters')
flex: 1;
color: white;
font-weight: 500;
text-decoration: none;
}
.chapter-title:hover {
color: hsl(12 76% 68%);
}
.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

@@ -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,5 +1,35 @@
<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 -->
@@ -23,7 +53,7 @@
<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 for perspective) -->
<!-- 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 -->
@@ -37,15 +67,38 @@
</svg>
<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>
<!-- 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">
@@ -99,6 +152,9 @@
{{ content?.noResults }}
</p>
</div>
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
@@ -107,10 +163,11 @@ definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('ecouter')
const { data: content } = await usePageContent('en-musique')
const { data: homeContent } = await usePageContent('home')
useHead({
title: content.value?.meta?.title ?? 'Écouter',
title: content.value?.meta?.title ?? 'En musique',
})
const store = usePlayerStore()
@@ -125,6 +182,8 @@ 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()
@@ -144,6 +203,50 @@ const filteredSongs = computed(() => {
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%;
@@ -155,12 +258,19 @@ const filteredSongs = computed(() => {
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

@@ -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}`"
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}`"
class="btn-ghost gap-2"
>
<span class="text-sm">{{ nextChapter.title }}</span>

View File

@@ -49,6 +49,30 @@
<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>
@@ -67,7 +91,7 @@
:key="chapter.path"
>
<NuxtLink
:to="`/lire/${chapter.stem}`"
:to="`/modele-eco/${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">
@@ -102,7 +126,7 @@ definePageMeta({
layout: 'default',
})
const { data: content } = await usePageContent('lire')
const { data: content } = await usePageContent('modele-eco')
useHead({
title: content.value?.meta?.title ?? 'Table des matières',
@@ -150,8 +174,26 @@ const { data: chapters } = await useAsyncData('book-toc', () =>
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>

View File

@@ -46,29 +46,29 @@ const palettes: Record<PaletteName, PaletteColors> = {
// ══════ LIGHT THEMES ══════
// Printemps : vert tendre, rose cerisier, lumière fraîche
// Printemps : vert vif, rose cerisier punchy, lumière fraîche
printemps: {
primary: '145 50% 38%', // vert bourgeon
accent: '340 65% 55%', // rose cerisier
surface: '120 12% 93%', // rosée du matin
bg: '100 15% 96%', // clarté verte
surfaceLight: '120 8% 87%', // feuille pâle
text: '150 15% 12%', // vert profond
textMuted: '140 8% 42%', // mousse
primary: '152 65% 36%', // vert émeraude vif
accent: '340 78% 52%', // rose cerisier punchy
surface: '130 18% 90%', // rosée du matin
bg: '110 20% 94%', // clarté verte
surfaceLight: '125 14% 84%', // feuille vive
text: '155 30% 10%', // vert profond saturé
textMuted: '145 14% 38%', // mousse riche
isLight: true,
label: 'Printemps',
icon: 'i-lucide-flower-2',
},
// Été : doré solaire, turquoise mer, lumineux chaleureux
// Été : orange solaire, turquoise pop, lumineux chaleureux
ete: {
primary: '25 85% 52%', // soleil couchant
accent: '175 55% 42%', // turquoise marin
surface: '40 25% 92%', // sable clair
bg: '42 30% 96%', // lumière dorée
surfaceLight: '38 18% 86%', // dune
text: '30 20% 12%', // terre chaude
textMuted: '30 10% 40%', // ombre estivale
primary: '22 92% 48%', // soleil éclatant
accent: '175 72% 38%', // turquoise pop
surface: '38 35% 88%', // sable doré
bg: '40 38% 93%', // lumière dorée chaude
surfaceLight: '35 28% 82%', // dune chaude
text: '28 35% 8%', // brun intense
textMuted: '28 16% 35%', // ombre chaude
isLight: true,
label: 'Été',
icon: 'i-lucide-sun',