Refactoring complet : contenu livre, config unique, routes, admin et light mode
- Source unique : supprime app/data/librodrome.config.yml, renomme site/ en bookplayer.config.yml - Morceaux : renommés avec slugs lisibles, fichiers audio renommés, inversion ch2↔ch3 corrigée - Chapitres : 11 fichiers .md réécrits avec le vrai contenu du livre (synthèse fidèle du PDF) - Routes : /lire → /modele-eco, /ecouter → /en-musique, redirections 301 - Admin chapitres : champs structurés (titre, description, temps lecture), compteur mots - Éditeur markdown : mode split, plein écran, support Tab, meilleur rendu aperçu - Admin morceaux : drag & drop, ajout/suppression, gestion playlist - Light mode : palettes printemps/été plus saturées et contrastées, teintes primary - Raccourcis clavier player : espace, flèches gauche/droite - Paroles : toggle supprimé, toujours visibles et scrollables - Nouvelles pages : autonomie, evenement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,14 +6,22 @@
|
||||
</div>
|
||||
|
||||
<template v-if="config">
|
||||
<AdminFormSection title="Métadonnées des chansons" open>
|
||||
<AdminFormSection title="Morceaux" open>
|
||||
<div
|
||||
v-for="(song, i) in config.songs"
|
||||
:key="i"
|
||||
:key="song.id"
|
||||
class="song-row"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(i, $event)"
|
||||
@dragover.prevent="onDragOver(i)"
|
||||
@dragend="onDragEnd"
|
||||
:class="{ 'song-row--dragging': dragIdx === i, 'song-row--over': dropIdx === i && dropIdx !== dragIdx }"
|
||||
>
|
||||
<div class="drag-handle" aria-label="Réordonner">
|
||||
<div class="i-lucide-grip-vertical h-4 w-4" />
|
||||
</div>
|
||||
<span class="song-num">{{ i + 1 }}</span>
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-2">
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
v-model="song.title"
|
||||
class="admin-input"
|
||||
@@ -24,8 +32,33 @@
|
||||
class="admin-input"
|
||||
placeholder="/audio/fichier.mp3"
|
||||
/>
|
||||
<input
|
||||
v-model="song.id"
|
||||
class="admin-input font-mono text-xs"
|
||||
placeholder="identifiant-slug"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
v-model="song.lyrics"
|
||||
class="admin-input lyrics-textarea"
|
||||
placeholder="Paroles..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="delete-btn"
|
||||
@click="removeSong(i)"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<div class="i-lucide-trash-2 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="add-btn" @click="addSong">
|
||||
<div class="i-lucide-plus h-4 w-4" />
|
||||
Ajouter un morceau
|
||||
</button>
|
||||
</AdminFormSection>
|
||||
</template>
|
||||
</div>
|
||||
@@ -37,14 +70,80 @@ definePageMeta({
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: config } = await useFetch('/api/content/config')
|
||||
const { data: config } = await useFetch<any>('/api/content/config')
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
// Drag & drop state
|
||||
const dragIdx = ref<number | null>(null)
|
||||
const dropIdx = ref<number | null>(null)
|
||||
|
||||
function onDragStart(i: number, e: DragEvent) {
|
||||
dragIdx.value = i
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(i: number) {
|
||||
dropIdx.value = i
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (dragIdx.value !== null && dropIdx.value !== null && dragIdx.value !== dropIdx.value && config.value) {
|
||||
const songs = config.value.songs
|
||||
const [moved] = songs.splice(dragIdx.value, 1)
|
||||
songs.splice(dropIdx.value, 0, moved)
|
||||
// Sync defaultPlaylistOrder
|
||||
config.value.defaultPlaylistOrder = songs.map((s: any) => s.id)
|
||||
}
|
||||
dragIdx.value = null
|
||||
dropIdx.value = null
|
||||
}
|
||||
|
||||
function addSong() {
|
||||
if (!config.value) return
|
||||
const newSong = {
|
||||
id: `nouveau-morceau-${Date.now()}`,
|
||||
title: '',
|
||||
artist: 'Yvv',
|
||||
file: '/audio/',
|
||||
duration: 0,
|
||||
lyrics: '',
|
||||
tags: [],
|
||||
}
|
||||
config.value.songs.push(newSong)
|
||||
config.value.defaultPlaylistOrder.push(newSong.id)
|
||||
}
|
||||
|
||||
function removeSong(i: number) {
|
||||
if (!config.value) return
|
||||
const songId = config.value.songs[i].id
|
||||
config.value.songs.splice(i, 1)
|
||||
// Remove from defaultPlaylistOrder
|
||||
const orderIdx = config.value.defaultPlaylistOrder.indexOf(songId)
|
||||
if (orderIdx !== -1) config.value.defaultPlaylistOrder.splice(orderIdx, 1)
|
||||
// Clean chapterSongs
|
||||
config.value.chapterSongs = config.value.chapterSongs.filter((cs: any) => cs.songId !== songId)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
// Regenerate IDs from titles for new songs
|
||||
for (const song of config.value!.songs) {
|
||||
if (song.id.startsWith('nouveau-morceau-')) {
|
||||
song.id = song.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
}
|
||||
// Sync playlist order
|
||||
config.value!.defaultPlaylistOrder = config.value!.songs.map((s: any) => s.id)
|
||||
|
||||
await $fetch('/api/admin/content/config', {
|
||||
method: 'PUT',
|
||||
body: config.value,
|
||||
@@ -61,10 +160,31 @@ async function save() {
|
||||
<style scoped>
|
||||
.song-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid hsl(20 8% 10%);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.song-row--dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.song-row--over {
|
||||
border-top: 2px solid hsl(12 76% 48%);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
padding: 0.25rem;
|
||||
color: hsl(20 8% 35%);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.song-num {
|
||||
@@ -73,6 +193,8 @@ async function save() {
|
||||
color: hsl(20 8% 40%);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
@@ -89,4 +211,45 @@ async function save() {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.lyrics-textarea {
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(0 60% 50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: hsl(0 60% 50% / 0.1);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(20 8% 25%);
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(12 76% 68%);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user