- Add virtual MASTER arrangement to all songs (read-only, shows all groups in song order, clonable) - Fix drop zone staying open after slide upload and thumbnail 403 (double slides/ path) - Add click-to-preview overlay with download button, prev/next navigation, and slide counter - Add X delete button with confirmation dialog and hover tooltip preview on agenda thumbnails - Fix arrangement select not updating after add/clone (emit + re-fetch pattern) - Move InformationBlock below agenda; announcement row scrolls to it instead of showing upload - Create storage symlink (public/storage -> storage/app/public)
503 lines
20 KiB
Vue
503 lines
20 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 data-testid="song-edit-modal" 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
|
|
data-testid="song-edit-modal-close-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
|
|
data-testid="song-edit-modal-error-close-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
|
|
data-testid="song-edit-modal-title-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
|
|
data-testid="song-edit-modal-ccli-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
|
|
data-testid="song-edit-modal-copyright-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"
|
|
@arrangements-changed="fetchSong"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|