initiation librodrome

This commit is contained in:
Yvv
2026-02-20 12:55:10 +01:00
commit 35e2897a73
208 changed files with 18951 additions and 0 deletions

29
app/app.config.ts Normal file
View File

@@ -0,0 +1,29 @@
export default defineAppConfig({
site: {
name: 'Le Librodrome',
description: 'Une économie du don — enfin concevable. Un livre et 9 chansons, lecture guidée et écoute libre.',
url: 'https://librodrome.org',
},
header: {
height: '4rem',
nav: [
{ label: 'Accueil', to: '/' },
{ label: 'Lire', to: '/lire' },
{ label: 'Écouter', to: '/ecouter' },
{ label: 'À propos', to: '/a-propos' },
],
},
footer: {
credits: '© 2026 Le Librodrome — Production collective',
links: [
{ label: 'Mentions légales', to: '/mentions-legales' },
],
},
gratewizard: {
url: '/gratewizard-app/',
popup: {
width: 420,
height: 720,
},
},
})

28
app/app.vue Normal file
View File

@@ -0,0 +1,28 @@
<template>
<div class="min-h-dvh">
<NuxtLoadingIndicator color="hsl(12, 76%, 48%)" />
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ClientOnly>
<PlayerPersistent />
</ClientOnly>
</div>
</template>
<script setup lang="ts">
const { data: site } = await useSiteContent()
useHead({
titleTemplate: (title) => {
const siteName = site.value?.identity.name ?? 'Le Librodrome'
return title ? `${title}${siteName}` : siteName
},
meta: [
{ name: 'description', content: site.value?.identity.description ?? '' },
],
})
</script>

View File

@@ -0,0 +1,166 @@
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 8px hsl(12 76% 48% / 0.3);
}
50% {
box-shadow: 0 0 24px hsl(12 76% 48% / 0.6);
}
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Utility classes for animations */
.animate-fade-in {
animation: fade-in 0.5s var(--ease-out-expo) both;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s var(--ease-out-expo) both;
}
.animate-fade-in-down {
animation: fade-in-down 0.6s var(--ease-out-expo) both;
}
.animate-slide-in-left {
animation: slide-in-left 0.5s var(--ease-out-expo) both;
}
.animate-slide-in-right {
animation: slide-in-right 0.5s var(--ease-out-expo) both;
}
.animate-scale-in {
animation: scale-in 0.4s var(--ease-out-expo) both;
}
.animate-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
/* Staggered animation delays */
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
/* Scroll reveal base state */
.scroll-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s var(--ease-out-expo),
transform 0.6s var(--ease-out-expo);
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Book Player 3D animations */
@keyframes fade-scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes book-open {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(-180deg);
}
}
@keyframes page-turn {
0% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(-180deg);
}
}
.animate-fade-scale-in {
animation: fade-scale-in 0.4s cubic-bezier(0.645, 0.045, 0.355, 1) both;
}

14
app/assets/css/fonts.css Normal file
View File

@@ -0,0 +1,14 @@
/* Font declarations are loaded via Bunny Fonts CDN in nuxt.config.ts */
/* This file provides fallback and utility classes */
.font-display {
font-family: 'Syne', system-ui, sans-serif;
}
.font-sans {
font-family: 'Space Grotesk', system-ui, sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}

90
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,90 @@
@import './fonts.css';
@import './animations.css';
@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-text: 0 0% 100%;
--color-text-muted: 0 0% 100% / 0.6;
--header-height: 4rem;
--player-height: 5rem;
--sidebar-width: 280px;
--font-display: 'Syne', sans-serif;
--font-sans: 'Space Grotesk', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
background-color: hsl(var(--color-bg));
color: hsl(var(--color-text));
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100dvh;
}
::selection {
background-color: hsl(var(--color-primary) / 0.3);
color: white;
}
:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--color-text) / 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--color-text) / 0.25);
}
/* Page transitions */
.page-enter-active,
.page-leave-active {
transition: opacity 300ms var(--ease-out-expo),
transform 300ms var(--ease-out-expo);
}
.page-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-8px);
}

View File

@@ -0,0 +1,173 @@
/* Prose styles for book chapter reading */
.prose {
font-family: var(--font-sans);
font-size: 1.125rem;
line-height: 1.8;
color: hsl(0 0% 100% / 0.90);
max-width: 65ch;
}
.prose h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 2.75rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
color: white;
margin-top: 0;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(12 76% 48% / 0.4);
}
.prose h2 {
font-family: var(--font-display);
font-size: clamp(1.625rem, 4vw, 2.125rem);
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.01em;
color: white;
margin-top: 3.5rem;
margin-bottom: 1rem;
padding-left: 0.75rem;
border-left: 3px solid hsl(12 76% 48% / 0.5);
}
.prose h3 {
font-family: var(--font-display);
font-size: clamp(1.25rem, 3vw, 1.625rem);
font-weight: 600;
line-height: 1.4;
color: hsl(0 0% 100% / 0.92);
margin-top: 3rem;
margin-bottom: 0.75rem;
}
.prose h3::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: hsl(36 80% 52%);
margin-right: 0.625rem;
vertical-align: middle;
position: relative;
top: -0.1em;
}
.prose h4 {
font-family: var(--font-display);
font-size: clamp(1.065rem, 2.5vw, 1.25rem);
font-weight: 600;
line-height: 1.45;
color: hsl(0 0% 100% / 0.85);
margin-top: 2.5rem;
margin-bottom: 0.625rem;
}
.prose h4::before {
content: '//';
font-family: var(--font-mono);
color: hsl(36 80% 52%);
margin-right: 0.5rem;
font-weight: 500;
}
/* Lede paragraph — first p after H2/H3 */
.prose h2 + p,
.prose h3 + p {
font-size: 1.175rem;
color: hsl(0 0% 100% / 0.75);
line-height: 1.85;
}
.prose p {
margin-top: 0;
margin-bottom: 1.5rem;
}
.prose a {
color: hsl(12 76% 68%);
text-decoration: underline;
text-decoration-color: hsl(12 76% 58% / 0.3);
text-underline-offset: 3px;
transition: text-decoration-color 0.2s;
}
.prose a:hover {
text-decoration-color: hsl(12 76% 58%);
}
.prose blockquote {
margin: 2rem 0;
padding: 1rem 1.5rem;
border-left: 3px solid hsl(12 76% 58%);
background: hsl(240 10% 8%);
border-radius: 0 0.5rem 0.5rem 0;
font-style: italic;
color: hsl(0 0% 100% / 0.75);
}
.prose blockquote p:last-child {
margin-bottom: 0;
}
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: hsl(240 10% 12%);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
color: hsl(31 97% 66%);
}
.prose pre {
margin: 2rem 0;
padding: 1.5rem;
background: hsl(240 10% 6%);
border: 1px solid hsl(0 0% 100% / 0.08);
border-radius: 0.75rem;
overflow-x: auto;
}
.prose pre code {
background: none;
padding: 0;
color: hsl(0 0% 100% / 0.87);
}
.prose ul,
.prose ol {
margin-top: 0;
margin-bottom: 1.5rem;
padding-left: 1.5rem;
}
.prose li {
margin-bottom: 0.5rem;
}
.prose li::marker {
color: hsl(12 76% 58%);
}
.prose hr {
margin: 3rem 0;
border: none;
border-top: 1px solid hsl(0 0% 100% / 0.1);
}
.prose strong {
color: white;
font-weight: 600;
}
.prose em {
color: hsl(0 0% 100% / 0.9);
}
.prose img {
border-radius: 0.75rem;
margin: 2rem 0;
}

View File

@@ -0,0 +1,117 @@
<template>
<div class="field">
<label class="field-label">{{ label }}</label>
<div
v-for="(item, i) in modelValue"
:key="i"
class="list-item"
>
<slot :item="item" :index="i" :update="(val: any) => updateItem(i, val)" />
<button class="list-remove" @click="removeItem(i)" aria-label="Supprimer">
<div class="i-lucide-x h-3.5 w-3.5" />
</button>
</div>
<button class="list-add" @click="addItem">
<div class="i-lucide-plus h-4 w-4" />
{{ addLabel }}
</button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: any[]
addLabel?: string
defaultItem?: () => any
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
function updateItem(index: number, value: any) {
const copy = [...props.modelValue]
copy[index] = value
emit('update:modelValue', copy)
}
function removeItem(index: number) {
const copy = [...props.modelValue]
copy.splice(index, 1)
emit('update:modelValue', copy)
}
function addItem() {
const newItem = props.defaultItem ? props.defaultItem() : {}
emit('update:modelValue', [...props.modelValue, newItem])
}
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.list-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
background: hsl(20 8% 5%);
}
.list-item > :deep(*:first-child) {
flex: 1;
}
.list-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
border: none;
background: hsl(0 60% 45% / 0.15);
color: hsl(0 60% 65%);
cursor: pointer;
flex-shrink: 0;
margin-top: 0.125rem;
transition: all 0.2s;
}
.list-remove:hover {
background: hsl(0 60% 45% / 0.3);
}
.list-add {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
border: 1px dashed hsl(20 8% 22%);
background: none;
color: hsl(20 8% 50%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.list-add:hover {
border-color: hsl(12 76% 48% / 0.4);
color: hsl(12 76% 68%);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="field">
<label :for="id" class="field-label">{{ label }}</label>
<input
:id="id"
:value="modelValue"
type="text"
class="field-input"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: string
placeholder?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.field-input {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="field">
<label :for="id" class="field-label">{{ label }}</label>
<textarea
:id="id"
:value="modelValue"
class="field-textarea"
:rows="rows"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: string
rows?: number
placeholder?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.field-textarea {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.875rem;
resize: vertical;
min-height: 5rem;
transition: border-color 0.2s;
font-family: inherit;
}
.field-textarea:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<details class="admin-section" :open="open">
<summary class="admin-section-header">
<div class="i-lucide-chevron-right h-4 w-4 transition-transform section-arrow" />
<span>{{ title }}</span>
</summary>
<div class="admin-section-body">
<slot />
</div>
</details>
</template>
<script setup lang="ts">
defineProps<{
title: string
open?: boolean
}>()
</script>
<style scoped>
.admin-section {
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
overflow: hidden;
margin-bottom: 1rem;
}
.admin-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.9rem;
color: white;
cursor: pointer;
background: hsl(20 8% 6%);
user-select: none;
}
.admin-section-header:hover {
background: hsl(20 8% 8%);
}
.admin-section[open] .section-arrow {
transform: rotate(90deg);
}
.admin-section-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="md-editor">
<div class="md-tabs">
<button
class="md-tab"
:class="{ 'md-tab--active': tab === 'edit' }"
@click="tab = 'edit'"
>
Édition
</button>
<button
class="md-tab"
:class="{ 'md-tab--active': tab === 'preview' }"
@click="tab = 'preview'"
>
Aperçu
</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>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
rows?: number
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const tab = ref<'edit' | 'preview'>('edit')
const renderedHtml = computed(() => {
// Simple markdown rendering for preview
return props.modelValue
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hp])(.+)/gm, '<p>$1</p>')
})
</script>
<style scoped>
.md-editor {
border: 1px solid hsl(20 8% 18%);
border-radius: 0.5rem;
overflow: hidden;
}
.md-tabs {
display: flex;
background: hsl(20 8% 6%);
border-bottom: 1px solid hsl(20 8% 14%);
}
.md-tab {
padding: 0.5rem 1rem;
border: none;
background: none;
color: hsl(20 8% 50%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.md-tab--active {
color: white;
background: hsl(20 8% 10%);
}
.md-textarea {
width: 100%;
padding: 1rem;
border: none;
background: hsl(20 8% 4%);
color: white;
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
line-height: 1.7;
resize: vertical;
min-height: 20rem;
}
.md-textarea:focus {
outline: none;
}
.md-preview {
padding: 1rem;
min-height: 20rem;
background: hsl(20 8% 4%);
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<div class="media-browser">
<!-- Toolbar -->
<div class="media-toolbar">
<div class="flex items-center gap-2">
<button
v-for="t in types"
:key="t.value"
class="filter-btn"
:class="{ 'filter-btn--active': filter === t.value }"
@click="filter = t.value"
>
{{ t.label }}
</button>
</div>
<span class="text-xs text-white/40">{{ filtered.length }} fichier(s)</span>
</div>
<!-- Grid -->
<div class="media-grid">
<div
v-for="file in filtered"
:key="file.path"
class="media-card"
:class="{ 'media-card--selected': selected === file.path }"
@click="selected = selected === file.path ? null : file.path"
>
<div v-if="file.type === 'image'" class="media-thumb">
<img :src="file.path" :alt="file.name" />
</div>
<div v-else class="media-icon">
<div
:class="file.type === 'audio' ? 'i-lucide-music' : file.type === 'document' ? 'i-lucide-file-text' : 'i-lucide-file'"
class="h-6 w-6"
/>
</div>
<div class="media-info">
<span class="media-name">{{ file.name }}</span>
<span class="media-size">{{ formatSize(file.size) }}</span>
</div>
</div>
</div>
<!-- Actions for selected -->
<div v-if="selected" class="media-actions">
<code class="text-xs text-accent">{{ selected }}</code>
<button class="delete-btn" @click="$emit('delete', selected)">
<div class="i-lucide-trash-2 h-4 w-4" />
Supprimer
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface MediaFile {
name: string
path: string
size: number
type: string
modifiedAt: string
}
const props = defineProps<{
files: MediaFile[]
}>()
defineEmits<{
delete: [path: string]
}>()
const filter = ref('all')
const selected = ref<string | null>(null)
const types = [
{ value: 'all', label: 'Tous' },
{ value: 'image', label: 'Images' },
{ value: 'audio', label: 'Audio' },
{ value: 'document', label: 'Documents' },
]
const filtered = computed(() => {
if (filter.value === 'all') return props.files
return props.files.filter(f => f.type === filter.value)
})
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<style scoped>
.media-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(20 8% 18%);
background: none;
color: hsl(20 8% 55%);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn--active {
background: hsl(12 76% 48% / 0.15);
border-color: hsl(12 76% 48% / 0.3);
color: hsl(12 76% 68%);
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.media-card {
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.media-card:hover {
border-color: hsl(20 8% 22%);
}
.media-card--selected {
border-color: hsl(12 76% 48%);
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3);
}
.media-thumb {
aspect-ratio: 1;
overflow: hidden;
background: hsl(20 8% 6%);
}
.media-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: hsl(20 8% 6%);
color: hsl(20 8% 40%);
}
.media-info {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.media-name {
font-size: 0.72rem;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-size {
font-size: 0.65rem;
color: hsl(20 8% 40%);
}
.media-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding: 0.75rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
background: hsl(20 8% 5%);
}
.delete-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(0 60% 45% / 0.3);
background: hsl(0 60% 45% / 0.1);
color: hsl(0 60% 65%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: hsl(0 60% 45% / 0.2);
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="upload-zone"
:class="{ 'upload-zone--active': isDragging }"
@dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,audio/*,.pdf"
class="hidden"
@change="handleFiles"
/>
<div v-if="uploading" class="upload-progress">
<div class="i-lucide-loader-2 h-6 w-6 animate-spin text-primary" />
<span>Upload en cours...</span>
</div>
<div v-else class="upload-content" @click="fileInput?.click()">
<div class="i-lucide-upload h-8 w-8 text-white/30 mb-2" />
<p class="text-sm text-white/50">Glissez des fichiers ici ou cliquez pour sélectionner</p>
<p class="text-xs text-white/30 mt-1">Images, audio, PDF</p>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
uploaded: [files: string[]]
}>()
const fileInput = ref<HTMLInputElement>()
const isDragging = ref(false)
const uploading = ref(false)
function handleDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (files) upload(files)
}
function handleFiles(e: Event) {
const target = e.target as HTMLInputElement
if (target.files) upload(target.files)
}
async function upload(files: FileList) {
uploading.value = true
try {
const formData = new FormData()
for (const file of files) {
formData.append('file', file)
}
const result = await $fetch<{ files: string[] }>('/api/admin/media/upload', {
method: 'POST',
body: formData,
})
emit('uploaded', result.files)
}
finally {
uploading.value = false
if (fileInput.value) fileInput.value.value = ''
}
}
</script>
<style scoped>
.upload-zone {
border: 2px dashed hsl(20 8% 22%);
border-radius: 0.75rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
border-color: hsl(12 76% 48% / 0.4);
}
.upload-zone--active {
border-color: hsl(12 76% 48%);
background: hsl(12 76% 48% / 0.05);
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: hsl(20 8% 55%);
font-size: 0.85rem;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<button
class="save-btn"
:class="{ 'save-btn--saving': saving, 'save-btn--saved': saved }"
:disabled="saving"
@click="$emit('save')"
>
<div v-if="saving" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
<div v-else-if="saved" class="i-lucide-check h-4 w-4" />
<div v-else class="i-lucide-save h-4 w-4" />
{{ saving ? 'Sauvegarde...' : saved ? 'Sauvegardé' : 'Sauvegarder' }}
</button>
</template>
<script setup lang="ts">
defineProps<{
saving?: boolean
saved?: boolean
}>()
defineEmits<{
save: []
}>()
</script>
<style scoped>
.save-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
border: none;
background: hsl(12 76% 48%);
color: white;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.save-btn:hover:not(:disabled) {
background: hsl(12 76% 42%);
}
.save-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.save-btn--saved {
background: hsl(140 50% 40%);
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<aside class="admin-sidebar">
<div class="sidebar-header">
<NuxtLink to="/admin" class="flex items-center gap-2 font-display text-lg font-bold">
<div class="i-lucide-settings h-5 w-5 text-primary" />
<span class="text-gradient">Admin</span>
</NuxtLink>
</div>
<nav class="sidebar-nav">
<p class="sidebar-section">Contenu</p>
<NuxtLink to="/admin" class="sidebar-link" exact-active-class="sidebar-link--active">
<div class="i-lucide-layout-dashboard h-4 w-4" />
Dashboard
</NuxtLink>
<NuxtLink to="/admin/site" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-globe h-4 w-4" />
Site
</NuxtLink>
<NuxtLink to="/admin/messages" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-message-square h-4 w-4" />
Messages
</NuxtLink>
<p class="sidebar-section">Pages</p>
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-home h-4 w-4" />
Accueil
</NuxtLink>
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-book-open h-4 w-4" />
Lire
</NuxtLink>
<NuxtLink to="/admin/pages/ecouter" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-headphones h-4 w-4" />
Écouter
</NuxtLink>
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-sparkles h-4 w-4" />
GrateWizard
</NuxtLink>
<p class="sidebar-section">Livre</p>
<NuxtLink to="/admin/book" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-list h-4 w-4" />
Chapitres
</NuxtLink>
<NuxtLink to="/admin/songs" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-music h-4 w-4" />
Chansons
</NuxtLink>
<p class="sidebar-section">Médias</p>
<NuxtLink to="/admin/media" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-image h-4 w-4" />
Navigateur
</NuxtLink>
</nav>
<div class="sidebar-footer">
<NuxtLink to="/" class="sidebar-link" target="_blank">
<div class="i-lucide-external-link h-4 w-4" />
Voir le site
</NuxtLink>
<button class="sidebar-link w-full" @click="logout">
<div class="i-lucide-log-out h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
</template>
<script setup lang="ts">
async function logout() {
await $fetch('/api/admin/auth/logout', { method: 'POST' })
navigateTo('/admin/login')
}
</script>
<style scoped>
.admin-sidebar {
background: hsl(20 8% 5%);
border-right: 1px solid hsl(20 8% 14%);
display: flex;
flex-direction: column;
height: 100dvh;
position: sticky;
top: 0;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid hsl(20 8% 14%);
}
.sidebar-nav {
flex: 1;
padding: 0.5rem;
overflow-y: auto;
}
.sidebar-section {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: hsl(20 8% 40%);
padding: 1rem 0.75rem 0.375rem;
margin: 0;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.85rem;
color: hsl(20 8% 60%);
text-decoration: none;
transition: all 0.2s;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.sidebar-link:hover {
background: hsl(20 8% 10%);
color: white;
}
.sidebar-link--active {
background: hsl(12 76% 48% / 0.12);
color: hsl(12 76% 68%);
}
.sidebar-footer {
padding: 0.5rem;
border-top: 1px solid hsl(20 8% 14%);
}
@media (max-width: 768px) {
.admin-sidebar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<Teleport to="body">
<Transition name="pdf-overlay">
<div
v-if="isOpen"
class="pdf-reader"
@keydown.escape="close"
tabindex="0"
ref="overlayRef"
>
<!-- Top bar -->
<div class="pdf-bar">
<div class="pdf-bar-title">
<div class="i-lucide-book-open h-4 w-4 text-accent" />
<span>{{ bpContent?.pdf.barTitle }}</span>
</div>
<button class="pdf-close" @click="close" aria-label="Fermer">
<div class="i-lucide-x h-5 w-5" />
</button>
</div>
<!-- PDF embed -->
<div class="pdf-viewport">
<iframe
:src="pdfUrl"
class="pdf-frame"
:title="bpContent?.pdf.iframeTitle"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { data: bpContent } = await usePageContent('book-player')
const overlayRef = ref<HTMLElement>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const pdfUrl = '/pdf/une-economie-du-don.pdf'
function close() {
isOpen.value = false
}
watch(isOpen, (open) => {
if (open) {
nextTick(() => overlayRef.value?.focus())
}
if (import.meta.client) {
document.body.style.overflow = open ? 'hidden' : ''
}
})
onUnmounted(() => {
if (import.meta.client) document.body.style.overflow = ''
})
</script>
<style scoped>
.pdf-reader {
position: fixed;
inset: 0;
z-index: 60;
background: hsl(20 8% 4%);
display: flex;
flex-direction: column;
outline: none;
}
.pdf-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: hsl(20 8% 6% / 0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid hsl(20 8% 14%);
flex-shrink: 0;
}
.pdf-bar-title {
display: flex;
align-items: center;
gap: 0.625rem;
font-family: var(--font-display, 'Syne', sans-serif);
font-size: 0.9rem;
font-weight: 600;
color: white;
}
.pdf-close {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: hsl(20 8% 10%);
color: hsl(20 8% 55%);
border: 1px solid hsl(20 8% 18%);
cursor: pointer;
transition: all 0.3s;
}
.pdf-close:hover {
background: hsl(12 76% 48% / 0.2);
color: white;
border-color: hsl(12 76% 48% / 0.3);
}
.pdf-viewport {
flex: 1;
position: relative;
overflow: hidden;
}
.pdf-frame {
width: 100%;
height: 100%;
border: none;
background: white;
}
/* Overlay transitions */
.pdf-overlay-enter-active {
animation: pdf-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.pdf-overlay-leave-active {
animation: pdf-enter 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
}
@keyframes pdf-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<template>
<article class="prose">
<ContentRenderer :value="content" />
</article>
</template>
<script setup lang="ts">
defineProps<{
content: any
}>()
</script>

View File

@@ -0,0 +1,62 @@
<template>
<header class="mb-8">
<div class="mb-2 flex items-center gap-2">
<span class="font-mono text-sm text-primary/70">
Chapitre {{ order }}
</span>
<span v-if="readingTime" class="text-xs text-white/30">
· {{ readingTime }}
</span>
</div>
<h1 class="chapter-title font-display font-bold leading-tight tracking-tight text-white">
{{ title }}
</h1>
<p v-if="description" class="mt-3 text-lg text-white/60">
{{ description }}
</p>
<!-- Associated songs badges -->
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
<button
v-for="song in songs"
:key="song.id"
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
@click="playSong(song)"
>
<div class="i-lucide-music h-3 w-3" />
{{ song.title }}
</button>
</div>
</header>
</template>
<script setup lang="ts">
import type { Song } from '~/types/song'
const props = defineProps<{
title: string
description?: string
order: number
readingTime?: string
chapterSlug: string
}>()
const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer()
await bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
function playSong(song: Song) {
loadAndPlay(song)
}
</script>
<style scoped>
.chapter-title {
font-size: clamp(2rem, 5vw, 2.75rem);
padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(12 76% 48% / 0.4);
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<nav class="chapter-nav" aria-label="Navigation des chapitres">
<h2 class="mb-4 font-display text-sm font-semibold uppercase tracking-wider text-white/40">
Chapitres
</h2>
<ul class="flex flex-col gap-1">
<li v-for="chapter in chapters" :key="chapter.path">
<NuxtLink
:to="`/lire/${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"
>
<span class="font-mono text-xs text-white/30 w-5 text-right">
{{ chapter.order }}
</span>
<span class="truncate">{{ chapter.title }}</span>
</NuxtLink>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
const { data: chapters } = await useAsyncData('book-chapters', () =>
queryCollection('book').order('order', 'ASC').all(),
)
</script>

View File

@@ -0,0 +1,49 @@
<template>
<button
v-if="song"
class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary transition-all hover:bg-primary/20 hover:scale-105 active:scale-95 my-2"
@click="handlePlay"
>
<div
:class="isCurrentAndPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="h-4 w-4"
/>
<span>{{ song.title }}</span>
<span class="font-mono text-xs text-primary/60">
{{ formatDuration(song.duration) }}
</span>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
song: string
}>()
const store = usePlayerStore()
const bookData = useBookData()
const { loadAndPlay, togglePlayPause } = useAudioPlayer()
await bookData.init()
const song = computed(() => bookData.getSongById(props.song))
const isCurrentAndPlaying = computed(() =>
store.currentSong?.id === props.song && store.isPlaying,
)
function handlePlay() {
if (store.currentSong?.id === props.song) {
togglePlayPause()
}
else if (song.value) {
loadAndPlay(song.value)
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,8 @@
<template>
<aside class="pull-quote my-8 rounded-xl border-l-4 border-accent bg-surface p-6">
<div class="i-lucide-quote mb-2 h-6 w-6 text-accent/40" />
<blockquote class="font-display text-lg font-medium leading-relaxed text-white/85 italic">
<slot />
</blockquote>
</aside>
</template>

View File

@@ -0,0 +1,92 @@
<template>
<section class="section-padding">
<div class="container-content">
<div class="grid items-center gap-12 md:grid-cols-2">
<!-- Book cover -->
<UiScrollReveal>
<div class="book-cover-wrapper">
<div class="book-cover-3d">
<img
:src="content?.book.coverImage"
:alt="content?.book.coverAlt"
class="book-cover-img"
/>
</div>
</div>
</UiScrollReveal>
<!-- Description -->
<div>
<UiScrollReveal>
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.bookPresentation.kicker }}</p>
<h2 class="heading-section font-display font-bold tracking-tight text-white">
{{ content?.bookPresentation.title }}
</h2>
</UiScrollReveal>
<UiScrollReveal
v-for="(paragraph, i) in content?.bookPresentation.description"
:key="i"
:delay="(i + 1) * 100"
>
<p class="mt-4 text-lg leading-relaxed text-white/60">
{{ paragraph }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="300">
<div class="mt-8">
<UiBaseButton :to="content?.bookPresentation.cta.to">
{{ content?.bookPresentation.cta.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</UiScrollReveal>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.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(20 8% 18%);
box-shadow:
0 12px 40px hsl(0 0% 0% / 0.5),
0 0 0 1px hsl(20 8% 15%);
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
box-shadow 0.5s ease;
max-width: 360px;
}
.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);
}
.book-cover-img {
width: 200%;
height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem);
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<section class="section-padding">
<div class="container-content">
<div class="grid items-center gap-12 md:grid-cols-2">
<!-- Book cover -->
<UiScrollReveal>
<div class="book-cover-wrapper">
<div class="book-cover-3d">
<img
:src="content?.book.coverImage"
:alt="content?.book.coverAlt"
class="book-cover-img"
/>
</div>
</div>
</UiScrollReveal>
<!-- Content + CTAs -->
<div>
<UiScrollReveal>
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p>
<h2 class="heading-section font-display font-bold tracking-tight text-white">
{{ content?.book.title }}
</h2>
</UiScrollReveal>
<UiScrollReveal :delay="100">
<p class="mt-4 text-lg leading-relaxed text-white/60">
{{ content?.book.description }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="200">
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
<UiBaseButton @click="$emit('open-player')">
<div class="i-lucide-play mr-2 h-5 w-5" />
{{ content?.book.cta.player }}
</UiBaseButton>
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
<div class="i-lucide-book-open mr-2 h-5 w-5" />
{{ content?.book.cta.pdf }}
</UiBaseButton>
</div>
</UiScrollReveal>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineEmits<{
'open-player': []
'open-pdf': []
}>()
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.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(20 8% 18%);
box-shadow:
0 12px 40px hsl(0 0% 0% / 0.5),
0 0 0 1px hsl(20 8% 15%);
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
box-shadow 0.5s ease;
max-width: 360px;
}
.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);
}
.book-cover-img {
width: 200%;
height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem);
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<section class="section-padding">
<div class="container-content">
<div class="mx-auto max-w-3xl text-center">
<UiScrollReveal>
<div class="i-lucide-users mx-auto h-12 w-12 text-accent/60 mb-6" />
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.cooperative.kicker }}</p>
<h2 class="heading-section font-display font-bold tracking-tight text-white">
{{ content?.cooperative.title }}
</h2>
</UiScrollReveal>
<UiScrollReveal
v-for="(paragraph, i) in content?.cooperative.description"
:key="i"
:delay="(i + 1) * 100"
>
<p class="mt-6 text-lg leading-relaxed text-white/60" :class="{ 'mt-4': i > 0 }">
{{ paragraph }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="300">
<div class="mt-8">
<UiBaseButton variant="ghost" :to="content?.cooperative.cta.to">
{{ content?.cooperative.cta.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</UiScrollReveal>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem);
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<section class="section-padding">
<div class="container-content">
<UiScrollReveal>
<div class="gw-card">
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8">
<!-- Icon -->
<div class="gw-icon-wrapper">
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
</div>
<!-- Content -->
<div class="flex-1">
<span class="inline-block mb-2 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
{{ content?.grateWizardTeaser.kicker }}
</span>
<h3 class="heading-h3 font-display font-bold text-white">
{{ content?.grateWizardTeaser.title }}
</h3>
<p class="mt-2 text-sm text-white/60 md:text-base leading-relaxed">
{{ content?.grateWizardTeaser.description }}
</p>
</div>
<!-- CTAs -->
<div class="shrink-0 flex flex-col gap-2">
<UiBaseButton @click="launch">
<div class="i-lucide-external-link mr-2 h-4 w-4" />
{{ content?.grateWizardTeaser.cta.launch }}
</UiBaseButton>
<UiBaseButton variant="ghost" :to="content?.grateWizardTeaser.cta.more.to">
{{ content?.grateWizardTeaser.cta.more.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</div>
</div>
</UiScrollReveal>
</div>
</section>
</template>
<script setup lang="ts">
const { launch } = useGrateWizard()
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.gw-card {
border: 1px solid hsl(40 80% 50% / 0.2);
border-radius: 1rem;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, hsl(40 80% 50% / 0.05), hsl(40 80% 50% / 0.02));
box-shadow: 0 0 40px hsl(40 80% 50% / 0.05);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.gw-card:hover {
border-color: hsl(40 80% 50% / 0.35);
box-shadow: 0 0 60px hsl(40 80% 50% / 0.1);
}
.heading-h3 {
font-size: clamp(1.25rem, 3vw, 1.625rem);
}
.gw-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 0.75rem;
background: hsl(40 80% 50% / 0.1);
border: 1px solid hsl(40 80% 50% / 0.15);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<section class="relative overflow-hidden section-padding">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
<!-- Content -->
<div class="container-content relative z-10 px-4">
<div class="mx-auto max-w-3xl text-center">
<UiScrollReveal>
<p class="mb-3 font-mono text-sm tracking-widest text-primary uppercase">
{{ content?.hero.kicker }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="100">
<h1 class="font-display font-extrabold leading-tight tracking-tight">
<span class="hero-title text-gradient">{{ content?.hero.title }}</span>
</h1>
</UiScrollReveal>
<UiScrollReveal :delay="200">
<p class="mt-6 text-lg leading-relaxed text-white/60 md:text-xl">
{{ content?.hero.subtitle }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="300">
<p class="mt-4 text-base leading-relaxed text-white/45">
{{ content?.hero.footnote }}
</p>
</UiScrollReveal>
<UiScrollReveal :delay="400">
<div class="mt-8 flex justify-center">
<UiBaseButton variant="ghost" :to="content?.hero.cta.to">
{{ content?.hero.cta.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</UiScrollReveal>
<HomeMessages />
</div>
</div>
</section>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('home')
</script>
<style scoped>
.hero-title {
font-size: clamp(2.25rem, 7vw, 4rem);
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="mt-16">
<!-- Formulaire -->
<UiScrollReveal :delay="500">
<div class="message-form-card">
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3>
<form v-if="!submitted" class="space-y-3" @submit.prevent="send">
<div class="grid gap-3 sm:grid-cols-2">
<input
v-model="form.author"
type="text"
placeholder="Votre nom *"
required
class="msg-input"
/>
<input
v-model="form.email"
type="email"
placeholder="Email (optionnel)"
class="msg-input"
/>
</div>
<textarea
v-model="form.text"
placeholder="Votre message *"
required
rows="3"
class="msg-input resize-none"
/>
<div class="flex justify-end">
<button type="submit" class="btn-primary text-sm" :disabled="sending">
<div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
Envoyer
</button>
</div>
</form>
<div v-else class="text-center py-4">
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" />
<p class="text-white/80">Merci pour votre message !</p>
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</p>
</div>
</div>
</UiScrollReveal>
<!-- 2 derniers messages publiés -->
<UiScrollReveal v-if="messages?.length" :delay="600">
<div class="mt-8 space-y-4">
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3>
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
<p class="text-white/80 text-sm leading-relaxed">{{ msg.text }}</p>
<div class="mt-2 flex items-center gap-2 text-xs text-white/40">
<span class="font-semibold text-white/60">{{ msg.author }}</span>
<span>&middot;</span>
<span>{{ formatDate(msg.createdAt) }}</span>
</div>
</div>
<div class="text-center">
<NuxtLink to="/messages" class="btn-ghost text-sm">
Voir tous les messages
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
</NuxtLink>
</div>
</div>
</UiScrollReveal>
</div>
</template>
<script setup lang="ts">
const { data: messages } = await useFetch('/api/messages')
const form = reactive({ author: '', email: '', text: '' })
const sending = ref(false)
const submitted = ref(false)
async function send() {
sending.value = true
try {
await $fetch('/api/messages', { method: 'POST', body: form })
submitted.value = true
}
catch {
alert('Erreur lors de l\'envoi du message.')
}
finally {
sending.value = false
}
}
function formatDate(iso: string) {
const date = new Date(iso)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return 'à l\'instant'
if (minutes < 60) return `il y a ${minutes} min`
if (hours < 24) return `il y a ${hours}h`
if (days < 30) return `il y a ${days}j`
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
}
</script>
<style scoped>
.message-form-card {
background: hsl(20 8% 6%);
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
padding: 1.5rem;
}
.msg-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 4%);
color: white;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.msg-input::placeholder {
color: hsl(20 8% 40%);
}
.msg-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
.message-card {
background: hsl(20 8% 6%);
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<section class="section-padding bg-surface-600/50">
<div class="container-content">
<UiScrollReveal>
<div class="text-center mb-12">
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.songs.kicker }}</p>
<h2 class="heading-section font-display font-bold tracking-tight text-white">
{{ content?.songs.title }}
</h2>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
{{ content?.songs.description }}
</p>
</div>
</UiScrollReveal>
<!-- Featured songs grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<UiScrollReveal
v-for="(song, i) in featuredSongs"
:key="song.id"
:delay="i * 100"
>
<SongItem :song="song" />
</UiScrollReveal>
</div>
<UiScrollReveal :delay="400">
<div class="mt-10 text-center">
<UiBaseButton variant="accent" :to="content?.songs.cta.to">
{{ content?.songs.cta.label }}
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
</UiBaseButton>
</div>
</UiScrollReveal>
</div>
</section>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('home')
const bookData = useBookData()
await bookData.init()
const featuredSongs = computed(() => bookData.getSongs().slice(0, 6))
</script>
<style scoped>
.heading-section {
font-size: clamp(1.625rem, 4vw, 2.125rem);
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<Transition name="overlay">
<div
v-if="open"
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
@click="emit('update:open', false)"
/>
</Transition>
<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"
aria-label="Menu mobile"
>
<div class="flex items-center justify-between mb-8">
<span class="font-display text-lg font-bold text-gradient">Menu</span>
<button
class="btn-ghost !p-2"
aria-label="Fermer le menu"
@click="emit('update:open', false)"
>
<div class="i-lucide-x h-5 w-5" />
</button>
</div>
<ul class="flex flex-col gap-2">
<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"
active-class="bg-primary/10 text-primary"
@click="emit('update:open', false)"
>
{{ item.label }}
</NuxtLink>
</li>
</ul>
</nav>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
nav: { label: string; to: string }[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>
<style scoped>
.overlay-enter-active,
.overlay-leave-active {
transition: opacity 0.3s;
}
.overlay-enter-from,
.overlay-leave-to {
opacity: 0;
}
.slide-menu-enter-active,
.slide-menu-leave-active {
transition: transform 0.3s var(--ease-out-expo);
}
.slide-menu-enter-from,
.slide-menu-leave-to {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<footer class="border-t border-white/8 bg-surface-600 pb-[var(--player-height)]">
<div class="container-content px-4 py-8">
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
<!-- Credits -->
<p class="text-sm text-white/40">
{{ site?.footer.credits }}
</p>
<!-- Links -->
<nav class="flex items-center gap-4" aria-label="Liens du pied de page">
<NuxtLink
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"
>
{{ link.label }}
</NuxtLink>
</nav>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const { data: site } = await useSiteContent()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<header class="sticky top-0 z-40 border-b border-white/8 bg-surface-bg/80 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>
<!-- Desktop navigation -->
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
<NuxtLink
v-for="item in site?.navigation"
:key="item.to"
:to="item.to"
class="btn-ghost text-sm"
active-class="text-white! bg-white/5"
>
{{ item.label }}
</NuxtLink>
</nav>
<!-- Mobile menu button -->
<button
class="btn-ghost md:hidden !p-2"
aria-label="Menu"
@click="isMobileMenuOpen = true"
>
<div class="i-lucide-menu h-5 w-5" />
</button>
</div>
<!-- Mobile menu -->
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" />
</header>
</template>
<script setup lang="ts">
const { data: site } = await useSiteContent()
const isMobileMenuOpen = ref(false)
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="flex items-center gap-2">
<!-- Shuffle -->
<button
class="btn-ghost !p-2"
:class="{ 'text-primary!': store.isShuffled }"
aria-label="Mélanger"
@click="toggleShuffle"
>
<div class="i-lucide-shuffle h-4 w-4" />
</button>
<!-- Previous -->
<button
class="btn-ghost !p-2"
:disabled="!store.hasPrev"
aria-label="Précédent"
@click="playPrev"
>
<div class="i-lucide-skip-back h-5 w-5" />
</button>
<!-- Play/Pause -->
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-white text-surface-bg transition-transform hover:scale-110 active:scale-95"
:aria-label="store.isPlaying ? 'Pause' : 'Lecture'"
@click="togglePlayPause"
>
<div :class="store.isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" class="h-5 w-5" />
</button>
<!-- Next -->
<button
class="btn-ghost !p-2"
:disabled="!store.hasNext"
aria-label="Suivant"
@click="playNext"
>
<div class="i-lucide-skip-forward h-5 w-5" />
</button>
<!-- Repeat -->
<button
class="btn-ghost !p-2"
:class="{ 'text-primary!': store.repeatMode !== 'none' }"
aria-label="Répéter"
@click="store.toggleRepeat()"
>
<div
:class="store.repeatMode === 'one' ? 'i-lucide-repeat-1' : 'i-lucide-repeat'"
class="h-4 w-4"
/>
</button>
</div>
</template>
<script setup lang="ts">
const store = usePlayerStore()
const { togglePlayPause, playNext, playPrev } = useAudioPlayer()
const { toggleShuffle } = usePlaylist()
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="flex items-center gap-2 rounded-full bg-surface-200 p-1">
<button
class="mode-btn"
:class="{ active: store.isGuidedMode }"
@click="setMode('guided')"
>
<div class="i-lucide-book-open h-3.5 w-3.5" />
<span class="hidden sm:inline">Guidé</span>
</button>
<button
class="mode-btn"
:class="{ active: !store.isGuidedMode }"
@click="setMode('free')"
>
<div class="i-lucide-headphones h-3.5 w-3.5" />
<span class="hidden sm:inline">Libre</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { PlayerMode } from '~/types/player'
const store = usePlayerStore()
function setMode(mode: PlayerMode) {
store.setMode(mode)
}
</script>
<style scoped>
.mode-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: hsl(0 0% 100% / 0.5);
transition: all 0.2s;
}
.mode-btn:hover {
color: hsl(0 0% 100% / 0.8);
}
.mode-btn.active {
background: hsl(12 76% 48%);
color: white;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<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"
>
<!-- 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>
</div>
</Transition>
<!-- Progress bar (top of player) -->
<PlayerProgress />
<!-- 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 />
<!-- Volume -->
<div class="flex items-center gap-2">
<button class="btn-ghost !p-1" @click="toggleMute">
<div :class="volumeIcon" class="h-4 w-4" />
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
:value="store.volume"
class="volume-slider w-20"
@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 -->
<button
class="btn-ghost !p-2"
:aria-label="store.isExpanded ? 'Réduire' : 'Développer'"
@click="store.toggleExpanded()"
>
<div :class="store.isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" />
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const store = usePlayerStore()
const { setVolume } = useAudioPlayer()
// Initialize media session
useMediaSession()
let previousVolume = 0.8
const volumeIcon = computed(() => {
if (store.volume === 0) return 'i-lucide-volume-x'
if (store.volume < 0.3) return 'i-lucide-volume'
if (store.volume < 0.7) return 'i-lucide-volume-1'
return 'i-lucide-volume-2'
})
function handleVolumeChange(e: Event) {
const value = parseFloat((e.target as HTMLInputElement).value)
setVolume(value)
}
function toggleMute() {
if (store.volume > 0) {
previousVolume = store.volume
setVolume(0)
}
else {
setVolume(previousVolume)
}
}
</script>
<style scoped>
.player-slide-enter-active,
.player-slide-leave-active {
transition: transform 0.3s var(--ease-out-expo);
}
.player-slide-enter-from,
.player-slide-leave-to {
transform: translateY(100%);
}
.panel-expand-enter-active,
.panel-expand-leave-active {
transition: all 0.3s var(--ease-out-expo);
overflow: hidden;
}
.panel-expand-enter-from,
.panel-expand-leave-to {
max-height: 0;
opacity: 0;
}
.panel-expand-enter-to,
.panel-expand-leave-from {
max-height: 400px;
opacity: 1;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
background: hsl(0 0% 100% / 0.15);
border-radius: 2px;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: white;
border: none;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="max-h-80 overflow-y-auto p-4">
<h3 class="mb-3 font-display text-sm font-semibold uppercase tracking-wider text-white/50">
Playlist
</h3>
<ul class="flex flex-col gap-1">
<li
v-for="song in store.playlist"
:key="song.id"
class="flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors hover:bg-white/5"
:class="{ 'bg-primary/10 text-primary': song.id === store.currentSong?.id }"
@click="playSong(song)"
>
<span class="font-mono text-xs text-white/30 w-6 text-right">
{{ store.playlist.indexOf(song) + 1 }}
</span>
<div
v-if="song.id === store.currentSong?.id && store.isPlaying"
class="i-lucide-volume-2 h-4 w-4 flex-shrink-0 text-primary"
/>
<div
v-else
class="i-lucide-music h-4 w-4 flex-shrink-0 text-white/30"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ song.title }}</p>
<p class="truncate text-xs text-white/40">{{ song.artist }}</p>
</div>
<span class="font-mono text-xs text-white/30">
{{ formatDuration(song.duration) }}
</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type { Song } from '~/types/song'
const store = usePlayerStore()
const { playSongFromPlaylist } = usePlaylist()
function playSong(song: Song) {
playSongFromPlaylist(song)
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div
class="player-progress group relative h-1 w-full cursor-pointer rounded-full bg-white/10 transition-all hover:h-2"
@click="handleSeek"
@mousedown="startDrag"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-75"
:style="{ width: `${store.progress}%` }"
/>
<div
class="absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-md transition-opacity group-hover:opacity-100"
:style="{ left: `calc(${store.progress}% - 6px)` }"
/>
</div>
</template>
<script setup lang="ts">
const store = usePlayerStore()
const { seek } = useAudioPlayer()
function handleSeek(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const time = percent * store.duration
seek(time)
}
function startDrag(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const onMove = (ev: MouseEvent) => {
const rect = el.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
const time = percent * store.duration
seek(time)
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div v-if="store.currentSong" class="flex items-center gap-3 min-w-0">
<div
class="h-10 w-10 flex-shrink-0 rounded-lg bg-surface-200 flex items-center justify-center overflow-hidden"
:class="{ 'animate-glow-pulse': store.isPlaying }"
>
<img
v-if="store.currentSong.coverImage"
:src="store.currentSong.coverImage"
:alt="store.currentSong.title"
class="h-full w-full object-cover"
>
<div v-else class="i-lucide-music h-5 w-5 text-primary" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-white">
{{ store.currentSong.title }}
</p>
<p class="truncate text-xs text-white/50">
{{ store.currentSong.artist }}
</p>
</div>
</div>
<div v-else class="flex items-center gap-3 text-white/40">
<div class="h-10 w-10 flex-shrink-0 rounded-lg bg-surface-200 flex items-center justify-center">
<div class="i-lucide-music h-5 w-5" />
</div>
<p class="text-sm">Aucune piste sélectionnée</p>
</div>
</template>
<script setup lang="ts">
const store = usePlayerStore()
</script>

View File

@@ -0,0 +1,91 @@
<template>
<canvas
ref="canvasRef"
class="h-12 w-full rounded-lg opacity-60"
/>
</template>
<script setup lang="ts">
const canvasRef = ref<HTMLCanvasElement | null>(null)
const store = usePlayerStore()
const { getAudio } = useAudioPlayer()
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let animId: number | null = null
let connected = false
function initAnalyser() {
if (connected || !canvasRef.value) return
try {
const audio = getAudio()
audioContext = new AudioContext()
analyser = audioContext.createAnalyser()
analyser.fftSize = 64
source = audioContext.createMediaElementSource(audio)
source.connect(analyser)
analyser.connect(audioContext.destination)
connected = true
}
catch {
// Web Audio API might not be available
}
}
function draw() {
if (!canvasRef.value || !analyser) {
animId = requestAnimationFrame(draw)
return
}
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
analyser.getByteFrequencyData(dataArray)
canvas.width = canvas.offsetWidth * window.devicePixelRatio
canvas.height = canvas.offsetHeight * window.devicePixelRatio
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
const width = canvas.offsetWidth
const height = canvas.offsetHeight
ctx.clearRect(0, 0, width, height)
const barWidth = width / bufferLength
const gap = 2
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * height
const x = i * (barWidth + gap)
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight)
gradient.addColorStop(0, 'hsl(12, 76%, 48%)')
gradient.addColorStop(1, 'hsl(36, 80%, 52%)')
ctx.fillStyle = gradient
ctx.fillRect(x, height - barHeight, barWidth, barHeight)
}
animId = requestAnimationFrame(draw)
}
watch(() => store.isPlaying, (playing) => {
if (playing) {
initAnalyser()
if (!animId) draw()
if (audioContext?.state === 'suspended') {
audioContext.resume()
}
}
})
onUnmounted(() => {
if (animId) cancelAnimationFrame(animId)
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div v-if="songs.length > 0" class="flex flex-wrap gap-1">
<span
v-for="song in songs"
:key="song.id"
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
>
<div class="i-lucide-music h-2.5 w-2.5" />
{{ song.title }}
</span>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
chapterSlug: string
}>()
const bookData = useBookData()
await bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
</script>

View File

@@ -0,0 +1,65 @@
<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="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>
</template>
<script setup lang="ts">
import type { Song } from '~/types/song'
const props = defineProps<{
song: Song
}>()
const store = usePlayerStore()
const { loadAndPlay, togglePlayPause } = useAudioPlayer()
const isCurrent = computed(() => store.currentSong?.id === props.song.id)
function handlePlay() {
if (isCurrent.value) {
togglePlayPause()
}
else {
loadAndPlay(props.song)
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex flex-col gap-2">
<SongItem
v-for="song in songs"
:key="song.id"
:song="song"
/>
</div>
</template>
<script setup lang="ts">
import type { Song } from '~/types/song'
defineProps<{
songs: Song[]
}>()
</script>

View File

@@ -0,0 +1,50 @@
<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>
</div>
</template>
<script setup lang="ts">
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,41 @@
<template>
<a
v-if="href"
:href="href"
:target="target"
:rel="target === '_blank' ? 'noopener noreferrer' : undefined"
:class="variantClasses"
>
<slot />
</a>
<NuxtLink
v-else-if="to"
:to="to"
:class="variantClasses"
>
<slot />
</NuxtLink>
<button
v-else
:class="variantClasses"
>
<slot />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
variant?: 'primary' | 'accent' | 'ghost'
to?: string
href?: string
target?: string
}>()
const variantClasses = computed(() => {
switch (props.variant) {
case 'accent': return 'btn-accent'
case 'ghost': return 'btn-ghost'
default: return 'btn-primary'
}
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div ref="el" class="scroll-reveal" :style="{ animationDelay: `${delay}ms` }">
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
delay?: number
}>()
const el = ref<HTMLElement | null>(null)
onMounted(() => {
if (!el.value) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible')
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' },
)
observer.observe(el.value)
onUnmounted(() => observer.disconnect())
})
</script>

View File

@@ -0,0 +1,153 @@
import type { Song } from '~/types/song'
let audio: HTMLAudioElement | null = null
let animationFrameId: number | null = null
export function useAudioPlayer() {
const store = usePlayerStore()
function getAudio(): HTMLAudioElement {
if (!audio) {
audio = new Audio()
audio.preload = 'metadata'
audio.volume = store.volume
audio.addEventListener('loadedmetadata', () => {
store.setDuration(audio!.duration)
})
audio.addEventListener('ended', () => {
const next = store.nextSong()
if (next) {
loadAndPlay(next)
}
})
audio.addEventListener('error', (e) => {
console.error('Audio error:', e)
store.pause()
})
}
return audio
}
function startTimeUpdate() {
if (animationFrameId) return
const update = () => {
if (audio && !audio.paused) {
store.setCurrentTime(audio.currentTime)
}
animationFrameId = requestAnimationFrame(update)
}
animationFrameId = requestAnimationFrame(update)
}
function stopTimeUpdate() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
async function loadAndPlay(song: Song) {
const el = getAudio()
store.setSong(song)
// Try OGG first, fall back to MP3
const oggPath = song.file.replace(/\.mp3$/, '.ogg')
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis')
el.src = canOgg ? oggPath : song.file
el.volume = store.volume
try {
await el.play()
store.play()
startTimeUpdate()
}
catch {
// If OGG failed, try MP3
if (el.src !== song.file) {
el.src = song.file
try {
await el.play()
store.play()
startTimeUpdate()
}
catch (err) {
console.error('Playback failed:', err)
}
}
}
}
function pause() {
getAudio().pause()
store.pause()
stopTimeUpdate()
}
function resume() {
const el = getAudio()
if (el.src) {
el.play()
store.play()
startTimeUpdate()
}
}
function togglePlayPause() {
if (store.isPlaying) {
pause()
}
else {
resume()
}
}
function seek(time: number) {
const el = getAudio()
el.currentTime = time
store.setCurrentTime(time)
}
function setVolume(vol: number) {
store.setVolume(vol)
getAudio().volume = store.volume
}
function playNext() {
const song = store.nextSong()
if (song) loadAndPlay(song)
}
function playPrev() {
const song = store.prevSong()
if (song) {
if (song === store.currentSong && store.currentTime <= 3) {
// prevSong already reset time
seek(0)
}
else {
loadAndPlay(song)
}
}
}
// Watch volume changes from store
watch(() => store.volume, (vol) => {
if (audio) audio.volume = vol
})
return {
loadAndPlay,
pause,
resume,
togglePlayPause,
seek,
setVolume,
playNext,
playPrev,
getAudio,
}
}

View File

@@ -0,0 +1,95 @@
import yaml from 'yaml'
import type { Song } from '~/types/song'
import type { ChapterSongLink, BookConfig } from '~/types/book'
let _configCache: BookConfig | null = null
async function loadConfig(): Promise<BookConfig> {
if (_configCache) return _configCache
const raw = await import('~/data/librodrome.config.yml?raw').then(m => m.default)
const parsed = yaml.parse(raw)
_configCache = {
title: parsed.book.title,
author: parsed.book.author,
description: parsed.book.description,
coverImage: parsed.book.coverImage,
chapters: [],
songs: parsed.songs as Song[],
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
}
return _configCache
}
export function useBookData() {
const config = ref<BookConfig | null>(null)
const isLoaded = ref(false)
async function init() {
if (isLoaded.value) return
config.value = await loadConfig()
isLoaded.value = true
}
function getSongs(): Song[] {
return config.value?.songs ?? []
}
function getSongById(id: string): Song | undefined {
return config.value?.songs.find(s => s.id === id)
}
function getChapterSongs(chapterSlug: string): Song[] {
if (!config.value) return []
const links = config.value.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug)
return links
.map(link => config.value!.songs.find(s => s.id === link.songId))
.filter((s): s is Song => !!s)
}
function getPrimarySong(chapterSlug: string): Song | undefined {
if (!config.value) return undefined
const link = config.value.chapterSongs.find(
cs => cs.chapterSlug === chapterSlug && cs.primary,
)
if (!link) return undefined
return config.value.songs.find(s => s.id === link.songId)
}
function getChapterSongLinks(chapterSlug: string): ChapterSongLink[] {
return config.value?.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug) ?? []
}
function getPlaylistOrder(): Song[] {
if (!config.value) return []
return config.value.defaultPlaylistOrder
.map(id => config.value!.songs.find(s => s.id === id))
.filter((s): s is Song => !!s)
}
function getBookMeta() {
if (!config.value) return null
return {
title: config.value.title,
author: config.value.author,
description: config.value.description,
coverImage: config.value.coverImage,
}
}
return {
config,
isLoaded,
init,
getSongs,
getSongById,
getChapterSongs,
getPrimarySong,
getChapterSongLinks,
getPlaylistOrder,
getBookMeta,
}
}

View File

@@ -0,0 +1,16 @@
export function useGrateWizard() {
const appConfig = useAppConfig()
function launch() {
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
const left = Math.round((window.screen.width - popup.width) / 2)
const top = Math.round((window.screen.height - popup.height) / 2)
window.open(
url,
'GrateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
)
}
return { launch }
}

View File

@@ -0,0 +1,36 @@
export function useGuidedMode() {
const route = useRoute()
const store = usePlayerStore()
const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer()
async function activateGuidedMode(chapterSlug: string) {
await bookData.init()
if (!store.isGuidedMode) return
const primarySong = bookData.getPrimarySong(chapterSlug)
if (primarySong && primarySong.id !== store.currentSong?.id) {
// Set the chapter's songs as the playlist
const chapterSongs = bookData.getChapterSongs(chapterSlug)
if (chapterSongs.length > 0) {
store.setPlaylist(chapterSongs)
}
loadAndPlay(primarySong)
}
}
// Watch route changes for guided mode
watch(
() => route.params.slug,
async (slug) => {
if (slug && typeof slug === 'string' && store.isGuidedMode) {
await activateGuidedMode(slug)
}
},
)
return {
activateGuidedMode,
}
}

View File

@@ -0,0 +1,63 @@
export function useMediaSession() {
const store = usePlayerStore()
const { togglePlayPause, playNext, playPrev, seek } = useAudioPlayer()
function updateMediaSession() {
if (!('mediaSession' in navigator)) return
if (!store.currentSong) return
navigator.mediaSession.metadata = new MediaMetadata({
title: store.currentSong.title,
artist: store.currentSong.artist,
album: 'Une économie du don — enfin concevable',
artwork: store.currentSong.coverImage
? [{ src: store.currentSong.coverImage, sizes: '512x512', type: 'image/jpeg' }]
: [],
})
navigator.mediaSession.setActionHandler('play', () => togglePlayPause())
navigator.mediaSession.setActionHandler('pause', () => togglePlayPause())
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => playNext())
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime != null) seek(details.seekTime)
})
}
function updatePositionState() {
if (!('mediaSession' in navigator)) return
if (!store.currentSong || store.duration === 0) return
try {
navigator.mediaSession.setPositionState({
duration: store.duration,
playbackRate: 1,
position: Math.min(store.currentTime, store.duration),
})
}
catch {
// Ignore errors from invalid position state
}
}
// Watch for song changes
watch(() => store.currentSong, () => {
updateMediaSession()
})
// Update position periodically
watch(() => store.currentTime, () => {
updatePositionState()
})
// Update playback state
watch(() => store.isPlaying, (playing) => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'
}
})
return {
updateMediaSession,
}
}

View File

@@ -0,0 +1,5 @@
export function usePageContent<T = Record<string, unknown>>(name: string) {
return useAsyncData<T>(`page-${name}`, () =>
$fetch(`/api/content/pages/${name}`),
)
}

View File

@@ -0,0 +1,51 @@
import type { Song } from '~/types/song'
export function usePlaylist() {
const store = usePlayerStore()
const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer()
async function loadFullPlaylist() {
await bookData.init()
const songs = bookData.getPlaylistOrder()
store.setPlaylist(songs)
}
function shufflePlaylist() {
const current = [...store.playlist]
// Fisher-Yates shuffle
for (let i = current.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[current[i], current[j]] = [current[j], current[i]]
}
store.setPlaylist(current)
store.toggleShuffle()
}
function unshuffle() {
const songs = bookData.getPlaylistOrder()
store.setPlaylist(songs)
store.toggleShuffle()
}
function playSongFromPlaylist(song: Song) {
loadAndPlay(song)
}
function toggleShuffle() {
if (store.isShuffled) {
unshuffle()
}
else {
shufflePlaylist()
}
}
return {
loadFullPlaylist,
shufflePlaylist,
unshuffle,
playSongFromPlaylist,
toggleShuffle,
}
}

View File

@@ -0,0 +1,40 @@
export function useScrollReveal() {
const observer = ref<IntersectionObserver | null>(null)
function init() {
if (typeof window === 'undefined') return
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible')
observer.value?.unobserve(entry.target)
}
})
},
{
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
},
)
document.querySelectorAll('.scroll-reveal').forEach((el) => {
observer.value?.observe(el)
})
}
function destroy() {
observer.value?.disconnect()
}
onMounted(() => {
nextTick(() => init())
})
onUnmounted(() => {
destroy()
})
return { init, destroy }
}

View File

@@ -0,0 +1,30 @@
interface NavItem {
label: string
to: string
}
interface SiteContent {
identity: {
name: string
description: string
url: string
}
navigation: NavItem[]
footer: {
credits: string
links: NavItem[]
}
gratewizard: {
url: string
popup: {
width: number
height: number
}
}
}
export function useSiteContent() {
return useAsyncData<SiteContent>('site-content', () =>
$fetch('/api/content/site'),
)
}

View File

@@ -0,0 +1,180 @@
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

28
app/layouts/admin.vue Normal file
View File

@@ -0,0 +1,28 @@
<template>
<div class="admin-layout">
<AdminSidebar />
<main class="admin-main">
<slot />
</main>
</div>
</template>
<style scoped>
.admin-layout {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100dvh;
}
.admin-main {
padding: 2rem;
overflow-y: auto;
max-height: 100dvh;
}
@media (max-width: 768px) {
.admin-layout {
grid-template-columns: 1fr;
}
}
</style>

15
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader />
<main class="pb-[var(--player-height)]">
<slot />
</main>
<LayoutTheFooter />
</div>
</template>
<style scoped>
.app-layout {
grid-template-rows: auto 1fr auto;
}
</style>

53
app/layouts/reading.vue Normal file
View File

@@ -0,0 +1,53 @@
<template>
<div class="app-layout grid grid-cols-1 min-h-dvh">
<LayoutTheHeader />
<div class="reading-layout pb-[var(--player-height)]">
<aside class="chapter-sidebar hidden lg:block">
<BookChapterNav />
</aside>
<main class="reading-main">
<slot />
</main>
</div>
<LayoutTheFooter />
</div>
</template>
<style scoped>
.app-layout {
grid-template-rows: auto 1fr auto;
}
.reading-layout {
display: grid;
grid-template-columns: 1fr;
max-width: 100%;
}
@media (min-width: 1024px) {
.reading-layout {
grid-template-columns: var(--sidebar-width) 1fr;
}
}
.chapter-sidebar {
position: sticky;
top: var(--header-height);
height: calc(100dvh - var(--header-height));
overflow-y: auto;
border-right: 1px solid hsl(0 0% 100% / 0.08);
padding: 1.5rem;
}
.reading-main {
padding: 2rem 1.5rem;
max-width: 65ch;
margin: 0 auto;
}
@media (min-width: 768px) {
.reading-main {
padding: 3rem 2rem;
}
}
</style>

11
app/middleware/admin.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async (to) => {
// Only protect admin routes (except login)
if (!to.path.startsWith('/admin') || to.path === '/admin/login') return
try {
await $fetch('/api/admin/auth/check')
}
catch {
return navigateTo('/admin/login')
}
})

21
app/pages/a-propos.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<div class="section-padding">
<div class="container-content mx-auto max-w-3xl">
<ContentRenderer v-if="page" :value="page" class="prose" />
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
useHead({
title: 'À propos',
})
const { data: page } = await useAsyncData('about', () =>
queryCollection('pages').path('/pages/about').first(),
)
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<NuxtLink to="/admin/book" class="text-sm text-white/40 hover:text-white/60 transition-colors">
Chapitres
</NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1">
{{ chapter?.slug }}
</h1>
</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>
<AdminFormSection title="Contenu Markdown" open>
<AdminMarkdownEditor v-model="body" :rows="30" />
</AdminFormSection>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const route = useRoute()
const slug = computed(() => route.params.slug as string)
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
const frontmatter = ref('')
const body = ref('')
watch(chapter, (val) => {
if (val) {
frontmatter.value = val.frontmatter ?? ''
body.value = val.body ?? ''
}
}, { immediate: true })
const saving = ref(false)
const saved = ref(false)
async function save() {
saving.value = true
saved.value = false
try {
await $fetch(`/api/admin/chapters/${slug.value}`, {
method: 'PUT',
body: {
frontmatter: frontmatter.value,
body: body.value,
},
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
finally {
saving.value = false
}
}
</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;
}
.fm-textarea:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div>
<h1 class="font-display text-2xl font-bold text-white mb-6">Chapitres</h1>
<div class="flex flex-col gap-2">
<NuxtLink
v-for="chapter in chapters"
:key="chapter.slug"
:to="`/admin/book/${chapter.slug}`"
class="chapter-item"
>
<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>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const { data: chapters } = await useFetch('/api/admin/chapters')
</script>
<style scoped>
.chapter-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
text-decoration: none;
transition: all 0.2s;
}
.chapter-item:hover {
border-color: hsl(12 76% 48% / 0.3);
background: hsl(20 8% 6%);
}
.chapter-order {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
color: hsl(12 76% 48% / 0.5);
font-weight: 600;
width: 1.75rem;
}
.chapter-title {
flex: 1;
color: white;
font-weight: 500;
}
</style>

60
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,60 @@
<template>
<div>
<h1 class="font-display text-2xl font-bold text-white mb-6">Dashboard</h1>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink to="/admin/site" class="dash-card">
<div class="i-lucide-globe h-8 w-8 text-primary mb-2" />
<h2 class="text-lg font-semibold text-white">Site</h2>
<p class="text-sm text-white/50">Identité, navigation, footer</p>
</NuxtLink>
<NuxtLink to="/admin/pages/home" class="dash-card">
<div class="i-lucide-home h-8 w-8 text-primary mb-2" />
<h2 class="text-lg font-semibold text-white">Pages</h2>
<p class="text-sm text-white/50">Contenus des pages publiques</p>
</NuxtLink>
<NuxtLink to="/admin/book" class="dash-card">
<div class="i-lucide-book-open h-8 w-8 text-primary mb-2" />
<h2 class="text-lg font-semibold text-white">Chapitres</h2>
<p class="text-sm text-white/50">Éditer le contenu du livre</p>
</NuxtLink>
<NuxtLink to="/admin/songs" class="dash-card">
<div class="i-lucide-music h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Chansons</h2>
<p class="text-sm text-white/50">Métadonnées des pistes</p>
</NuxtLink>
<NuxtLink to="/admin/media" class="dash-card">
<div class="i-lucide-image h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Médias</h2>
<p class="text-sm text-white/50">Images, audio, PDF</p>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
</script>
<style scoped>
.dash-card {
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(20 8% 14%);
background: hsl(20 8% 6%);
text-decoration: none;
transition: all 0.2s;
}
.dash-card:hover {
border-color: hsl(12 76% 48% / 0.3);
background: hsl(20 8% 8%);
}
</style>

134
app/pages/admin/login.vue Normal file
View File

@@ -0,0 +1,134 @@
<template>
<div class="login-page">
<form class="login-form" @submit.prevent="login">
<div class="i-lucide-lock h-10 w-10 text-primary mb-4 mx-auto" />
<h1 class="font-display text-2xl font-bold text-white text-center mb-6">Administration</h1>
<div v-if="error" class="login-error">
{{ error }}
</div>
<label class="login-label" for="password">Mot de passe</label>
<input
id="password"
v-model="password"
type="password"
class="login-input"
placeholder="Entrez le mot de passe"
autofocus
/>
<button type="submit" class="login-btn" :disabled="loading">
<div v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
Se connecter
</button>
</form>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
})
const password = ref('')
const error = ref('')
const loading = ref(false)
async function login() {
error.value = ''
loading.value = true
try {
await $fetch('/api/admin/auth/login', {
method: 'POST',
body: { password: password.value },
})
navigateTo('/admin')
}
catch {
error.value = 'Mot de passe incorrect'
}
finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: hsl(20 8% 3.5%);
}
.login-form {
width: 100%;
max-width: 24rem;
padding: 2.5rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 1rem;
background: hsl(20 8% 6%);
}
.login-error {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: hsl(0 60% 45% / 0.15);
color: hsl(0 60% 70%);
font-size: 0.85rem;
margin-bottom: 1rem;
}
.login-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
margin-bottom: 0.375rem;
}
.login-input {
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 4%);
color: white;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.login-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
.login-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem;
border-radius: 0.5rem;
border: none;
background: hsl(12 76% 48%);
color: white;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover:not(:disabled) {
background: hsl(12 76% 42%);
}
.login-btn:disabled {
opacity: 0.7;
cursor: wait;
}
</style>

32
app/pages/admin/media.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<div>
<h1 class="font-display text-2xl font-bold text-white mb-6">Médias</h1>
<AdminMediaUpload class="mb-6" @uploaded="refresh" />
<AdminMediaBrowser
v-if="files"
:files="files"
@delete="deleteFile"
/>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const { data: files, refresh } = await useFetch('/api/admin/media')
async function deleteFile(path: string) {
if (!confirm(`Supprimer ${path} ?`)) return
// Remove leading slash for the API path
const apiPath = path.startsWith('/') ? path.slice(1) : path
await $fetch(`/api/admin/media/${apiPath}`, { method: 'DELETE' })
await refresh()
}
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Messages</h1>
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
</div>
<div v-if="messages?.length" class="space-y-3">
<div
v-for="msg in messages"
:key="msg.id"
class="msg-row"
:class="{ 'msg-row--draft': !msg.published }"
>
<!-- En-tête -->
<div class="flex items-center justify-between gap-4 mb-2">
<div class="flex items-center gap-2 min-w-0">
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span>
<span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
<span
class="status-badge"
:class="msg.published ? 'status-badge--pub' : 'status-badge--draft'"
>
{{ msg.published ? 'Publié' : 'En attente' }}
</span>
</div>
</div>
<!-- Texte éditable -->
<div v-if="editing === msg.id" class="mb-3">
<input
v-model="editForm.author"
class="admin-input mb-2 w-full"
placeholder="Auteur"
/>
<textarea
v-model="editForm.text"
class="admin-input w-full"
rows="3"
/>
</div>
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
<!-- Actions -->
<div class="flex items-center gap-2">
<button class="action-btn" @click="togglePublished(msg)">
<div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
{{ msg.published ? 'Dépublier' : 'Publier' }}
</button>
<template v-if="editing === msg.id">
<button class="action-btn action-btn--save" @click="saveEdit(msg)">
<div class="i-lucide-check h-3.5 w-3.5" />
Valider
</button>
<button class="action-btn" @click="editing = null">
Annuler
</button>
</template>
<button v-else class="action-btn" @click="startEdit(msg)">
<div class="i-lucide-pencil h-3.5 w-3.5" />
Modifier
</button>
<button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
<div class="i-lucide-trash-2 h-3.5 w-3.5" />
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-center text-white/40 py-12">Aucun message.</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
const editing = ref<number | null>(null)
const editForm = reactive({ author: '', text: '' })
function startEdit(msg: any) {
editing.value = msg.id
editForm.author = msg.author
editForm.text = msg.text
}
async function saveEdit(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT',
body: { author: editForm.author, text: editForm.text },
})
editing.value = null
await refresh()
}
async function togglePublished(msg: any) {
await $fetch(`/api/admin/messages/${msg.id}`, {
method: 'PUT',
body: { published: !msg.published },
})
await refresh()
}
async function remove(msg: any) {
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
await refresh()
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<style scoped>
.msg-row {
background: hsl(20 8% 6%);
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
.msg-row--draft {
border-left: 3px solid hsl(36 80% 52%);
}
.status-badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
}
.status-badge--pub {
background: hsl(142 70% 40% / 0.15);
color: hsl(142 70% 60%);
}
.status-badge--draft {
background: hsl(36 80% 52% / 0.15);
color: hsl(36 80% 66%);
}
.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);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border-radius: 0.375rem;
font-size: 0.75rem;
color: hsl(20 8% 60%);
background: none;
border: 1px solid hsl(20 8% 16%);
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: hsl(20 8% 10%);
color: white;
}
.action-btn--save {
border-color: hsl(142 70% 40% / 0.3);
color: hsl(142 70% 60%);
}
.action-btn--save:hover {
background: hsl(142 70% 40% / 0.1);
}
.action-btn--danger:hover {
background: hsl(0 70% 40% / 0.1);
border-color: hsl(0 70% 40% / 0.3);
color: hsl(0 70% 60%);
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
Dashboard
</NuxtLink>
<h1 class="font-display text-2xl font-bold text-white mt-1">
Page : {{ pageName }}
</h1>
</div>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
</div>
<div v-if="data" class="page-editor">
<AdminFormSection title="Contenu YAML" open>
<div class="yaml-editor-wrapper">
<textarea
v-model="yamlContent"
class="yaml-textarea"
rows="30"
spellcheck="false"
/>
</div>
</AdminFormSection>
</div>
<p v-else-if="error" class="text-red-400">
Erreur de chargement : {{ error.message }}
</p>
</div>
</template>
<script setup lang="ts">
import yaml from 'yaml'
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const route = useRoute()
const pageName = computed(() => route.params.name as string)
const { data, error } = await useFetch(() => `/api/content/pages/${pageName.value}`)
const yamlContent = ref('')
watch(data, (val) => {
if (val) {
yamlContent.value = yaml.stringify(val, { lineWidth: 120 })
}
}, { immediate: true })
const saving = ref(false)
const saved = ref(false)
async function save() {
saving.value = true
saved.value = false
try {
const parsed = yaml.parse(yamlContent.value)
await $fetch(`/api/admin/content/pages/${pageName.value}`, {
method: 'PUT',
body: parsed,
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
catch (e: any) {
alert('Erreur YAML : ' + (e?.message ?? 'format invalide'))
}
finally {
saving.value = false
}
}
</script>
<style scoped>
.yaml-textarea {
width: 100%;
padding: 1rem;
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;
min-height: 20rem;
}
.yaml-textarea:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

116
app/pages/admin/site.vue Normal file
View File

@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Configuration du site</h1>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
</div>
<template v-if="data">
<AdminFormSection title="Identité" open>
<AdminFieldText v-model="data.identity.name" label="Nom du site" />
<AdminFieldTextarea v-model="data.identity.description" label="Description" :rows="3" />
<AdminFieldText v-model="data.identity.url" label="URL" />
</AdminFormSection>
<AdminFormSection title="Navigation" open>
<AdminFieldList
v-model="data.navigation"
label="Liens de navigation"
add-label="Ajouter un lien"
:default-item="() => ({ label: '', to: '/' })"
>
<template #default="{ item, update }">
<div class="flex gap-2 flex-1">
<input
:value="item.label"
class="admin-input flex-1"
placeholder="Label"
@input="update({ ...item, label: ($event.target as HTMLInputElement).value })"
/>
<input
:value="item.to"
class="admin-input w-32"
placeholder="/chemin"
@input="update({ ...item, to: ($event.target as HTMLInputElement).value })"
/>
</div>
</template>
</AdminFieldList>
</AdminFormSection>
<AdminFormSection title="Pied de page">
<AdminFieldText v-model="data.footer.credits" label="Crédits" />
<AdminFieldList
v-model="data.footer.links"
label="Liens"
add-label="Ajouter un lien"
:default-item="() => ({ label: '', to: '/' })"
>
<template #default="{ item, update }">
<div class="flex gap-2 flex-1">
<input
:value="item.label"
class="admin-input flex-1"
placeholder="Label"
@input="update({ ...item, label: ($event.target as HTMLInputElement).value })"
/>
<input
:value="item.to"
class="admin-input w-32"
placeholder="/chemin"
@input="update({ ...item, to: ($event.target as HTMLInputElement).value })"
/>
</div>
</template>
</AdminFieldList>
</AdminFormSection>
<AdminFormSection title="GrateWizard">
<AdminFieldText v-model="data.gratewizard.url" label="URL de l'application" />
</AdminFormSection>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const { data } = await useFetch('/api/content/site')
const saving = ref(false)
const saved = ref(false)
async function save() {
saving.value = true
saved.value = false
try {
await $fetch('/api/admin/content/site', {
method: 'PUT',
body: data.value,
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
finally {
saving.value = false
}
}
</script>
<style scoped>
.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);
}
</style>

92
app/pages/admin/songs.vue Normal file
View File

@@ -0,0 +1,92 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Chansons</h1>
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
</div>
<template v-if="config">
<AdminFormSection title="Métadonnées des chansons" open>
<div
v-for="(song, i) in config.songs"
:key="i"
class="song-row"
>
<span class="song-num">{{ i + 1 }}</span>
<div class="flex-1 grid gap-2 sm:grid-cols-2">
<input
v-model="song.title"
class="admin-input"
placeholder="Titre"
/>
<input
v-model="song.file"
class="admin-input"
placeholder="/audio/fichier.mp3"
/>
</div>
</div>
</AdminFormSection>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const { data: config } = await useFetch('/api/content/config')
const saving = ref(false)
const saved = ref(false)
async function save() {
saving.value = true
saved.value = false
try {
await $fetch('/api/admin/content/config', {
method: 'PUT',
body: config.value,
})
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
finally {
saving.value = false
}
}
</script>
<style scoped>
.song-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-bottom: 1px solid hsl(20 8% 10%);
}
.song-num {
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
color: hsl(20 8% 40%);
width: 1.25rem;
text-align: right;
}
.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;
width: 100%;
}
.admin-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

110
app/pages/ecouter/index.vue Normal file
View File

@@ -0,0 +1,110 @@
<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>

95
app/pages/gratewizard.vue Normal file
View File

@@ -0,0 +1,95 @@
<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>
<!-- 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>
<!-- 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>
<!-- 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>
</div>
</UiScrollReveal>
</div>
</div>
</template>
<script setup lang="ts">
const { data: content } = await usePageContent('gratewizard')
useHead({
title: content.value?.meta?.title ?? 'GrateWizard Coefficients relatifs',
})
const { launch } = useGrateWizard()
</script>
<style scoped>
.page-title {
font-size: clamp(2rem, 5vw, 2.75rem);
}
.gw-feature-card {
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 8% / 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);
}
code {
font-family: ui-monospace, monospace;
font-size: 0.85em;
padding: 0.1em 0.3em;
border-radius: 0.25em;
background: hsl(40 80% 50% / 0.1);
}
</style>

18
app/pages/index.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<div>
<HomeHeroSection />
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
<HomeGrateWizardTeaser />
<BookPlayer v-model="showBookPlayer" />
<BookPdfReader v-model="showPdfReader" />
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Accueil',
})
const showBookPlayer = ref(false)
const showPdfReader = ref(false)
</script>

81
app/pages/lire/[slug].vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<div v-if="chapter">
<BookChapterHeader
:title="chapter.title"
:description="chapter.description"
:order="chapter.order"
:reading-time="chapter.readingTime"
:chapter-slug="slug"
/>
<BookChapterContent :content="chapter" />
<!-- Prev / Next navigation -->
<nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8">
<NuxtLink
v-if="prevChapter"
:to="`/lire/${prevChapter.stem}`"
class="btn-ghost gap-2"
>
<div class="i-lucide-arrow-left h-4 w-4" />
<span class="text-sm">{{ prevChapter.title }}</span>
</NuxtLink>
<div v-else />
<NuxtLink
v-if="nextChapter"
:to="`/lire/${nextChapter.stem}`"
class="btn-ghost gap-2"
>
<span class="text-sm">{{ nextChapter.title }}</span>
<div class="i-lucide-arrow-right h-4 w-4" />
</NuxtLink>
<div v-else />
</nav>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'reading',
})
const route = useRoute()
const slug = route.params.slug as string
// Initialize guided mode
useGuidedMode()
const { data: chapter } = await useAsyncData(`chapter-${slug}`, () =>
queryCollection('book').path(`/book/${slug}`).first(),
)
if (!chapter.value) {
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
}
useHead({
title: chapter.value?.title,
})
// Get adjacent chapters for navigation
const { data: allChapters } = await useAsyncData('book-nav', () =>
queryCollection('book').order('order', 'ASC').all(),
)
const currentIndex = computed(() =>
allChapters.value?.findIndex(c => c.stem === slug) ?? -1,
)
const prevChapter = computed(() => {
const idx = currentIndex.value
if (idx <= 0 || !allChapters.value) return null
return allChapters.value[idx - 1]
})
const nextChapter = computed(() => {
const idx = currentIndex.value
if (!allChapters.value || idx >= allChapters.value.length - 1) return null
return allChapters.value[idx + 1]
})
</script>

71
app/pages/lire/index.vue Normal file
View File

@@ -0,0 +1,71 @@
<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>

50
app/pages/messages.vue Normal file
View File

@@ -0,0 +1,50 @@
<template>
<div class="section-padding">
<div class="container-content mx-auto max-w-3xl">
<h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1>
<p class="text-white/50 mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
<div v-if="messages?.length" class="space-y-4">
<div v-for="msg in messages" :key="msg.id" class="message-card">
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p>
<div class="mt-3 flex items-center gap-2 text-xs text-white/40">
<span class="font-semibold text-white/60">{{ msg.author }}</span>
<span>&middot;</span>
<span>{{ formatDate(msg.createdAt) }}</span>
</div>
</div>
</div>
<p v-else class="text-center text-white/40 py-12">Aucun message pour l'instant.</p>
<div class="mt-8 text-center">
<NuxtLink to="/" class="btn-ghost text-sm">
<div class="i-lucide-arrow-left mr-1 h-3.5 w-3.5" />
Retour à l'accueil
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Messages',
})
const { data: messages } = await useFetch('/api/messages')
function formatDate(iso: string) {
const date = new Date(iso)
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
</script>
<style scoped>
.message-card {
background: hsl(20 8% 6%);
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
}
</style>

View File

@@ -0,0 +1,26 @@
export default defineNuxtPlugin(() => {
// Initialize the player store on client side
const store = usePlayerStore()
// Restore volume from localStorage
if (typeof localStorage !== 'undefined') {
const savedVolume = localStorage.getItem('librodrome-volume')
if (savedVolume) {
store.setVolume(parseFloat(savedVolume))
}
const savedMode = localStorage.getItem('librodrome-mode') as 'guided' | 'free' | null
if (savedMode) {
store.setMode(savedMode)
}
// Watch for changes and persist
watch(() => store.volume, (vol) => {
localStorage.setItem('librodrome-volume', String(vol))
})
watch(() => store.mode, (mode) => {
localStorage.setItem('librodrome-mode', mode)
})
}
})

187
app/stores/player.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { Song } from '~/types/song'
import type { PlayerMode, RepeatMode } from '~/types/player'
export const usePlayerStore = defineStore('player', () => {
// State
const isPlaying = ref(false)
const currentSong = ref<Song | null>(null)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(0.8)
const mode = ref<PlayerMode>('guided')
const repeatMode = ref<RepeatMode>('none')
const isShuffled = ref(false)
const playlist = ref<Song[]>([])
const queue = ref<Song[]>([])
const isExpanded = ref(false)
// Computed
const progress = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedDuration = computed(() => formatTime(duration.value))
const isGuidedMode = computed(() => mode.value === 'guided')
const currentIndex = computed(() => {
if (!currentSong.value) return -1
return playlist.value.findIndex(s => s.id === currentSong.value!.id)
})
const hasNext = computed(() => {
if (repeatMode.value === 'all') return playlist.value.length > 0
return currentIndex.value < playlist.value.length - 1
})
const hasPrev = computed(() => {
return currentIndex.value > 0
})
// Actions
function setSong(song: Song) {
currentSong.value = song
currentTime.value = 0
duration.value = song.duration
}
function setPlaylist(songs: Song[]) {
playlist.value = songs
}
function setMode(newMode: PlayerMode) {
mode.value = newMode
}
function togglePlay() {
isPlaying.value = !isPlaying.value
}
function play() {
isPlaying.value = true
}
function pause() {
isPlaying.value = false
}
function setCurrentTime(time: number) {
currentTime.value = time
}
function setDuration(dur: number) {
duration.value = dur
}
function setVolume(vol: number) {
volume.value = Math.max(0, Math.min(1, vol))
}
function toggleRepeat() {
const modes: RepeatMode[] = ['none', 'all', 'one']
const idx = modes.indexOf(repeatMode.value)
repeatMode.value = modes[(idx + 1) % modes.length]
}
function toggleShuffle() {
isShuffled.value = !isShuffled.value
}
function toggleExpanded() {
isExpanded.value = !isExpanded.value
}
function nextSong(): Song | null {
if (playlist.value.length === 0) return null
if (repeatMode.value === 'one') {
currentTime.value = 0
return currentSong.value
}
let nextIdx = currentIndex.value + 1
if (nextIdx >= playlist.value.length) {
if (repeatMode.value === 'all') {
nextIdx = 0
}
else {
pause()
return null
}
}
const song = playlist.value[nextIdx]
setSong(song)
return song
}
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
}
}
const song = playlist.value[prevIdx]
setSong(song)
return song
}
return {
// State
isPlaying,
currentSong,
currentTime,
duration,
volume,
mode,
repeatMode,
isShuffled,
playlist,
queue,
isExpanded,
// Computed
progress,
formattedCurrentTime,
formattedDuration,
isGuidedMode,
currentIndex,
hasNext,
hasPrev,
// Actions
setSong,
setPlaylist,
setMode,
togglePlay,
play,
pause,
setCurrentTime,
setDuration,
setVolume,
toggleRepeat,
toggleShuffle,
toggleExpanded,
nextSong,
prevSong,
}
})
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}

24
app/types/book.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface ChapterMeta {
slug: string
title: string
description?: string
order: number
readingTime?: string
}
export interface ChapterSongLink {
chapterSlug: string
songId: string
primary: boolean
}
export interface BookConfig {
title: string
author: string
description: string
coverImage?: string
chapters: ChapterMeta[]
songs: import('./song').Song[]
chapterSongs: ChapterSongLink[]
defaultPlaylistOrder: string[]
}

18
app/types/player.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Song } from './song'
export type PlayerMode = 'guided' | 'free'
export type RepeatMode = 'none' | 'one' | 'all'
export interface PlayerState {
isPlaying: boolean
currentSong: Song | null
currentTime: number
duration: number
volume: number
mode: PlayerMode
repeatMode: RepeatMode
isShuffled: boolean
playlist: Song[]
queue: Song[]
isExpanded: boolean
}

10
app/types/song.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface Song {
id: string
title: string
artist: string
file: string
duration: number // seconds
coverImage?: string
lyrics?: string
tags: string[]
}