initiation librodrome
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user