feat(ui): add MASTER arrangement, fix slide upload/thumbnail bugs, add slide preview with navigation

- 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)
This commit is contained in:
Thorsten Bus 2026-03-29 17:41:26 +02:00
parent 852231ae01
commit b8b92f094e
5 changed files with 518 additions and 117 deletions

View file

@ -1,6 +1,9 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue' import SlideUploader from '@/Components/SlideUploader.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({ const props = defineProps({
agendaItem: { type: Object, required: true }, agendaItem: { type: Object, required: true },
@ -8,23 +11,35 @@ const props = defineProps({
serviceDate: { type: String, default: null }, serviceDate: { type: String, default: null },
}) })
const emit = defineEmits(['slides-updated']) const emit = defineEmits(['slides-updated', 'scroll-to-info'])
const showUploader = ref(false) const showUploader = ref(false)
// Preview state
const previewSlide = ref(null)
const previewIndex = ref(-1)
// Delete confirmation state
const confirmingDelete = ref(false)
const slideToDelete = ref(null)
const deleting = ref(false)
// Hover preview state
const hoverSlide = ref(null)
const hoverPosition = ref({ x: 0, y: 0 })
const allSlides = computed(() => props.agendaItem.slides ?? [])
const responsibleNames = computed(() => { const responsibleNames = computed(() => {
const r = props.agendaItem.responsible const r = props.agendaItem.responsible
if (!r) return '' if (!r) return ''
// CTS returns {text: "...", persons: [{person: {title: "Name"}, service: "[Role]"}]}
if (r.persons && Array.isArray(r.persons)) { if (r.persons && Array.isArray(r.persons)) {
const names = r.persons const names = r.persons
.map((p) => p.person?.title || p.name || '') .map((p) => p.person?.title || p.name || '')
.filter(Boolean) .filter(Boolean)
if (names.length > 0) return [...new Set(names)].join(', ') if (names.length > 0) return [...new Set(names)].join(', ')
} }
// Fallback: use text field
if (r.text) return r.text if (r.text) return r.text
// Legacy: array of objects
if (Array.isArray(r)) { if (Array.isArray(r)) {
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ') return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
} }
@ -63,10 +78,99 @@ const borderClass = computed(() => {
return '' return ''
}) })
function thumbnailUrl(slide) {
if (!slide.thumbnail_filename) return null
return `/storage/${slide.thumbnail_filename}`
}
function fullImageUrl(slide) {
if (!slide.stored_filename) return null
return `/storage/${slide.stored_filename}`
}
function onUploaded() { function onUploaded() {
showUploader.value = false
emit('slides-updated') emit('slides-updated')
} }
// Preview with navigation
function openPreview(slide) {
previewSlide.value = slide
previewIndex.value = allSlides.value.findIndex((s) => s.id === slide.id)
}
function closePreview() {
previewSlide.value = null
previewIndex.value = -1
}
function prevPreview() {
if (previewIndex.value <= 0) return
previewIndex.value--
previewSlide.value = allSlides.value[previewIndex.value]
}
function nextPreview() {
if (previewIndex.value >= allSlides.value.length - 1) return
previewIndex.value++
previewSlide.value = allSlides.value[previewIndex.value]
}
const hasPrev = computed(() => previewIndex.value > 0)
const hasNext = computed(() => previewIndex.value < allSlides.value.length - 1)
// Delete
function promptDelete(slide, event) {
event.stopPropagation()
slideToDelete.value = slide
confirmingDelete.value = true
}
function cancelDelete() {
confirmingDelete.value = false
slideToDelete.value = null
}
function confirmDelete() {
if (!slideToDelete.value) return
deleting.value = true
const deletedId = slideToDelete.value.id
axios.delete(route('slides.destroy', deletedId))
.then(() => {
confirmingDelete.value = false
slideToDelete.value = null
deleting.value = false
if (previewSlide.value?.id === deletedId) {
previewSlide.value = null
previewIndex.value = -1
}
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deleting.value = false
})
}
// Hover preview
function onThumbMouseEnter(slide, event) {
const rect = event.target.getBoundingClientRect()
hoverPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top,
}
hoverSlide.value = slide
}
function onThumbMouseLeave() {
hoverSlide.value = null
}
// Scroll to info block
function scrollToInfo() {
emit('scroll-to-info')
}
</script> </script>
<template> <template>
@ -116,13 +220,32 @@ function onUploaded() {
<!-- Slide thumbnails --> <!-- Slide thumbnails -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1"> <div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
<img <div
v-for="slide in agendaItem.slides.slice(0, 4)" v-for="slide in agendaItem.slides.slice(0, 4)"
:key="slide.id" :key="slide.id"
:src="`/storage/slides/${slide.thumbnail_filename}`" class="group/thumb relative"
class="h-8 w-14 rounded border border-gray-200 object-cover" >
<img
:src="thumbnailUrl(slide)"
class="h-8 w-14 cursor-pointer rounded border border-gray-200 object-cover transition-all hover:border-amber-400 hover:ring-1 hover:ring-amber-400/40"
:alt="slide.original_filename" :alt="slide.original_filename"
data-testid="agenda-slide-thumbnail"
@click="openPreview(slide)"
@mouseenter="onThumbMouseEnter(slide, $event)"
@mouseleave="onThumbMouseLeave"
/> />
<!-- Delete X button -->
<button
data-testid="agenda-slide-delete-btn"
class="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white shadow-sm transition hover:bg-red-600 group-hover/thumb:flex"
title="Folie entfernen"
@click="promptDelete(slide, $event)"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<span <span
v-if="agendaItem.slides.length > 4" v-if="agendaItem.slides.length > 4"
class="flex h-8 w-14 items-center justify-center rounded border border-gray-200 text-[10px] text-gray-500" class="flex h-8 w-14 items-center justify-center rounded border border-gray-200 text-[10px] text-gray-500"
@ -140,7 +263,22 @@ function onUploaded() {
<!-- Aktionen --> <!-- Aktionen -->
<td class="py-2.5 align-top"> <td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<!-- Announcement row: scroll to info block instead of + button -->
<button <button
v-if="agendaItem.is_announcement_position"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
data-testid="agenda-item-scroll-to-info"
title="Zu Info-Folien springen"
@click="scrollToInfo"
>
<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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
</button>
<!-- Normal rows: + / x toggle for uploader -->
<button
v-else
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-500" class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-500"
data-testid="agenda-item-add-slides" data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'" :title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@ -157,8 +295,8 @@ function onUploaded() {
</td> </td>
</tr> </tr>
<!-- Uploader row (spanning all columns) --> <!-- Uploader row (spanning all columns) not for announcement rows -->
<tr v-if="showUploader"> <tr v-if="showUploader && !agendaItem.is_announcement_position">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3"> <td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader <SlideUploader
type="agenda_item" type="agenda_item"
@ -170,4 +308,130 @@ function onUploaded() {
/> />
</td> </td>
</tr> </tr>
<!-- Hover preview tooltip (doubled size: w-[512px]) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="hoverSlide"
class="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full"
:style="{ left: hoverPosition.x + 'px', top: (hoverPosition.y - 8) + 'px' }"
>
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<img
:src="thumbnailUrl(hoverSlide)"
:alt="hoverSlide.original_filename"
class="h-auto w-[512px] object-contain"
/>
</div>
</div>
</Transition>
</Teleport>
<!-- Full preview overlay with prev/next navigation -->
<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="previewSlide"
data-testid="agenda-slide-preview"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm"
@click.self="closePreview"
>
<!-- Image + controls -->
<div class="relative max-h-[90vh] max-w-[90vw]">
<img
:src="fullImageUrl(previewSlide)"
:alt="previewSlide.original_filename"
class="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
/>
<!-- Previous arrow (left edge of image) -->
<button
v-if="hasPrev"
data-testid="agenda-slide-preview-prev"
class="absolute left-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Vorherige Folie"
@click="prevPreview"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<!-- Next arrow (right edge of image) -->
<button
v-if="hasNext"
data-testid="agenda-slide-preview-next"
class="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Nächste Folie"
@click="nextPreview"
>
<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="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<!-- Slide counter -->
<div
v-if="allSlides.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white tabular-nums"
>
{{ previewIndex + 1 }} / {{ allSlides.length }}
</div>
<div class="absolute -top-3 -right-3 flex gap-2">
<!-- Download button -->
<a
:href="fullImageUrl(previewSlide)"
:download="previewSlide.original_filename"
class="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg transition hover:bg-emerald-700"
title="Herunterladen"
data-testid="agenda-slide-preview-download"
>
<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="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>
</a>
<!-- Close button -->
<button
@click="closePreview"
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-800 text-white shadow-lg transition hover:bg-gray-700"
title="Schließen"
data-testid="agenda-slide-preview-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>
</div>
</Transition>
</Teleport>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="confirmingDelete"
title="Folie löschen?"
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
confirm-label="Löschen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</template> </template>

View file

@ -3,6 +3,8 @@ 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'
const MASTER_ID = 'master'
const props = defineProps({ const props = defineProps({
songId: { songId: {
type: Number, type: Number,
@ -17,22 +19,41 @@ const props = defineProps({
required: true, required: true,
}, },
selectedArrangementId: { selectedArrangementId: {
type: Number, type: [Number, String],
default: null, default: null,
}, },
}) })
const emit = defineEmits(['arrangement-selected']) const emit = defineEmits(['arrangement-selected', 'arrangements-changed'])
// Virtual MASTER arrangement always first, computed from availableGroups
const masterArrangement = computed(() => ({
id: MASTER_ID,
name: 'MASTER',
is_default: false,
is_master: true,
groups: props.availableGroups.map((g) => ({ ...g })),
}))
// All arrangements with MASTER prepended
const allArrangements = computed(() => [
masterArrangement.value,
...props.arrangements,
])
const selectedId = ref( const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null, props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? MASTER_ID,
) )
const isMasterSelected = computed(() => selectedId.value === MASTER_ID)
const arrangementGroups = ref([]) const arrangementGroups = ref([])
const poolGroups = ref([]) const poolGroups = ref([])
const selectedArrangement = computed(() => const selectedArrangement = computed(() =>
props.arrangements.find((arrangement) => arrangement.id === Number(selectedId.value)) ?? null, allArrangements.value.find((arrangement) => arrangement.id === selectedId.value)
?? allArrangements.value.find((arrangement) => arrangement.id === Number(selectedId.value))
?? null,
) )
watch( watch(
@ -54,7 +75,7 @@ watch(
watch( watch(
selectedId, selectedId,
(arrangementId) => { (arrangementId) => {
emit('arrangement-selected', Number(arrangementId)) emit('arrangement-selected', arrangementId === MASTER_ID ? null : Number(arrangementId))
}, },
) )
@ -75,33 +96,60 @@ watch(
}, },
) )
const addArrangement = () => { function addArrangement() {
const name = window.prompt('Name des neuen Arrangements') const name = window.prompt('Name des neuen Arrangements')
if (!name) return if (!name) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, { preserveScroll: true }) router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
} }
const cloneArrangement = () => { function cloneArrangement() {
if (!selectedArrangement.value) return if (!selectedArrangement.value) return
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) {
const name = window.prompt('Name des neuen Arrangements', 'MASTER Kopie')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
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 if (!name) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, { preserveScroll: true }) router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
} }
const addGroupFromPool = (group) => { function addGroupFromPool(group) {
if (isMasterSelected.value) return
arrangementGroups.value.push({ ...group }) arrangementGroups.value.push({ ...group })
saveArrangement() saveArrangement()
} }
const removeGroupAt = (index) => { function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1) arrangementGroups.value.splice(index, 1)
saveArrangement() saveArrangement()
} }
const saveArrangement = () => { function saveArrangement() {
if (!selectedArrangement.value) { if (!selectedArrangement.value || isMasterSelected.value) {
return return
} }
@ -120,13 +168,16 @@ const saveArrangement = () => {
) )
} }
const deleteArrangement = () => { function deleteArrangement() {
if (!selectedArrangement.value) { if (!selectedArrangement.value || isMasterSelected.value) {
return return
} }
router.delete(`/arrangements/${selectedArrangement.value.id}`, { router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
}) })
} }
</script> </script>
@ -148,11 +199,11 @@ const deleteArrangement = () => {
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
> >
<option <option
v-for="arrangement in arrangements" v-for="arrangement in allArrangements"
:key="arrangement.id" :key="arrangement.id"
:value="arrangement.id" :value="arrangement.id"
> >
{{ arrangement.name }} {{ arrangement.name }}{{ arrangement.is_default ? ' (Standard)' : '' }}
</option> </option>
</select> </select>
</div> </div>
@ -178,7 +229,8 @@ const deleteArrangement = () => {
<button <button
data-testid="arrangement-delete-button" data-testid="arrangement-delete-button"
type="button" type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700" class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement" @click="deleteArrangement"
> >
Löschen Löschen
@ -193,17 +245,20 @@ const deleteArrangement = () => {
<VueDraggable <VueDraggable
v-model="poolGroups" v-model="poolGroups"
:group="{ name: 'song-groups', pull: 'clone', put: false }" :group="{ name: 'song-groups', pull: isMasterSelected ? false : 'clone', put: false }"
:sort="false" :sort="false"
:disabled="isMasterSelected"
ghost-class="drag-ghost" ghost-class="drag-ghost"
chosen-class="drag-chosen" chosen-class="drag-chosen"
drag-class="drag-active" drag-class="drag-active"
class="flex flex-wrap gap-1.5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-2" class="flex flex-wrap gap-1.5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-2"
:class="{ 'opacity-50': isMasterSelected }"
> >
<span <span
v-for="group in poolGroups" v-for="group in poolGroups"
:key="group.id" :key="group.id"
class="inline-flex cursor-grab rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80" class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80"
:class="isMasterSelected ? 'cursor-default' : 'cursor-grab'"
:style="groupPillStyle(group)" :style="groupPillStyle(group)"
@click="addGroupFromPool(group)" @click="addGroupFromPool(group)"
> >
@ -215,25 +270,30 @@ const deleteArrangement = () => {
<div class="space-y-1"> <div class="space-y-1">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500"> <h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge Gruppenfolge
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
</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: !isMasterSelected }"
:disabled="isMasterSelected"
ghost-class="drag-ghost" ghost-class="drag-ghost"
chosen-class="drag-chosen" chosen-class="drag-chosen"
drag-class="drag-active" 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" class="flex min-h-10 flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-gray-50 p-2"
:class="{ 'opacity-60': isMasterSelected }"
@end="saveArrangement" @end="saveArrangement"
> >
<span <span
v-for="(group, index) in arrangementGroups" v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`" :key="`${group.id}-${index}`"
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" class="inline-flex items-center gap-1 rounded-full py-0.5 pl-2.5 text-xs font-semibold text-white"
:class="isMasterSelected ? 'cursor-default pr-2.5' : 'cursor-grab pr-1'"
:style="groupPillStyle(group)" :style="groupPillStyle(group)"
> >
{{ group.name }} {{ group.name }}
<button <button
v-if="!isMasterSelected"
data-testid="arrangement-remove-button" data-testid="arrangement-remove-button"
type="button" type="button"
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" 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"

View file

@ -3,6 +3,8 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3' import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
const MASTER_ID = 'master'
const props = defineProps({ const props = defineProps({
songId: { songId: {
type: Number, type: Number,
@ -89,6 +91,26 @@ async function assignSong() {
/* ── State ── */ /* ── State ── */
// Virtual MASTER arrangement always first, computed from availableGroups
const masterArrangement = computed(() => ({
id: MASTER_ID,
name: 'MASTER',
is_default: false,
is_master: true,
groups: props.availableGroups.map((g) => ({
...g,
slides: g.slides ?? [],
})),
}))
// All arrangements with MASTER prepended (for select dropdown)
const allArrangements = computed(() => [
masterArrangement.value,
...props.arrangements,
])
const isMasterSelected = computed(() => currentArrangementId.value === MASTER_ID)
// Local copy of arrangements so changes survive switching between arrangements // Local copy of arrangements so changes survive switching between arrangements
const localArrangements = ref(JSON.parse(JSON.stringify(props.arrangements))) const localArrangements = ref(JSON.parse(JSON.stringify(props.arrangements)))
@ -109,12 +131,13 @@ watch(
) )
const currentArrangementId = ref( const currentArrangementId = ref(
props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? null, props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? MASTER_ID,
) )
const currentArrangement = computed(() => const currentArrangement = computed(() => {
localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null, if (currentArrangementId.value === MASTER_ID) return masterArrangement.value
) return localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null
})
const arrangementGroups = ref([]) const arrangementGroups = ref([])
const hoveredIndex = ref(null) const hoveredIndex = ref(null)
@ -122,12 +145,16 @@ const hoveredIndex = ref(null)
watch( watch(
currentArrangementId, currentArrangementId,
(id) => { (id) => {
if (id === MASTER_ID) {
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
return
}
const arr = localArrangements.value.find((a) => a.id === Number(id)) const arr = localArrangements.value.find((a) => a.id === Number(id))
if (arr?.groups?.length) { if (arr?.groups?.length) {
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` })) arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
} else { } else {
// Fallback: show all available groups in order (Master) // Fallback: show all available groups in order (Master)
arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, _uid: `${g.id}-master-${i}-${Date.now()}` })) arrangementGroups.value = props.availableGroups.map((g, i) => ({ ...g, slides: g.slides ?? [], _uid: `${g.id}-master-${i}-${Date.now()}` }))
} }
}, },
{ immediate: true }, { immediate: true },
@ -209,29 +236,59 @@ function createArrangement() {
const name = window.prompt('Name für das neue Arrangement:') const name = window.prompt('Name für das neue Arrangement:')
if (!name?.trim()) return if (!name?.trim()) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { preserveScroll: true }) router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
} }
function cloneArrangement() { function cloneArrangement() {
if (!currentArrangementId.value) return if (!currentArrangementId.value) return
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) {
const name = window.prompt('Name für das geklonte Arrangement:', 'MASTER Kopie')
if (!name?.trim()) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
return
}
const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`) const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`)
if (!name?.trim()) return if (!name?.trim()) return
pendingAutoSelect.value = true pendingAutoSelect.value = true
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, { preserveScroll: true }) router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
} }
function deleteArrangement() { function deleteArrangement() {
if (!currentArrangementId.value) return if (!currentArrangementId.value || isMasterSelected.value) return
if (props.arrangements.length <= 1) { if (props.arrangements.length <= 1) {
alert('Das letzte Arrangement kann nicht gelöscht werden.') alert('Das letzte Arrangement kann nicht gelöscht werden.')
return return
} }
if (!confirm('Arrangement wirklich löschen?')) return if (!confirm('Arrangement wirklich löschen?')) return
router.delete(`/arrangements/${currentArrangementId.value}`, { preserveScroll: true }) router.delete(`/arrangements/${currentArrangementId.value}`, {
preserveScroll: true,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
} }
function saveArrangement() { function saveArrangement() {
if (!currentArrangement.value) return if (!currentArrangement.value || isMasterSelected.value) return
// Update local arrangements copy so changes survive switching // Update local arrangements copy so changes survive switching
const localArr = localArrangements.value.find((a) => a.id === currentArrangement.value.id) const localArr = localArrangements.value.find((a) => a.id === currentArrangement.value.id)
@ -260,6 +317,7 @@ function saveArrangement() {
/* ── Group operations ── */ /* ── Group operations ── */
function duplicateGroup(index) { function duplicateGroup(index) {
if (isMasterSelected.value) return
const item = arrangementGroups.value[index] const item = arrangementGroups.value[index]
const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` } const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` }
arrangementGroups.value.splice(index + 1, 0, newItem) arrangementGroups.value.splice(index + 1, 0, newItem)
@ -267,6 +325,7 @@ function duplicateGroup(index) {
} }
function addGroupAt(index, group) { function addGroupAt(index, group) {
if (isMasterSelected.value) return
const newItem = { const newItem = {
id: group.id, id: group.id,
name: group.name, name: group.name,
@ -280,6 +339,7 @@ function addGroupAt(index, group) {
} }
function removeGroupAt(index) { function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1) arrangementGroups.value.splice(index, 1)
saveArrangement() saveArrangement()
} }
@ -287,7 +347,7 @@ function removeGroupAt(index) {
/* ── Selection emit ── */ /* ── Selection emit ── */
watch(currentArrangementId, (id) => { watch(currentArrangementId, (id) => {
emit('arrangement-selected', Number(id)) emit('arrangement-selected', id === MASTER_ID ? null : Number(id))
}) })
/* ── Close on backdrop ── */ /* ── Close on backdrop ── */
@ -343,7 +403,7 @@ function closeOnBackdrop(e) {
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
> >
<option <option
v-for="arr in arrangements" v-for="arr in allArrangements"
:key="arr.id" :key="arr.id"
:value="arr.id" :value="arr.id"
> >
@ -373,7 +433,8 @@ function closeOnBackdrop(e) {
<button <button
data-testid="arrangement-delete-btn" data-testid="arrangement-delete-btn"
type="button" type="button"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-red-700" class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement" @click="deleteArrangement"
> >
Löschen Löschen
@ -477,22 +538,28 @@ function closeOnBackdrop(e) {
> >
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500"> <h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge Gruppenfolge
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
</h4> </h4>
<VueDraggable <VueDraggable
v-model="arrangementGroups" v-model="arrangementGroups"
:disabled="isMasterSelected"
ghost-class="drag-ghost" ghost-class="drag-ghost"
chosen-class="drag-chosen" chosen-class="drag-chosen"
drag-class="drag-active" drag-class="drag-active"
class="flex flex-col gap-2" class="flex flex-col gap-2"
:class="{ 'opacity-60': isMasterSelected }"
@end="saveArrangement" @end="saveArrangement"
> >
<div <div
v-for="(element, index) in arrangementGroups" v-for="(element, index) in arrangementGroups"
:key="element._uid" :key="element._uid"
data-testid="arrangement-pill" data-testid="arrangement-pill"
class="flex cursor-grab items-center gap-2 rounded-lg border-2 px-3 py-2" class="flex items-center gap-2 rounded-lg border-2 px-3 py-2"
:class="{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index }" :class="[
{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index },
isMasterSelected ? 'cursor-default' : 'cursor-grab',
]"
:style="{ :style="{
borderColor: element.color ?? '#6b7280', borderColor: element.color ?? '#6b7280',
backgroundColor: (element.color ?? '#6b7280') + '20', backgroundColor: (element.color ?? '#6b7280') + '20',
@ -503,6 +570,7 @@ function closeOnBackdrop(e) {
{{ element.name }} {{ element.name }}
</span> </span>
<template v-if="!isMasterSelected">
<!-- Duplicate button (2×) --> <!-- Duplicate button (2×) -->
<button <button
data-testid="arrangement-duplicate-btn" data-testid="arrangement-duplicate-btn"
@ -557,6 +625,7 @@ function closeOnBackdrop(e) {
> >
× ×
</button> </button>
</template>
</div> </div>
</VueDraggable> </VueDraggable>

View file

@ -491,6 +491,7 @@ onUnmounted(() => {
:song-id="songData.id" :song-id="songData.id"
:arrangements="arrangements" :arrangements="arrangements"
:available-groups="availableGroups" :available-groups="availableGroups"
@arrangements-changed="fetchSong"
/> />
</div> </div>
</div> </div>

View file

@ -65,10 +65,16 @@ function goBack() {
router.get(route('services.index')) router.get(route('services.index'))
} }
const informationBlockRef = ref(null)
function refreshPage() { function refreshPage() {
router.reload({ preserveScroll: true }) router.reload({ preserveScroll: true })
} }
function scrollToInfoBlock() {
informationBlockRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
/* ── Agenda helpers ──────────────────────────────────────── */ /* ── Agenda helpers ──────────────────────────────────────── */
const arrangementDialogItem = ref(null) const arrangementDialogItem = ref(null)
@ -349,34 +355,9 @@ async function downloadService() {
</div> </div>
</template> </template>
<!-- Information Block -->
<div class="py-6">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 shadow-sm">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
<p class="text-xs text-gray-500">Info-Folien fuer alle kommenden Services</p>
</div>
</div>
<InformationBlock
:service-id="service.id"
:service-date="service.date"
:slides="informationSlides"
@slides-updated="refreshPage"
/>
</div>
</div>
</div>
<!-- Ablauf (Agenda) --> <!-- Ablauf (Agenda) -->
<div class="py-6"> <div class="py-6">
<div class="mx-auto max-w-4xl px-4 pb-24 sm:px-6 lg:px-8"> <div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3> <h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
<!-- Empty state --> <!-- Empty state -->
@ -427,6 +408,7 @@ async function downloadService() {
:service-id="service.id" :service-id="service.id"
:service-date="service.date" :service-date="service.date"
@slides-updated="refreshPage" @slides-updated="refreshPage"
@scroll-to-info="scrollToInfoBlock"
/> />
</template> </template>
</tbody> </tbody>
@ -434,6 +416,31 @@ async function downloadService() {
</div> </div>
</div> </div>
<!-- Information Block (below agenda, scrolled to from announcement row) -->
<div ref="informationBlockRef" class="py-6">
<div class="mx-auto max-w-4xl px-4 pb-24 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 shadow-sm">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
<p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
</div>
</div>
<InformationBlock
:service-id="service.id"
:service-date="service.date"
:slides="informationSlides"
@slides-updated="refreshPage"
/>
</div>
</div>
</div>
<!-- Arrangement Dialog --> <!-- Arrangement Dialog -->
<ArrangementDialog <ArrangementDialog
v-if="arrangementDialogItem" v-if="arrangementDialogItem"