pp-planer/resources/js/Components/SongEditModal.vue
Thorsten Bus 27f8402ae8 feat: Wave 4 - Song DB Management + Finalization (T20-T24)
T20: Song DB Page
- Songs/Index.vue with search, action buttons, pagination
- Upload area for .pro files (calls T23 placeholder)
- Song-Datenbank nav link added to AuthenticatedLayout
- Tests: 9 new (44 assertions)

T21: Song DB Edit Popup
- SongEditModal.vue with metadata + ArrangementConfigurator
- Auto-save with fetch (500ms debounce for text, immediate on blur)
- Tests: 11 new (53 assertions)

T22: Song DB Translate Page
- Songs/Translate.vue with two-column editor
- URL fetch or manual paste, line-count constraints
- Group headers with colors, save marks has_translation=true
- Tests: 1 new (12 assertions)

T23: .pro File Placeholders
- ProParserNotImplementedException with HTTP 501
- ProFileController with importPro/downloadPro placeholders
- German error messages
- Tests: 5 new (7 assertions)

T24: Service Finalization + Status
- Two-step finalization with warnings (unmatched songs, missing slides)
- Download placeholder toast
- isReadyToFinalize accessor on Service model
- Tests: 11 new (30 assertions)

All tests passing: 174/174 (905 assertions)
Build: ✓ Vite production build successful
German UI: All user-facing text in German with 'Du' form
2026-03-01 20:30:07 +01:00

497 lines
19 KiB
Vue

<script setup>
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
songId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['close', 'updated'])
/* ── State ── */
const loading = ref(false)
const error = ref(null)
const songData = ref(null)
const title = ref('')
const ccliId = ref('')
const copyrightText = ref('')
const saving = ref(false)
const saved = ref(false)
let savedTimeout = null
/* ── Data fetching ── */
const fetchSong = async () => {
if (!props.songId) return
loading.value = true
error.value = null
try {
const response = await fetch(`/api/songs/${props.songId}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Song konnte nicht geladen werden.')
}
const json = await response.json()
songData.value = json.data
title.value = json.data.title ?? ''
ccliId.value = json.data.ccli_id ?? ''
copyrightText.value = json.data.copyright_text ?? ''
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
watch(
() => props.show,
(isVisible) => {
if (isVisible && props.songId) {
fetchSong()
}
if (!isVisible) {
songData.value = null
error.value = null
}
},
)
/* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */
const performSave = async (data) => {
if (!props.songId) return
saving.value = true
saved.value = false
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const response = await fetch(`/api/songs/${props.songId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Speichern fehlgeschlagen')
}
saving.value = false
saved.value = true
if (savedTimeout) clearTimeout(savedTimeout)
savedTimeout = setTimeout(() => {
saved.value = false
}, 2000)
emit('updated')
} catch {
saving.value = false
}
}
const buildPayload = () => ({
title: title.value,
ccli_id: ccliId.value || null,
copyright_text: copyrightText.value || null,
})
// 500ms debounce for text inputs
const debouncedSave = useDebounceFn((data) => {
performSave(data)
}, 500)
const onTextInput = () => {
debouncedSave(buildPayload())
}
// Immediate save on blur (cancel pending debounce)
const onFieldBlur = () => {
debouncedSave.cancel?.()
performSave(buildPayload())
}
/* ── Arrangement props ── */
const arrangements = computed(() => {
if (!songData.value?.arrangements) return []
return songData.value.arrangements.map((arr) => ({
id: arr.id,
name: arr.name,
is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => {
const group = songData.value.groups.find((g) => g.id === ag.song_group_id)
return {
id: ag.song_group_id,
name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280',
order: ag.order,
}
}),
}))
})
const availableGroups = computed(() => {
if (!songData.value?.groups) return []
return songData.value.groups.map((group) => ({
id: group.id,
name: group.name,
color: group.color,
}))
})
/* ── Close handling ── */
const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) {
emit('close')
}
}
const closeOnBackdrop = (e) => {
if (e.target === e.currentTarget) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
})
</script>
<template>
<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="show"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div class="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<svg
class="h-5 w-5 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900">
Song bearbeiten
</h2>
<p class="text-sm text-gray-500">
Metadaten und Arrangements verwalten
</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Save status indicator -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="saving"
class="inline-flex items-center gap-1.5 text-sm text-gray-400"
>
<svg
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Speichert…
</span>
<span
v-else-if="saved"
class="inline-flex items-center gap-1.5 text-sm text-emerald-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="M5 13l4 4L19 7"
/>
</svg>
Gespeichert
</span>
</Transition>
<button
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
>
<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>
<!-- Loading State -->
<div
v-if="loading"
class="flex items-center justify-center py-16"
>
<svg
class="h-8 w-8 animate-spin text-amber-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="ml-3 text-gray-600">Song wird geladen…</span>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="px-6 py-16 text-center"
>
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg
class="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p class="text-red-600">{{ error }}</p>
<button
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="emit('close')"
>
Schließen
</button>
</div>
<!-- Content -->
<div
v-else-if="songData"
class="max-h-[80vh] overflow-y-auto"
>
<!-- Metadata Fields -->
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
Metadaten
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label
for="song-edit-title"
class="mb-1 block text-sm font-medium text-gray-700"
>
Titel
</label>
<input
id="song-edit-title"
v-model="title"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Songtitel eingeben…"
@input="onTextInput"
@blur="onFieldBlur"
>
</div>
<div>
<label
for="song-edit-ccli"
class="mb-1 block text-sm font-medium text-gray-700"
>
CCLI-ID
</label>
<input
id="song-edit-ccli"
v-model="ccliId"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. 123456"
@input="onTextInput"
@blur="onFieldBlur"
>
</div>
<div class="self-end pb-2 text-sm text-gray-400">
<span
v-if="songData.has_translation"
class="inline-flex items-center gap-1 text-emerald-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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
Übersetzung vorhanden
</span>
<span
v-else
class="text-gray-400"
>
Keine Übersetzung
</span>
</div>
<div class="sm:col-span-2">
<label
for="song-edit-copyright"
class="mb-1 block text-sm font-medium text-gray-700"
>
Copyright-Text
</label>
<textarea
id="song-edit-copyright"
v-model="copyrightText"
rows="3"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Copyright-Informationen…"
@input="onTextInput"
@blur="onFieldBlur"
/>
</div>
</div>
</div>
<!-- Arrangement Configurator -->
<div class="px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
Arrangements
</h3>
<ArrangementConfigurator
:song-id="songData.id"
:arrangements="arrangements"
:available-groups="availableGroups"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>