initiation librodrome
This commit is contained in:
29
app/app.config.ts
Normal file
29
app/app.config.ts
Normal 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
28
app/app.vue
Normal 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>
|
||||
166
app/assets/css/animations.css
Normal file
166
app/assets/css/animations.css
Normal 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
14
app/assets/css/fonts.css
Normal 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
90
app/assets/css/main.css
Normal 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);
|
||||
}
|
||||
173
app/assets/css/typography.css
Normal file
173
app/assets/css/typography.css
Normal 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;
|
||||
}
|
||||
117
app/components/admin/AdminFieldList.vue
Normal file
117
app/components/admin/AdminFieldList.vue
Normal 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>
|
||||
56
app/components/admin/AdminFieldText.vue
Normal file
56
app/components/admin/AdminFieldText.vue
Normal 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>
|
||||
60
app/components/admin/AdminFieldTextarea.vue
Normal file
60
app/components/admin/AdminFieldTextarea.vue
Normal 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>
|
||||
55
app/components/admin/AdminFormSection.vue
Normal file
55
app/components/admin/AdminFormSection.vue
Normal 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>
|
||||
110
app/components/admin/AdminMarkdownEditor.vue
Normal file
110
app/components/admin/AdminMarkdownEditor.vue
Normal 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>
|
||||
211
app/components/admin/AdminMediaBrowser.vue
Normal file
211
app/components/admin/AdminMediaBrowser.vue
Normal 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>
|
||||
107
app/components/admin/AdminMediaUpload.vue
Normal file
107
app/components/admin/AdminMediaUpload.vue
Normal 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>
|
||||
54
app/components/admin/AdminSaveButton.vue
Normal file
54
app/components/admin/AdminSaveButton.vue
Normal 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>
|
||||
148
app/components/admin/AdminSidebar.vue
Normal file
148
app/components/admin/AdminSidebar.vue
Normal 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>
|
||||
144
app/components/book/BookPdfReader.vue
Normal file
144
app/components/book/BookPdfReader.vue
Normal 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>
|
||||
1002
app/components/book/BookPlayer.vue
Normal file
1002
app/components/book/BookPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
11
app/components/book/ChapterContent.vue
Normal file
11
app/components/book/ChapterContent.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<article class="prose">
|
||||
<ContentRenderer :value="content" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
content: any
|
||||
}>()
|
||||
</script>
|
||||
62
app/components/book/ChapterHeader.vue
Normal file
62
app/components/book/ChapterHeader.vue
Normal 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>
|
||||
27
app/components/book/ChapterNav.vue
Normal file
27
app/components/book/ChapterNav.vue
Normal 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>
|
||||
49
app/components/content/AudioInline.vue
Normal file
49
app/components/content/AudioInline.vue
Normal 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>
|
||||
8
app/components/content/PullQuote.vue
Normal file
8
app/components/content/PullQuote.vue
Normal 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>
|
||||
92
app/components/home/BookPresentation.vue
Normal file
92
app/components/home/BookPresentation.vue
Normal 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>
|
||||
97
app/components/home/BookSection.vue
Normal file
97
app/components/home/BookSection.vue
Normal 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>
|
||||
44
app/components/home/CooperativeVision.vue
Normal file
44
app/components/home/CooperativeVision.vue
Normal 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>
|
||||
78
app/components/home/GrateWizardTeaser.vue
Normal file
78
app/components/home/GrateWizardTeaser.vue
Normal 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>
|
||||
57
app/components/home/HeroSection.vue
Normal file
57
app/components/home/HeroSection.vue
Normal 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>
|
||||
141
app/components/home/HomeMessages.vue
Normal file
141
app/components/home/HomeMessages.vue
Normal 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>·</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>
|
||||
51
app/components/home/SongsPreview.vue
Normal file
51
app/components/home/SongsPreview.vue
Normal 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>
|
||||
74
app/components/layout/NavMobile.vue
Normal file
74
app/components/layout/NavMobile.vue
Normal 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>
|
||||
28
app/components/layout/TheFooter.vue
Normal file
28
app/components/layout/TheFooter.vue
Normal 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>
|
||||
41
app/components/layout/TheHeader.vue
Normal file
41
app/components/layout/TheHeader.vue
Normal 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>
|
||||
61
app/components/player/PlayerControls.vue
Normal file
61
app/components/player/PlayerControls.vue
Normal 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>
|
||||
53
app/components/player/PlayerModeToggle.vue
Normal file
53
app/components/player/PlayerModeToggle.vue
Normal 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>
|
||||
159
app/components/player/PlayerPersistent.vue
Normal file
159
app/components/player/PlayerPersistent.vue
Normal 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>
|
||||
52
app/components/player/PlayerPlaylist.vue
Normal file
52
app/components/player/PlayerPlaylist.vue
Normal 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>
|
||||
48
app/components/player/PlayerProgress.vue
Normal file
48
app/components/player/PlayerProgress.vue
Normal 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>
|
||||
34
app/components/player/PlayerTrackInfo.vue
Normal file
34
app/components/player/PlayerTrackInfo.vue
Normal 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>
|
||||
91
app/components/player/PlayerVisualizer.vue
Normal file
91
app/components/player/PlayerVisualizer.vue
Normal 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>
|
||||
23
app/components/song/SongBadges.vue
Normal file
23
app/components/song/SongBadges.vue
Normal 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>
|
||||
65
app/components/song/SongItem.vue
Normal file
65
app/components/song/SongItem.vue
Normal 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>
|
||||
17
app/components/song/SongList.vue
Normal file
17
app/components/song/SongList.vue
Normal 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>
|
||||
50
app/components/song/SongLyrics.vue
Normal file
50
app/components/song/SongLyrics.vue
Normal 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>
|
||||
41
app/components/ui/BaseButton.vue
Normal file
41
app/components/ui/BaseButton.vue
Normal 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>
|
||||
33
app/components/ui/ScrollReveal.vue
Normal file
33
app/components/ui/ScrollReveal.vue
Normal 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>
|
||||
153
app/composables/useAudioPlayer.ts
Normal file
153
app/composables/useAudioPlayer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
95
app/composables/useBookData.ts
Normal file
95
app/composables/useBookData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
16
app/composables/useGrateWizard.ts
Normal file
16
app/composables/useGrateWizard.ts
Normal 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 }
|
||||
}
|
||||
36
app/composables/useGuidedMode.ts
Normal file
36
app/composables/useGuidedMode.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
app/composables/useMediaSession.ts
Normal file
63
app/composables/useMediaSession.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
5
app/composables/usePageContent.ts
Normal file
5
app/composables/usePageContent.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function usePageContent<T = Record<string, unknown>>(name: string) {
|
||||
return useAsyncData<T>(`page-${name}`, () =>
|
||||
$fetch(`/api/content/pages/${name}`),
|
||||
)
|
||||
}
|
||||
51
app/composables/usePlaylist.ts
Normal file
51
app/composables/usePlaylist.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
app/composables/useScrollReveal.ts
Normal file
40
app/composables/useScrollReveal.ts
Normal 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 }
|
||||
}
|
||||
30
app/composables/useSiteContent.ts
Normal file
30
app/composables/useSiteContent.ts
Normal 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'),
|
||||
)
|
||||
}
|
||||
180
app/data/librodrome.config.yml
Normal file
180
app/data/librodrome.config.yml
Normal 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
28
app/layouts/admin.vue
Normal 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
15
app/layouts/default.vue
Normal 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
53
app/layouts/reading.vue
Normal 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
11
app/middleware/admin.ts
Normal 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
21
app/pages/a-propos.vue
Normal 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>
|
||||
94
app/pages/admin/book/[slug].vue
Normal file
94
app/pages/admin/book/[slug].vue
Normal 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>
|
||||
59
app/pages/admin/book/index.vue
Normal file
59
app/pages/admin/book/index.vue
Normal 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
60
app/pages/admin/index.vue
Normal 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
134
app/pages/admin/login.vue
Normal 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
32
app/pages/admin/media.vue
Normal 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>
|
||||
209
app/pages/admin/messages.vue
Normal file
209
app/pages/admin/messages.vue
Normal 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>
|
||||
98
app/pages/admin/pages/[name].vue
Normal file
98
app/pages/admin/pages/[name].vue
Normal 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
116
app/pages/admin/site.vue
Normal 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
92
app/pages/admin/songs.vue
Normal 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
110
app/pages/ecouter/index.vue
Normal 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
95
app/pages/gratewizard.vue
Normal 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
18
app/pages/index.vue
Normal 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
81
app/pages/lire/[slug].vue
Normal 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
71
app/pages/lire/index.vue
Normal 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
50
app/pages/messages.vue
Normal 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>·</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>
|
||||
26
app/plugins/audio-player.client.ts
Normal file
26
app/plugins/audio-player.client.ts
Normal 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
187
app/stores/player.ts
Normal 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
24
app/types/book.ts
Normal 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
18
app/types/player.ts
Normal 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
10
app/types/song.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user