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

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>