Simplify ArrangementConfigurator: replace color pickers with compact pills, add click-to-add from pool, use watcher-based auto-select for new/cloned arrangements, remove group_colors from save payload. Enhance SongsBlock preview: color-coded group headers with tinted backgrounds, PDF download button inside preview modal, .pro download link per matched song, show DB ccli_id with fallback to CTS ccli_id. Fix Modal z-index for nested dialogs. Fix SlideUploader duplicate upload on watch by adding deep option and upload guard. Expand API log detail sections by default and increase JSON tree depth. Convert song download button from emit to direct .pro download link.
762 lines
39 KiB
Vue
762 lines
39 KiB
Vue
<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 -->
|
||
<a
|
||
data-testid="song-list-download-button"
|
||
:href="`/api/songs/${song.id}/download-pro`"
|
||
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"
|
||
>
|
||
<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
|
||
</a>
|
||
|
||
<!-- 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>
|