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.
This commit is contained in:
Thorsten Bus 2026-03-02 23:02:51 +01:00
parent b40c371edc
commit af0c72ebcc
7 changed files with 143 additions and 160 deletions

View file

@ -31,21 +31,27 @@ public function preview(Song $song, SongArrangement $arrangement): JsonResponse
]); ]);
} }
public function download(Song $song, SongArrangement $arrangement): Response public function download(Song $song, SongArrangement $arrangement): Response|JsonResponse
{ {
abort_unless($arrangement->song_id === $song->id, 404); abort_unless($arrangement->song_id === $song->id, 404);
$groupsInOrder = $this->buildGroupsInOrder($arrangement); try {
$groupsInOrder = $this->buildGroupsInOrder($arrangement);
$pdf = Pdf::loadView('pdf.song', [ $pdf = Pdf::loadView('pdf.song', [
'song' => $song, 'song' => $song,
'arrangement' => $arrangement, 'arrangement' => $arrangement,
'groupsInOrder' => $groupsInOrder, 'groupsInOrder' => $groupsInOrder,
]); ])->setPaper('a4', 'portrait');
$filename = str($song->title)->slug() . '-' . str($arrangement->name)->slug() . '.pdf'; $filename = str($song->title)->slug().'-'.str($arrangement->name)->slug().'.pdf';
return $pdf->download($filename); return $pdf->download($filename);
} catch (\Throwable $e) {
return response()->json([
'message' => 'PDF-Erzeugung fehlgeschlagen: '.$e->getMessage(),
], 500);
}
} }
private function buildGroupsInOrder(SongArrangement $arrangement): array private function buildGroupsInOrder(SongArrangement $arrangement): array

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3' import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
@ -58,76 +58,41 @@ watch(
}, },
) )
const colorMap = computed(() => {
const map = {}
poolGroups.value.forEach((group) => {
map[group.id] = group.color
})
arrangementGroups.value.forEach((group) => {
map[group.id] = group.color
})
return map
})
const groupPillStyle = (group) => ({ const groupPillStyle = (group) => ({
backgroundColor: group.color, backgroundColor: group.color,
}) })
const pendingAutoSelect = ref(false)
watch(
() => props.arrangements.length,
(newLen, oldLen) => {
if (pendingAutoSelect.value && newLen > oldLen) {
const newest = props.arrangements.reduce((a, b) => a.id > b.id ? a : b)
selectedId.value = newest.id
pendingAutoSelect.value = false
}
},
)
const addArrangement = () => { const addArrangement = () => {
const name = window.prompt('Name des neuen Arrangements') const name = window.prompt('Name des neuen Arrangements')
if (!name) return
if (!name) { pendingAutoSelect.value = true
return router.post(`/songs/${props.songId}/arrangements`, { name }, { preserveScroll: true })
}
router.post(
`/songs/${props.songId}/arrangements`,
{ name },
{
preserveScroll: true,
onSuccess: () => {
nextTick(() => {
const updatedArrangements = props.arrangements
if (updatedArrangements.length > 0) {
const newest = updatedArrangements.reduce((a, b) => a.id > b.id ? a : b)
selectedId.value = newest.id
}
})
},
},
)
} }
const cloneArrangement = () => { const cloneArrangement = () => {
if (!selectedArrangement.value) { if (!selectedArrangement.value) return
return
}
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`) const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
if (!name) return
pendingAutoSelect.value = true
router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, { preserveScroll: true })
}
if (!name) { const addGroupFromPool = (group) => {
return arrangementGroups.value.push({ ...group })
} saveArrangement()
router.post(
`/arrangements/${selectedArrangement.value.id}/clone`,
{ name },
{
preserveScroll: true,
onSuccess: () => {
nextTick(() => {
const updatedArrangements = props.arrangements
if (updatedArrangements.length > 0) {
const newest = updatedArrangements.reduce((a, b) => a.id > b.id ? a : b)
selectedId.value = newest.id
}
})
},
},
)
} }
const removeGroupAt = (index) => { const removeGroupAt = (index) => {
@ -135,25 +100,6 @@ const removeGroupAt = (index) => {
saveArrangement() saveArrangement()
} }
const syncPoolColor = (groupId, color) => {
poolGroups.value = poolGroups.value.map((group) => {
if (group.id !== groupId) {
return group
}
return {
...group,
color,
}
})
}
const updateGroupColor = (group, color) => {
group.color = color
syncPoolColor(group.id, color)
saveArrangement()
}
const saveArrangement = () => { const saveArrangement = () => {
if (!selectedArrangement.value) { if (!selectedArrangement.value) {
return return
@ -166,7 +112,6 @@ const saveArrangement = () => {
song_group_id: group.id, song_group_id: group.id,
order: index + 1, order: index + 1,
})), })),
group_colors: colorMap.value,
}, },
{ {
preserveScroll: true, preserveScroll: true,
@ -241,8 +186,8 @@ const deleteArrangement = () => {
</div> </div>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div class="space-y-2"> <div class="space-y-1">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600"> <h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Verfügbare Gruppen Verfügbare Gruppen
</h4> </h4>
@ -250,79 +195,76 @@ const deleteArrangement = () => {
v-model="poolGroups" v-model="poolGroups"
:group="{ name: 'song-groups', pull: 'clone', put: false }" :group="{ name: 'song-groups', pull: 'clone', put: false }"
:sort="false" :sort="false"
class="min-h-24 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3" ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex flex-wrap gap-1.5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-2"
> >
<div <span
v-for="group in poolGroups" v-for="group in poolGroups"
:key="group.id" :key="group.id"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2" class="inline-flex cursor-grab rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80"
:style="groupPillStyle(group)"
@click="addGroupFromPool(group)"
> >
<span {{ group.name }}
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white" </span>
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
</div>
</VueDraggable> </VueDraggable>
</div> </div>
<div class="space-y-2"> <div class="space-y-1">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600"> <h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge Gruppenfolge
</h4> </h4>
<VueDraggable <VueDraggable
v-model="arrangementGroups" v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: true }" :group="{ name: 'song-groups', pull: true, put: true }"
class="min-h-24 rounded-lg border border-gray-200 bg-gray-50 p-3" ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex min-h-10 flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-gray-50 p-2"
@end="saveArrangement" @end="saveArrangement"
> >
<div <span
v-for="(group, index) in arrangementGroups" v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`" :key="`${group.id}-${index}`"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2" class="inline-flex cursor-grab items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-xs font-semibold text-white"
:style="groupPillStyle(group)"
> >
<span data-testid="arrangement-drag-handle" class="cursor-move text-gray-500"></span> {{ group.name }}
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
<button <button
data-testid="arrangement-remove-button" data-testid="arrangement-remove-button"
type="button" type="button"
class="rounded px-2 py-1 text-xs font-semibold text-red-600 hover:bg-red-50" class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/20 hover:text-white"
@click="removeGroupAt(index)" @click.stop="removeGroupAt(index)"
> >
Entfernen ×
</button> </button>
</div> </span>
</VueDraggable> </VueDraggable>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
:deep(.drag-ghost) {
opacity: 0.4;
ring: 2px solid white;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 0 4px rgba(99, 102, 241, 0.6);
border-radius: 9999px;
}
:deep(.drag-chosen) {
opacity: 0.7;
}
:deep(.drag-active) {
opacity: 1 !important;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.8);
border-radius: 9999px;
z-index: 50;
}
</style>

View file

@ -176,6 +176,12 @@ const closePreview = () => {
previewData.value = null 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) => { const downloadSongPdf = (serviceSong) => {
if (!serviceSong.song_id || !serviceSong.song_arrangement_id) { if (!serviceSong.song_id || !serviceSong.song_arrangement_id) {
showToast('Bitte zuerst ein Arrangement auswählen.', 'warning') showToast('Bitte zuerst ein Arrangement auswählen.', 'warning')
@ -251,7 +257,7 @@ const toastClasses = () => {
</h4> </h4>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-600"> <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"> <span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
CCLI: {{ serviceSong.cts_ccli_id || '-' }} CCLI: {{ serviceSong.song?.ccli_id || serviceSong.cts_ccli_id || '-' }}
</span> </span>
<span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium"> <span class="rounded-full bg-gray-100 px-2.5 py-1 font-medium">
Hat Uebersetzung: Hat Uebersetzung:
@ -405,6 +411,13 @@ const toastClasses = () => {
> >
PDF herunterladen PDF herunterladen
</button> </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>
</div> </div>
@ -449,22 +462,43 @@ const toastClasses = () => {
<span class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span> <span class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span>
</div> </div>
<div v-else-if="previewData" class="space-y-4"> <div v-else-if="previewData" class="space-y-3">
<div v-for="(group, idx) in previewData.groups" :key="idx" class="rounded-lg border border-gray-200 p-4"> <div v-for="(group, idx) in previewData.groups" :key="idx"
<div class="mb-2 flex items-center gap-2"> class="rounded-lg border overflow-hidden"
<span :style="{ borderColor: group.color || '#6b7280' }"
class="inline-flex rounded-full px-3 py-1 text-xs font-semibold text-white" >
:style="{ backgroundColor: group.color || '#6b7280' }" <!-- Group name as box title bar -->
> <div class="px-3 py-1.5 text-xs font-bold text-white"
{{ group.name }} :style="{ backgroundColor: group.color || '#6b7280' }"
</span> >
{{ 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-for="(slide, sIdx) in group.slides" :key="sIdx" class="mt-2 whitespace-pre-wrap text-sm text-gray-700 leading-relaxed">{{ slide.text_content }}</div>
</div> </div>
<div v-if="previewData.song?.copyright_text" class="border-t pt-3 text-xs text-gray-400"> <div v-if="previewData.song?.copyright_text" class="border-t pt-3 text-xs text-gray-400">
{{ previewData.song.copyright_text }} {{ previewData.song.copyright_text }}
</div> </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> </div>
</div> </div>

View file

@ -112,7 +112,7 @@ const maxWidthClass = computed(() => {
> >
<div <div
v-show="show" v-show="show"
class="mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full" class="relative z-10 mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full"
:class="maxWidthClass" :class="maxWidthClass"
> >
<slot v-if="showSlot" /> <slot v-if="showSlot" />

View file

@ -54,10 +54,10 @@ watch(files, (newFiles) => {
if (newFiles.length > 0 && !uploading.value) { if (newFiles.length > 0 && !uploading.value) {
processFiles() processFiles()
} }
}) }, { deep: true })
function processFiles() { function processFiles() {
if (files.value.length === 0) return if (files.value.length === 0 || uploading.value) return
uploadError.value = '' uploadError.value = ''
totalCount.value = files.value.length totalCount.value = files.value.length
@ -242,7 +242,6 @@ function dismissError() {
:disabled="isUploading" :disabled="isUploading"
:show-select-button="false" :show-select-button="false"
:class="['slide-dropzone', { 'slide-dropzone--inline': inline }]" :class="['slide-dropzone', { 'slide-dropzone--inline': inline }]"
@change="processFiles"
> >
<template #placeholder-img> <template #placeholder-img>
<div class="flex flex-col items-center gap-2 pointer-events-none"> <div class="flex flex-col items-center gap-2 pointer-events-none">

View file

@ -97,7 +97,10 @@ async function toggleExpanded(id) {
}) })
if (response.ok) { if (response.ok) {
detailData.value[id] = await response.json() const data = await response.json()
data._showContext = true
data._showResponse = true
detailData.value[id] = data
} }
} catch (e) { } catch (e) {
console.error('Detail load error:', e) console.error('Detail load error:', e)
@ -211,7 +214,7 @@ async function toggleExpanded(id) {
Antwort-Daten Antwort-Daten
</button> </button>
<div v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3"> <div v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3">
<JsonTreeViewer :data="parseJson(detailData[log.id].response_body)" :expand-depth="1" /> <JsonTreeViewer :data="parseJson(detailData[log.id].response_body)" :expand-depth="2" />
</div> </div>
<p v-if="detailData[log.id]._showResponse && !detailData[log.id].response_body" class="mt-1 text-sm italic text-gray-400">Keine Antwort-Daten verfügbar</p> <p v-if="detailData[log.id]._showResponse && !detailData[log.id].response_body" class="mt-1 text-sm italic text-gray-400">Keine Antwort-Daten verfügbar</p>
</div> </div>

View file

@ -541,18 +541,17 @@ function pageRange() {
</a> </a>
<!-- Herunterladen --> <!-- Herunterladen -->
<button <a
data-testid="song-list-download-button" data-testid="song-list-download-button"
type="button" :href="`/api/songs/${song.id}/download-pro`"
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" 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" 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"> <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" /> <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> </svg>
Herunterladen Herunterladen
</button> </a>
<!-- Löschen --> <!-- Löschen -->
<button <button