All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- BookPlayer : toutes les couleurs HSL en dur remplacées par variables CSS palette - Admin (sidebar, formulaires, pages, book, songs, messages, media, login) : idem - L'ambiance graphique suit maintenant la palette active partout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
256 lines
6.1 KiB
Vue
256 lines
6.1 KiB
Vue
<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="Morceaux" open>
|
|
<div
|
|
v-for="(song, i) in config.songs"
|
|
: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-3">
|
|
<input
|
|
v-model="song.title"
|
|
class="admin-input"
|
|
placeholder="Titre"
|
|
/>
|
|
<input
|
|
v-model="song.file"
|
|
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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin',
|
|
})
|
|
|
|
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,
|
|
})
|
|
saved.value = true
|
|
setTimeout(() => { saved.value = false }, 2000)
|
|
}
|
|
finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.song-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 0.5rem;
|
|
border-bottom: 1px solid hsl(var(--color-surface));
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.song-row--dragging {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.song-row--over {
|
|
border-top: 2px solid hsl(var(--color-primary));
|
|
}
|
|
|
|
.drag-handle {
|
|
cursor: grab;
|
|
padding: 0.25rem;
|
|
color: hsl(var(--color-text-muted));
|
|
flex-shrink: 0;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.song-num {
|
|
font-family: var(--font-mono, monospace);
|
|
font-size: 0.8rem;
|
|
color: hsl(var(--color-text-muted));
|
|
width: 1.25rem;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
margin-top: 0.375rem;
|
|
}
|
|
|
|
.admin-input {
|
|
padding: 0.375rem 0.5rem;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid hsl(var(--color-surface-light));
|
|
background: hsl(var(--color-bg));
|
|
color: white;
|
|
font-size: 0.8rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.admin-input:focus {
|
|
outline: none;
|
|
border-color: hsl(var(--color-primary) / 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(var(--color-surface-light));
|
|
background: none;
|
|
color: hsl(var(--color-text-muted));
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.add-btn:hover {
|
|
border-color: hsl(var(--color-primary) / 0.5);
|
|
color: hsl(var(--color-primary));
|
|
}
|
|
</style>
|