pp-planer/resources/js/Pages/Songs/Index.vue
Thorsten Bus 4520c1ce5f test(e2e): add data-testid attributes to all Vue components
- 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
2026-03-01 22:45:13 +01:00

589 lines
31 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 { 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>