- Add data-testid to 18 Vue components (Pages, Blocks, Features, Layouts, Primitives)
- Naming convention: {component-kebab}-{element-description}
- 98 total data-testid attributes added
- Target elements: buttons, links, inputs, modals, navigation
- No logic/styling changes - attributes only
589 lines
31 KiB
Vue
589 lines
31 KiB
Vue
<script setup>
|
||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||
import SongEditModal from '@/Components/SongEditModal.vue'
|
||
import { Head } from '@inertiajs/vue3'
|
||
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 fileInput = ref(null)
|
||
|
||
// Edit modal state
|
||
const showEditModal = ref(false)
|
||
const editSongId = ref(null)
|
||
let debounceTimer = null
|
||
|
||
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)
|
||
}
|
||
|
||
// Upload handlers (placeholder — T23 will implement parser)
|
||
function handleDragOver(e) {
|
||
e.preventDefault()
|
||
isDragging.value = true
|
||
}
|
||
|
||
function handleDragLeave() {
|
||
isDragging.value = false
|
||
}
|
||
|
||
function handleDrop(e) {
|
||
e.preventDefault()
|
||
isDragging.value = false
|
||
uploadError.value = 'ProPresenter-Import (.pro) ist noch nicht verfügbar. Kommt bald!'
|
||
setTimeout(() => { uploadError.value = '' }, 4000)
|
||
}
|
||
|
||
function triggerFileInput() {
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
function handleFileSelect() {
|
||
uploadError.value = 'ProPresenter-Import (.pro) ist noch nicht verfügbar. Kommt bald!'
|
||
if (fileInput.value) fileInput.value.value = ''
|
||
setTimeout(() => { uploadError.value = '' }, 4000)
|
||
}
|
||
|
||
// 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>
|
||
|
||
<!-- 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">
|
||
<!-- 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
|
||
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"
|
||
/>
|
||
|
||
</AuthenticatedLayout>
|
||
</template>
|