pp-planer/resources/js/Components/Blocks/SongsBlock.vue
Thorsten Bus af0c72ebcc feat(ui): improve arrangement configurator, song preview, and downloads
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.
2026-03-02 23:02:51 +01:00

509 lines
21 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 ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
import { router } from '@inertiajs/vue3'
import { reactive, ref, watch } from 'vue'
const props = defineProps({
serviceSongs: {
type: Array,
default: () => [],
},
songsCatalog: {
type: Array,
default: () => [],
},
})
const toastMessage = ref('')
const toastVariant = ref('info')
const selectedSongIds = reactive({})
const searchTerms = reactive({})
const translationValues = reactive({})
const dropdownOpen = reactive({})
const showToast = (message, variant = 'info') => {
toastMessage.value = message
toastVariant.value = variant
setTimeout(() => {
toastMessage.value = ''
}, 2500)
}
const normalize = (value) => (value ?? '').toString().toLowerCase().trim()
const initLocalState = () => {
props.serviceSongs.forEach((serviceSong) => {
selectedSongIds[serviceSong.id] = selectedSongIds[serviceSong.id] ?? ''
searchTerms[serviceSong.id] = searchTerms[serviceSong.id] ?? ''
translationValues[serviceSong.id] = serviceSong.use_translation
})
}
watch(
() => props.serviceSongs,
() => {
initLocalState()
},
{ immediate: true, deep: true },
)
const sortedSongs = () => {
return [...props.serviceSongs].sort((a, b) => a.order - b.order)
}
const filteredCatalog = (serviceSongId) => {
const term = normalize(searchTerms[serviceSongId])
if (term === '') {
return props.songsCatalog.slice(0, 100)
}
return props.songsCatalog
.filter((song) => normalize(song.title).includes(term) || normalize(song.ccli_id).includes(term))
.slice(0, 100)
}
const assignSong = async (serviceSongId) => {
const selectedSongId = Number(selectedSongIds[serviceSongId])
if (!selectedSongId) {
showToast('Bitte waehle zuerst einen Song aus.', 'warning')
return
}
try {
await window.axios.post(`/api/service-songs/${serviceSongId}/assign`, {
song_id: selectedSongId,
})
selectedSongIds[serviceSongId] = ''
searchTerms[serviceSongId] = ''
showToast('Song wurde zugeordnet.', 'success')
router.reload({
only: ['serviceSongs'],
preserveScroll: true,
preserveState: true,
})
} catch {
showToast('Zuordnung fehlgeschlagen.', 'error')
}
}
const requestCreation = async (serviceSongId) => {
try {
await window.axios.post(`/api/service-songs/${serviceSongId}/request`)
showToast('Anfrage wurde gesendet.', 'success')
router.reload({
only: ['serviceSongs'],
preserveScroll: true,
preserveState: true,
})
} catch {
showToast('Anfrage konnte nicht gesendet werden.', 'error')
}
}
const updateUseTranslation = async (serviceSongId) => {
try {
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
use_translation: Boolean(translationValues[serviceSongId]),
})
showToast('Uebersetzung wurde gespeichert.', 'success')
} catch {
showToast('Speichern fehlgeschlagen.', 'error')
}
}
const updateArrangementSelection = async (serviceSongId, arrangementId) => {
try {
await window.axios.patch(`/api/service-songs/${serviceSongId}`, {
song_arrangement_id: arrangementId,
})
showToast('Arrangement wurde gespeichert.', 'success')
} catch {
showToast('Arrangement konnte nicht gespeichert werden.', 'error')
}
}
const openDropdown = (serviceSongId) => {
dropdownOpen[serviceSongId] = true
}
const closeDropdown = (serviceSongId) => {
setTimeout(() => { dropdownOpen[serviceSongId] = false }, 200)
}
const selectSong = (serviceSongId, song) => {
selectedSongIds[serviceSongId] = song.id
searchTerms[serviceSongId] = song.title
dropdownOpen[serviceSongId] = false
}
// Preview / PDF
const previewData = ref(null)
const showPreview = ref(false)
const previewLoading = ref(false)
const openSongPreview = async (serviceSong) => {
if (!serviceSong.song_id || !serviceSong.song_arrangement_id) {
showToast('Bitte zuerst ein Arrangement auswählen.', 'warning')
return
}
previewLoading.value = true
showPreview.value = true
try {
const response = await window.axios.get(
`/songs/${serviceSong.song_id}/arrangements/${serviceSong.song_arrangement_id}/preview`
)
previewData.value = response.data
} catch {
showToast('Vorschau konnte nicht geladen werden.', 'error')
showPreview.value = false
} finally {
previewLoading.value = false
}
}
const closePreview = () => {
showPreview.value = false
previewData.value = null
}
const downloadPreviewPdf = () => {
if (previewData.value?.song?.id && previewData.value?.arrangement?.id) {
window.location.href = `/songs/${previewData.value.song.id}/arrangements/${previewData.value.arrangement.id}/pdf`
}
}
const downloadSongPdf = (serviceSong) => {
if (!serviceSong.song_id || !serviceSong.song_arrangement_id) {
showToast('Bitte zuerst ein Arrangement auswählen.', 'warning')
return
}
window.location.href = `/songs/${serviceSong.song_id}/arrangements/${serviceSong.song_arrangement_id}/pdf`
}
const formatDateTime = (value) => {
if (!value) {
return null
}
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const toastClasses = () => {
if (toastVariant.value === 'success') {
return 'border-emerald-300 bg-emerald-50 text-emerald-700'
}
if (toastVariant.value === 'warning') {
return 'border-amber-300 bg-amber-50 text-amber-700'
}
if (toastVariant.value === 'error') {
return 'border-red-300 bg-red-50 text-red-700'
}
return 'border-slate-300 bg-slate-50 text-slate-700'
}
</script>
<template>
<div data-testid="songs-block" class="space-y-4">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="toastMessage"
class="rounded-lg border px-4 py-3 text-sm font-medium"
:class="toastClasses()"
role="status"
>
{{ toastMessage }}
</div>
</Transition>
<div
data-testid="songs-block-song-card"
v-for="serviceSong in sortedSongs()"
:key="serviceSong.id"
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Song {{ serviceSong.order }}
</p>
<h4 class="mt-1 text-lg font-semibold text-gray-900">
{{ serviceSong.cts_song_name || '-' }}
</h4>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-600">
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
CCLI: {{ serviceSong.song?.ccli_id || serviceSong.cts_ccli_id || '-' }}
</span>
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
Hat Uebersetzung:
{{ serviceSong.song?.has_translation ? 'Ja' : 'Nein' }}
</span>
</div>
</div>
<span
v-if="serviceSong.song_id"
class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700"
>
Zugeordnet
</span>
<span
v-else
class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
>
Nicht zugeordnet
</span>
</div>
<div
v-if="!serviceSong.song_id"
class="space-y-4 rounded-lg border border-amber-200 bg-amber-50/40 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-amber-900">
Dieser CTS-Song ist noch nicht in der Song-DB verknuepft.
</p>
<p
v-if="serviceSong.request_sent_at"
class="mt-1 text-xs text-amber-700"
>
Anfrage gesendet am {{ formatDateTime(serviceSong.request_sent_at) }}
</p>
</div>
<button
data-testid="songs-block-request-button"
type="button"
class="inline-flex items-center rounded-md border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
@click="requestCreation(serviceSong.id)"
>
Erstellung anfragen
</button>
</div>
<div class="grid gap-3 md:grid-cols-[1fr,auto] md:items-end">
<div class="relative">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Song suchen und auswählen
</label>
<input
data-testid="songs-block-search-input"
v-model="searchTerms[serviceSong.id]"
type="text"
placeholder="Titel oder CCLI eingeben…"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
@focus="openDropdown(serviceSong.id)"
@input="openDropdown(serviceSong.id)"
@blur="closeDropdown(serviceSong.id)"
>
<!-- Selected indicator -->
<div v-if="selectedSongIds[serviceSong.id]" class="mt-1 flex items-center gap-2">
<span class="text-xs text-emerald-700 font-medium">
Ausgewählt: {{ songsCatalog.find(s => s.id === Number(selectedSongIds[serviceSong.id]))?.title || '' }}
</span>
<button type="button" class="text-xs text-gray-400 hover:text-red-500" @click="selectedSongIds[serviceSong.id] = ''; searchTerms[serviceSong.id] = ''">&#10005;</button>
</div>
<!-- Dropdown -->
<div
v-if="dropdownOpen[serviceSong.id] && filteredCatalog(serviceSong.id).length > 0"
class="absolute z-30 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="song in filteredCatalog(serviceSong.id)"
:key="song.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-emerald-50"
@mousedown.prevent="selectSong(serviceSong.id, song)"
>
<span class="font-medium text-gray-900">{{ song.title }}</span>
<span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span>
</button>
</div>
</div>
<button
data-testid="songs-block-assign-button"
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
@click="assignSong(serviceSong.id)"
>
Zuordnen
</button>
</div>
</div>
<div v-else class="space-y-4">
<div class="flex flex-wrap items-center gap-4">
<p class="text-sm text-gray-600">
Zugeordnet zu:
<span class="font-semibold text-gray-900">
{{ serviceSong.song?.title }}
</span>
</p>
<label
v-if="serviceSong.song?.has_translation"
class="inline-flex items-center gap-2 text-sm font-medium text-gray-800"
>
<input
data-testid="songs-block-translation-checkbox"
v-model="translationValues[serviceSong.id]"
type="checkbox"
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
@change="updateUseTranslation(serviceSong.id)"
>
Uebersetzung verwenden
</label>
</div>
<ArrangementConfigurator
:song-id="serviceSong.song.id"
:arrangements="serviceSong.song.arrangements || []"
:available-groups="serviceSong.song.groups || []"
:selected-arrangement-id="serviceSong.song_arrangement_id"
@arrangement-selected="(arrangementId) => updateArrangementSelection(serviceSong.id, arrangementId)"
/>
<div class="flex flex-wrap gap-2">
<button
data-testid="songs-block-preview-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
:disabled="!serviceSong.song_arrangement_id"
:class="{ 'opacity-50 cursor-not-allowed': !serviceSong.song_arrangement_id }"
@click="openSongPreview(serviceSong)"
>
Vorschau
</button>
<button
data-testid="songs-block-download-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
:disabled="!serviceSong.song_arrangement_id"
:class="{ 'opacity-50 cursor-not-allowed': !serviceSong.song_arrangement_id }"
@click="downloadSongPdf(serviceSong)"
>
PDF herunterladen
</button>
<a
data-testid="songs-block-download-pro-button"
:href="`/api/songs/${serviceSong.song_id}/download-pro`"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
>
.pro herunterladen
</a>
</div>
</div>
</div>
<div
v-if="serviceSongs.length === 0"
class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-10 text-center"
>
<p class="text-sm font-medium text-gray-600">
Fuer diesen Service sind aktuell keine Songs vorhanden.
</p>
</div>
<!-- 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="showPreview"
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">
{{ previewData?.song?.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-3">
<div v-for="(group, idx) in previewData.groups" :key="idx"
class="rounded-lg border overflow-hidden"
:style="{ borderColor: group.color || '#6b7280' }"
>
<!-- Group name as box title bar -->
<div class="px-3 py-1.5 text-xs font-bold text-white"
:style="{ backgroundColor: group.color || '#6b7280' }"
>
{{ group.name }}
</div>
<!-- Slides content with lightened background -->
<div class="px-3 py-2 space-y-1"
:style="{ backgroundColor: (group.color || '#6b7280') + '15' }"
>
<div v-for="(slide, sIdx) in group.slides" :key="sIdx"
class="whitespace-pre-wrap text-sm text-gray-700 leading-snug"
>{{ slide.text_content }}</div>
</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 class="flex justify-end pt-3 border-t border-gray-200">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-700"
@click="downloadPreviewPdf"
>
<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 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>
PDF herunterladen
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>