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:
parent
852231ae01
commit
b8b92f094e
|
|
@ -1,6 +1,9 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import axios from 'axios'
|
||||
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
agendaItem: { type: Object, required: true },
|
||||
|
|
@ -8,23 +11,35 @@ const props = defineProps({
|
|||
serviceDate: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['slides-updated'])
|
||||
const emit = defineEmits(['slides-updated', 'scroll-to-info'])
|
||||
|
||||
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 r = props.agendaItem.responsible
|
||||
if (!r) return ''
|
||||
// CTS returns {text: "...", persons: [{person: {title: "Name"}, service: "[Role]"}]}
|
||||
if (r.persons && Array.isArray(r.persons)) {
|
||||
const names = r.persons
|
||||
.map((p) => p.person?.title || p.name || '')
|
||||
.filter(Boolean)
|
||||
if (names.length > 0) return [...new Set(names)].join(', ')
|
||||
}
|
||||
// Fallback: use text field
|
||||
if (r.text) return r.text
|
||||
// Legacy: array of objects
|
||||
if (Array.isArray(r)) {
|
||||
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
|
||||
}
|
||||
|
|
@ -63,10 +78,99 @@ const borderClass = computed(() => {
|
|||
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() {
|
||||
showUploader.value = false
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -116,13 +220,32 @@ function onUploaded() {
|
|||
|
||||
<!-- Slide thumbnails -->
|
||||
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
|
||||
<img
|
||||
<div
|
||||
v-for="slide in agendaItem.slides.slice(0, 4)"
|
||||
:key="slide.id"
|
||||
:src="`/storage/slides/${slide.thumbnail_filename}`"
|
||||
class="h-8 w-14 rounded border border-gray-200 object-cover"
|
||||
:alt="slide.original_filename"
|
||||
/>
|
||||
class="group/thumb relative"
|
||||
>
|
||||
<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"
|
||||
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
|
||||
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"
|
||||
|
|
@ -140,7 +263,22 @@ function onUploaded() {
|
|||
<!-- Aktionen -->
|
||||
<td class="py-2.5 align-top">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<!-- Announcement row: scroll to info block instead of + 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"
|
||||
data-testid="agenda-item-add-slides"
|
||||
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
|
||||
|
|
@ -157,8 +295,8 @@ function onUploaded() {
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Uploader row (spanning all columns) -->
|
||||
<tr v-if="showUploader">
|
||||
<!-- Uploader row (spanning all columns) — not for announcement rows -->
|
||||
<tr v-if="showUploader && !agendaItem.is_announcement_position">
|
||||
<td colspan="6" class="border-b border-gray-100 px-3 pb-3">
|
||||
<SlideUploader
|
||||
type="agenda_item"
|
||||
|
|
@ -170,4 +308,130 @@ function onUploaded() {
|
|||
/>
|
||||
</td>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { computed, ref, watch } from 'vue'
|
|||
import { router } from '@inertiajs/vue3'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const MASTER_ID = 'master'
|
||||
|
||||
const props = defineProps({
|
||||
songId: {
|
||||
type: Number,
|
||||
|
|
@ -17,22 +19,41 @@ const props = defineProps({
|
|||
required: true,
|
||||
},
|
||||
selectedArrangementId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
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(
|
||||
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 poolGroups = ref([])
|
||||
|
||||
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(
|
||||
|
|
@ -54,7 +75,7 @@ watch(
|
|||
watch(
|
||||
selectedId,
|
||||
(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')
|
||||
if (!name) return
|
||||
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
|
||||
|
||||
// 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`)
|
||||
if (!name) return
|
||||
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 })
|
||||
saveArrangement()
|
||||
}
|
||||
|
||||
const removeGroupAt = (index) => {
|
||||
function removeGroupAt(index) {
|
||||
if (isMasterSelected.value) return
|
||||
arrangementGroups.value.splice(index, 1)
|
||||
saveArrangement()
|
||||
}
|
||||
|
||||
const saveArrangement = () => {
|
||||
if (!selectedArrangement.value) {
|
||||
function saveArrangement() {
|
||||
if (!selectedArrangement.value || isMasterSelected.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -120,13 +168,16 @@ const saveArrangement = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const deleteArrangement = () => {
|
||||
if (!selectedArrangement.value) {
|
||||
function deleteArrangement() {
|
||||
if (!selectedArrangement.value || isMasterSelected.value) {
|
||||
return
|
||||
}
|
||||
|
||||
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
emit('arrangements-changed')
|
||||
},
|
||||
})
|
||||
}
|
||||
</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"
|
||||
>
|
||||
<option
|
||||
v-for="arrangement in arrangements"
|
||||
v-for="arrangement in allArrangements"
|
||||
:key="arrangement.id"
|
||||
:value="arrangement.id"
|
||||
>
|
||||
{{ arrangement.name }}
|
||||
{{ arrangement.name }}{{ arrangement.is_default ? ' (Standard)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -178,7 +229,8 @@ const deleteArrangement = () => {
|
|||
<button
|
||||
data-testid="arrangement-delete-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"
|
||||
>
|
||||
Löschen
|
||||
|
|
@ -193,17 +245,20 @@ const deleteArrangement = () => {
|
|||
|
||||
<VueDraggable
|
||||
v-model="poolGroups"
|
||||
:group="{ name: 'song-groups', pull: 'clone', put: false }"
|
||||
:group="{ name: 'song-groups', pull: isMasterSelected ? false : 'clone', put: false }"
|
||||
:sort="false"
|
||||
:disabled="isMasterSelected"
|
||||
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"
|
||||
:class="{ 'opacity-50': isMasterSelected }"
|
||||
>
|
||||
<span
|
||||
v-for="group in poolGroups"
|
||||
: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)"
|
||||
@click="addGroupFromPool(group)"
|
||||
>
|
||||
|
|
@ -215,25 +270,30 @@ const deleteArrangement = () => {
|
|||
<div class="space-y-1">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Gruppenfolge
|
||||
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
|
||||
</h4>
|
||||
|
||||
<VueDraggable
|
||||
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"
|
||||
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"
|
||||
:class="{ 'opacity-60': isMasterSelected }"
|
||||
@end="saveArrangement"
|
||||
>
|
||||
<span
|
||||
v-for="(group, index) in arrangementGroups"
|
||||
: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)"
|
||||
>
|
||||
{{ group.name }}
|
||||
<button
|
||||
v-if="!isMasterSelected"
|
||||
data-testid="arrangement-remove-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"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
|
|||
import { router } from '@inertiajs/vue3'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const MASTER_ID = 'master'
|
||||
|
||||
const props = defineProps({
|
||||
songId: {
|
||||
type: Number,
|
||||
|
|
@ -89,6 +91,26 @@ async function assignSong() {
|
|||
|
||||
/* ── 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
|
||||
const localArrangements = ref(JSON.parse(JSON.stringify(props.arrangements)))
|
||||
|
||||
|
|
@ -109,12 +131,13 @@ watch(
|
|||
)
|
||||
|
||||
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(() =>
|
||||
localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null,
|
||||
)
|
||||
const currentArrangement = computed(() => {
|
||||
if (currentArrangementId.value === MASTER_ID) return masterArrangement.value
|
||||
return localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null
|
||||
})
|
||||
|
||||
const arrangementGroups = ref([])
|
||||
const hoveredIndex = ref(null)
|
||||
|
|
@ -122,12 +145,16 @@ const hoveredIndex = ref(null)
|
|||
watch(
|
||||
currentArrangementId,
|
||||
(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))
|
||||
if (arr?.groups?.length) {
|
||||
arrangementGroups.value = arr.groups.map((g, i) => ({ ...g, _uid: `${g.id}-${i}-${Date.now()}` }))
|
||||
} else {
|
||||
// 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 },
|
||||
|
|
@ -209,29 +236,59 @@ function createArrangement() {
|
|||
const name = window.prompt('Name für das neue Arrangement:')
|
||||
if (!name?.trim()) return
|
||||
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() {
|
||||
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`)
|
||||
if (!name?.trim()) return
|
||||
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() {
|
||||
if (!currentArrangementId.value) return
|
||||
if (!currentArrangementId.value || isMasterSelected.value) return
|
||||
if (props.arrangements.length <= 1) {
|
||||
alert('Das letzte Arrangement kann nicht gelöscht werden.')
|
||||
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() {
|
||||
if (!currentArrangement.value) return
|
||||
if (!currentArrangement.value || isMasterSelected.value) return
|
||||
|
||||
// Update local arrangements copy so changes survive switching
|
||||
const localArr = localArrangements.value.find((a) => a.id === currentArrangement.value.id)
|
||||
|
|
@ -260,6 +317,7 @@ function saveArrangement() {
|
|||
/* ── Group operations ── */
|
||||
|
||||
function duplicateGroup(index) {
|
||||
if (isMasterSelected.value) return
|
||||
const item = arrangementGroups.value[index]
|
||||
const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` }
|
||||
arrangementGroups.value.splice(index + 1, 0, newItem)
|
||||
|
|
@ -267,6 +325,7 @@ function duplicateGroup(index) {
|
|||
}
|
||||
|
||||
function addGroupAt(index, group) {
|
||||
if (isMasterSelected.value) return
|
||||
const newItem = {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
|
|
@ -280,6 +339,7 @@ function addGroupAt(index, group) {
|
|||
}
|
||||
|
||||
function removeGroupAt(index) {
|
||||
if (isMasterSelected.value) return
|
||||
arrangementGroups.value.splice(index, 1)
|
||||
saveArrangement()
|
||||
}
|
||||
|
|
@ -287,7 +347,7 @@ function removeGroupAt(index) {
|
|||
/* ── Selection emit ── */
|
||||
|
||||
watch(currentArrangementId, (id) => {
|
||||
emit('arrangement-selected', Number(id))
|
||||
emit('arrangement-selected', id === MASTER_ID ? null : Number(id))
|
||||
})
|
||||
|
||||
/* ── 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"
|
||||
>
|
||||
<option
|
||||
v-for="arr in arrangements"
|
||||
v-for="arr in allArrangements"
|
||||
:key="arr.id"
|
||||
:value="arr.id"
|
||||
>
|
||||
|
|
@ -373,7 +433,8 @@ function closeOnBackdrop(e) {
|
|||
<button
|
||||
data-testid="arrangement-delete-btn"
|
||||
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"
|
||||
>
|
||||
Löschen
|
||||
|
|
@ -477,22 +538,28 @@ function closeOnBackdrop(e) {
|
|||
>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Gruppenfolge
|
||||
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
|
||||
</h4>
|
||||
|
||||
<VueDraggable
|
||||
v-model="arrangementGroups"
|
||||
:disabled="isMasterSelected"
|
||||
ghost-class="drag-ghost"
|
||||
chosen-class="drag-chosen"
|
||||
drag-class="drag-active"
|
||||
class="flex flex-col gap-2"
|
||||
:class="{ 'opacity-60': isMasterSelected }"
|
||||
@end="saveArrangement"
|
||||
>
|
||||
<div
|
||||
v-for="(element, index) in arrangementGroups"
|
||||
:key="element._uid"
|
||||
data-testid="arrangement-pill"
|
||||
class="flex cursor-grab items-center gap-2 rounded-lg border-2 px-3 py-2"
|
||||
:class="{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index }"
|
||||
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 },
|
||||
isMasterSelected ? 'cursor-default' : 'cursor-grab',
|
||||
]"
|
||||
:style="{
|
||||
borderColor: element.color ?? '#6b7280',
|
||||
backgroundColor: (element.color ?? '#6b7280') + '20',
|
||||
|
|
@ -503,60 +570,62 @@ function closeOnBackdrop(e) {
|
|||
{{ element.name }}
|
||||
</span>
|
||||
|
||||
<!-- Duplicate button (2×) -->
|
||||
<button
|
||||
data-testid="arrangement-duplicate-btn"
|
||||
type="button"
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs hover:bg-gray-200"
|
||||
@click="duplicateGroup(index)"
|
||||
>
|
||||
2×
|
||||
</button>
|
||||
|
||||
<!-- Add group button (+) -->
|
||||
<div
|
||||
class="relative"
|
||||
data-group-picker
|
||||
>
|
||||
<template v-if="!isMasterSelected">
|
||||
<!-- Duplicate button (2×) -->
|
||||
<button
|
||||
data-testid="arrangement-add-group-btn"
|
||||
data-testid="arrangement-duplicate-btn"
|
||||
type="button"
|
||||
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
|
||||
@click.stop="toggleGroupPicker(index)"
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs hover:bg-gray-200"
|
||||
@click="duplicateGroup(index)"
|
||||
>
|
||||
+
|
||||
2×
|
||||
</button>
|
||||
|
||||
<!-- Quick select dropdown -->
|
||||
<!-- Add group button (+) -->
|
||||
<div
|
||||
v-if="groupPickerOpen === index"
|
||||
class="absolute right-0 top-8 z-10 min-w-36 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
class="relative"
|
||||
data-group-picker
|
||||
>
|
||||
<button
|
||||
v-for="g in availableGroups"
|
||||
:key="g.id"
|
||||
data-testid="arrangement-add-group-btn"
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="addGroupAt(index, g)"
|
||||
class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
|
||||
@click.stop="toggleGroupPicker(index)"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 inline-block h-2.5 w-2.5 rounded-full"
|
||||
:style="{ backgroundColor: g.color }"
|
||||
/>
|
||||
{{ g.name }}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
data-testid="arrangement-remove-btn"
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-xs text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
@click="removeGroupAt(index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<!-- Quick select dropdown -->
|
||||
<div
|
||||
v-if="groupPickerOpen === index"
|
||||
class="absolute right-0 top-8 z-10 min-w-36 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="g in availableGroups"
|
||||
:key="g.id"
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="addGroupAt(index, g)"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 inline-block h-2.5 w-2.5 rounded-full"
|
||||
:style="{ backgroundColor: g.color }"
|
||||
/>
|
||||
{{ g.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
data-testid="arrangement-remove-btn"
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-xs text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
@click="removeGroupAt(index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
|
||||
|
|
|
|||
|
|
@ -491,6 +491,7 @@ onUnmounted(() => {
|
|||
:song-id="songData.id"
|
||||
:arrangements="arrangements"
|
||||
:available-groups="availableGroups"
|
||||
@arrangements-changed="fetchSong"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,10 +65,16 @@ function goBack() {
|
|||
router.get(route('services.index'))
|
||||
}
|
||||
|
||||
const informationBlockRef = ref(null)
|
||||
|
||||
function refreshPage() {
|
||||
router.reload({ preserveScroll: true })
|
||||
}
|
||||
|
||||
function scrollToInfoBlock() {
|
||||
informationBlockRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
/* ── Agenda helpers ──────────────────────────────────────── */
|
||||
|
||||
const arrangementDialogItem = ref(null)
|
||||
|
|
@ -349,34 +355,9 @@ async function downloadService() {
|
|||
</div>
|
||||
</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) -->
|
||||
<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>
|
||||
|
||||
<!-- Empty state -->
|
||||
|
|
@ -427,6 +408,7 @@ async function downloadService() {
|
|||
:service-id="service.id"
|
||||
:service-date="service.date"
|
||||
@slides-updated="refreshPage"
|
||||
@scroll-to-info="scrollToInfoBlock"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
|
|
@ -434,6 +416,31 @@ async function downloadService() {
|
|||
</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 -->
|
||||
<ArrangementDialog
|
||||
v-if="arrangementDialogItem"
|
||||
|
|
|
|||
Loading…
Reference in a new issue