pp-planer/resources/js/Pages/Songs/Index.vue
Thorsten Bus 6e48779259 feat(songs): add preview, searchable combo select, import toast, auto-select arrangement
- Hide translate button for already-translated songs in SongDB
- Auto-select newly created/cloned arrangement via onSuccess + nextTick
- Add preview button to SongDB list (fetches default arrangement preview)
- Show success toast with count after .pro file import
- Replace search+select with single searchable combo/autocomplete field
- Wire preview modal and PDF download to real endpoints in service songs
- Disable preview/PDF buttons when no arrangement selected
2026-03-02 14:10:59 +01:00

763 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
import { Head } from '@inertiajs/vue3'
import axios from 'axios'
import { ref, watch, onMounted } from 'vue'
const songs = ref([])
const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 })
const search = ref('')
const loading = ref(false)
const deleting = ref(null)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
// Upload state
const isDragging = ref(false)
const uploadError = ref('')
const uploadSuccess = ref('')
const fileInput = ref(null)
// Edit modal state
const showEditModal = ref(false)
const editSongId = ref(null)
let debounceTimer = null
// Preview modal state
const previewSong = ref(null)
const previewData = ref(null)
const previewLoading = ref(false)
async function fetchSongs(page = 1) {
loading.value = true
try {
const params = new URLSearchParams({ page, per_page: 20 })
if (search.value.trim()) {
params.set('search', search.value.trim())
}
const response = await fetch(`/api/songs?${params}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
if (!response.ok) throw new Error('Fehler beim Laden')
const json = await response.json()
songs.value = json.data
meta.value = json.meta
} catch (err) {
console.error('Song fetch error:', err)
} finally {
loading.value = false
}
}
watch(search, () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => fetchSongs(1), 500)
})
onMounted(() => fetchSongs())
function goToPage(page) {
if (page < 1 || page > meta.value.last_page) return
fetchSongs(page)
}
function formatDate(value) {
if (!value) return ''
return new Date(value).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function formatDateTime(value) {
if (!value) return ''
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// --- Actions ---
function confirmDelete(song) {
deleteTarget.value = song
showDeleteConfirm.value = true
}
async function deleteSong() {
if (!deleteTarget.value) return
deleting.value = deleteTarget.value.id
try {
const response = await fetch(`/api/songs/${deleteTarget.value.id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
credentials: 'same-origin',
})
if (!response.ok) throw new Error('Fehler beim Löschen')
showDeleteConfirm.value = false
deleteTarget.value = null
await fetchSongs(meta.value.current_page)
} catch (err) {
console.error('Delete error:', err)
} finally {
deleting.value = null
}
}
function cancelDelete() {
showDeleteConfirm.value = false
deleteTarget.value = null
}
function openEditModal(song) {
editSongId.value = song.id
showEditModal.value = true
}
function closeEditModal() {
showEditModal.value = false
editSongId.value = null
}
function onSongUpdated() {
fetchSongs(meta.value.current_page)
}
async function openPreview(song) {
previewSong.value = song
previewLoading.value = true
previewData.value = null
try {
// Fetch song details to get arrangements
const songRes = await fetch(`/api/songs/${song.id}`, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
})
if (!songRes.ok) throw new Error('Song konnte nicht geladen werden')
const songDetail = await songRes.json()
const arrangements = songDetail.data?.arrangements || songDetail.arrangements || []
if (arrangements.length === 0) {
previewSong.value = null
previewLoading.value = false
return
}
const defaultArr = arrangements.find(a => a.is_default) || arrangements[0]
const previewRes = await fetch(`/songs/${song.id}/arrangements/${defaultArr.id}/preview`, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
})
if (!previewRes.ok) throw new Error('Vorschau konnte nicht geladen werden')
previewData.value = await previewRes.json()
} catch (err) {
console.error('Preview error:', err)
previewSong.value = null
} finally {
previewLoading.value = false
}
}
function closePreview() {
previewSong.value = null
previewData.value = null
}
// Upload handlers (placeholder — T23 will implement parser)
function handleDragOver(e) {
e.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
async function handleDrop(e) {
e.preventDefault()
isDragging.value = false
const droppedFiles = Array.from(e.dataTransfer?.files || [])
if (droppedFiles.length === 0) return
await uploadProFiles(droppedFiles)
}
function triggerFileInput() {
fileInput.value?.click()
}
function handleFileSelect() {
const selectedFiles = Array.from(fileInput.value?.files || [])
if (fileInput.value) fileInput.value.value = ''
if (selectedFiles.length === 0) return
uploadProFiles(selectedFiles)
}
async function uploadProFiles(files) {
uploadError.value = ''
loading.value = true
let successCount = 0
let errors = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
try {
await axios.post('/api/songs/import-pro', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
successCount++
} catch (err) {
const message = err.response?.data?.message || `Fehler bei "${file.name}"`
errors.push(message)
}
}
if (successCount > 0) {
await fetchSongs(1)
uploadSuccess.value = `${successCount} ${successCount === 1 ? 'Song' : 'Songs'} erfolgreich importiert.`
setTimeout(() => { uploadSuccess.value = '' }, 4000)
}
if (errors.length > 0) {
uploadError.value = errors.join(', ')
setTimeout(() => { uploadError.value = '' }, 6000)
}
loading.value = false
}
// Page range for pagination
function pageRange() {
const current = meta.value.current_page
const last = meta.value.last_page
const delta = 2
const range = []
for (let i = Math.max(2, current - delta); i <= Math.min(last - 1, current + delta); i++) {
range.push(i)
}
if (current - delta > 2) range.unshift('...')
if (current + delta < last - 1) range.push('...')
range.unshift(1)
if (last > 1) range.push(last)
return range
}
</script>
<template>
<Head title="Song-Datenbank" />
<AuthenticatedLayout>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold leading-tight text-gray-800">Song-Datenbank</h2>
<p class="text-sm text-gray-500">Verwalte alle Songs, Übersetzungen und Arrangements.</p>
</div>
</template>
<div class="py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Upload Area -->
<div
data-testid="song-list-upload-area"
class="group relative mb-6 cursor-pointer overflow-hidden rounded-xl border-2 border-dashed transition-all duration-300"
:class="isDragging
? 'border-amber-400 bg-amber-50/80 shadow-lg shadow-amber-100/50'
: 'border-gray-200 bg-white hover:border-amber-300 hover:bg-amber-50/30'"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="triggerFileInput"
>
<input
data-testid="song-list-file-input"
ref="fileInput"
type="file"
multiple
accept=".pro,.zip"
class="hidden"
@change="handleFileSelect"
/>
<div class="flex flex-col items-center justify-center px-6 py-8">
<!-- Upload icon -->
<div
class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl transition-all duration-300"
:class="isDragging
? 'bg-amber-200/80 text-amber-700 scale-110'
: 'bg-gray-100 text-gray-400 group-hover:bg-amber-100 group-hover:text-amber-600'"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p class="text-sm font-medium text-gray-700">
<span class="text-amber-600 underline underline-offset-2 decoration-amber-300">Dateien auswählen</span>
oder hierher ziehen
</p>
<p class="mt-1 text-xs text-gray-400">.pro Dateien oder .zip Archive mit .pro Dateien</p>
</div>
<!-- Upload error toast -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="uploadError"
class="absolute inset-x-0 bottom-0 bg-orange-50 border-t border-orange-200 px-4 py-2.5 text-center text-sm font-medium text-orange-700"
>
{{ uploadError }}
</div>
</Transition>
</div>
<!-- Upload Success Toast -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="uploadSuccess"
class="mb-4 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700"
>
{{ uploadSuccess }}
</div>
</Transition>
<!-- Search Bar -->
<div class="relative mb-6">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-4.5 w-4.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</div>
<input
data-testid="song-list-search-input"
v-model="search"
type="text"
placeholder="Songs durchsuchen (Name oder CCLI-ID)…"
class="w-full rounded-xl border border-gray-200 bg-white py-3 pl-11 pr-4 text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
/>
<Transition
enter-active-class="transition duration-150"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<button
data-testid="song-list-search-clear"
v-if="search"
@click="search = ''"
class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:text-gray-600"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Transition>
</div>
<!-- Song Count + Loading -->
<div class="mb-3 flex items-center justify-between px-1">
<p class="text-xs font-medium text-gray-500">
<template v-if="!loading">
{{ meta.total }} {{ meta.total === 1 ? 'Song' : 'Songs' }}
<span v-if="search" class="text-gray-400">für „{{ search }}"</span>
</template>
<template v-else>
<span class="inline-flex items-center gap-1.5">
<span class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span>
Lade Songs…
</span>
</template>
</p>
</div>
<!-- Table -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div v-if="!loading && songs.length === 0" class="p-12 text-center">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gray-100">
<svg class="h-7 w-7 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
</svg>
</div>
<p class="text-sm font-medium text-gray-600">
{{ search ? 'Keine Songs gefunden' : 'Noch keine Songs vorhanden' }}
</p>
<p class="mt-1 text-xs text-gray-400">
{{ search ? 'Versuch einen anderen Suchbegriff.' : 'Lade .pro Dateien hoch, um Songs hinzuzufügen.' }}
</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50/80">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Titel</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">CCLI-ID</th>
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 md:table-cell">Erstellt</th>
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 lg:table-cell">Letzte Änderung</th>
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 lg:table-cell">Zuletzt verwendet</th>
<th class="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-gray-500">Übersetzung</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="song in songs"
:key="song.id"
class="group transition-colors hover:bg-amber-50/30"
>
<!-- Titel -->
<td class="px-4 py-3.5">
<div class="font-medium text-gray-900">{{ song.title }}</div>
<div v-if="song.author" class="mt-0.5 text-xs text-gray-400">{{ song.author }}</div>
</td>
<!-- CCLI-ID -->
<td class="px-4 py-3.5">
<span
v-if="song.ccli_id"
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-mono font-medium text-gray-700"
>
{{ song.ccli_id }}
</span>
<span v-else class="text-xs text-gray-300"></span>
</td>
<!-- Erstellt -->
<td class="hidden px-4 py-3.5 text-sm text-gray-500 md:table-cell">
{{ formatDate(song.created_at) }}
</td>
<!-- Letzte Änderung -->
<td class="hidden px-4 py-3.5 text-sm text-gray-500 lg:table-cell">
{{ formatDateTime(song.updated_at) }}
</td>
<!-- Zuletzt verwendet -->
<td class="hidden px-4 py-3.5 text-sm lg:table-cell">
<span v-if="song.last_used_in_service" class="text-gray-700">
{{ formatDate(song.last_used_in_service) }}
</span>
<span v-else class="text-xs text-gray-300">noch nie</span>
</td>
<!-- Übersetzung -->
<td class="px-4 py-3.5 text-center">
<span
v-if="song.has_translation"
class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700"
title="Übersetzung vorhanden"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Ja
</span>
<span
v-else
class="inline-flex items-center rounded-full bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-400"
>
Nein
</span>
</td>
<!-- Aktionen -->
<td class="px-4 py-3.5">
<div class="flex items-center justify-end gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
<!-- Vorschau -->
<button
data-testid="song-list-preview-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700"
title="Vorschau"
@click="openPreview(song)"
>
<svg class="mr-1 h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Vorschau
</button>
<!-- Bearbeiten -->
<button
data-testid="song-list-edit-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
title="Bearbeiten"
@click="openEditModal(song)"
>
<svg class="mr-1 h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
Bearbeiten
</button>
<!-- Übersetzen -->
<a
v-if="!song.has_translation"
data-testid="song-list-translate-link"
:href="`/songs/${song.id}/translate`"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
title="Übersetzen"
>
<svg class="mr-1 h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
</svg>
Übersetzen
</a>
<!-- Herunterladen -->
<button
data-testid="song-list-download-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700"
title="Als .pro herunterladen"
@click="$emit('download', song)"
>
<svg class="mr-1 h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Herunterladen
</button>
<!-- Löschen -->
<button
data-testid="song-list-delete-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-red-300 hover:bg-red-50 hover:text-red-700"
title="Löschen"
:disabled="deleting === song.id"
@click.stop="confirmDelete(song)"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div
v-if="meta.last_page > 1"
class="mt-4 flex items-center justify-between px-1"
>
<p class="text-xs text-gray-500">
Seite {{ meta.current_page }} von {{ meta.last_page }}
</p>
<nav class="flex items-center gap-1">
<button
data-testid="song-list-pagination-prev"
:disabled="meta.current_page <= 1"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
@click="goToPage(meta.current_page - 1)"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<template v-for="page in pageRange()" :key="page">
<span
v-if="page === '...'"
class="flex h-8 w-8 items-center justify-center text-xs text-gray-400"
>…</span>
<button
v-else
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border text-xs font-medium shadow-sm transition-all"
:class="page === meta.current_page
? 'border-amber-300 bg-amber-50 text-amber-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'"
@click="goToPage(page)"
>
{{ page }}
</button>
</template>
<button
data-testid="song-list-pagination-next"
:disabled="meta.current_page >= meta.last_page"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
@click="goToPage(meta.current_page + 1)"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</nav>
</div>
<!-- Delete Confirm Modal -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
@click.self="cancelDelete"
>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="showDeleteConfirm"
class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl"
>
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-red-50">
<svg class="h-5.5 w-5.5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900">Song löschen?</h3>
<p class="mt-1.5 text-sm text-gray-500">
„{{ deleteTarget?.title }}" wird gelöscht. Diese Aktion kann rückgängig gemacht werden.
</p>
<div class="mt-5 flex items-center justify-end gap-2.5">
<button
data-testid="song-list-delete-cancel-button"
type="button"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
@click="cancelDelete"
>
Abbrechen
</button>
<button
data-testid="song-list-delete-confirm-button"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-red-700 disabled:opacity-60"
:disabled="deleting"
@click="deleteSong"
>
<span v-if="deleting" class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
Löschen
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</div>
</div>
<!-- Song Edit Modal -->
<SongEditModal
data-testid="song-list-edit-modal"
:show="showEditModal"
:song-id="editSongId"
@close="closeEditModal"
@updated="onSongUpdated"
/>
<!-- Song Preview Modal -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="previewSong"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
@click.self="closePreview"
>
<div class="w-full max-w-2xl max-h-[80vh] overflow-auto rounded-2xl bg-white p-6 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
{{ previewSong?.title || 'Vorschau' }}
</h3>
<button type="button" class="text-gray-400 hover:text-gray-600" @click="closePreview">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-if="previewLoading" class="flex items-center justify-center py-12">
<span class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span>
</div>
<div v-else-if="previewData" class="space-y-4">
<div v-for="(group, idx) in previewData.groups" :key="idx" class="rounded-lg border border-gray-200 p-4">
<div class="mb-2 flex items-center gap-2">
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-semibold text-white"
:style="{ backgroundColor: group.color || '#6b7280' }"
>
{{ group.name }}
</span>
</div>
<div v-for="(slide, sIdx) in group.slides" :key="sIdx" class="mt-2 whitespace-pre-wrap text-sm text-gray-700 leading-relaxed">{{ slide.text_content }}</div>
</div>
<div v-if="previewData.song?.copyright_text" class="border-t pt-3 text-xs text-gray-400">
{{ previewData.song.copyright_text }}
</div>
</div>
<div v-else class="py-8 text-center text-sm text-gray-500">
Kein Arrangement vorhanden.
</div>
</div>
</div>
</Transition>
</Teleport>
</AuthenticatedLayout>
</template>