Files
librodrome/app/pages/admin/songs.vue
Yvv 9d92c4a5b3
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Fix typos blanches admin lightmode + hero audience
- Remplace color:white → hsl(var(--color-text)) dans tous les composants admin
  (AdminFieldText, AdminFieldTextarea, AdminFormSection, AdminMarkdownEditor,
  AdminMediaBrowser, AdminSidebar, book/index, book/[slug], login, messages, site, songs)
- Conserve color:white uniquement sur fond primary (AdminSaveButton, login-btn)
- Hero home : ajout bloc audience/addressees (clé distincte pour éviter conflit YAML)
- home.yml : réordonne axes (citoyenne en premier — effet triangle)
- TypewriterText : affiche le second bloc avec séparateur fin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:08:03 +01:00

256 lines
6.2 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: hsl(var(--color-text));
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>