pp-planer/resources/js/Components/ArrangementConfigurator.vue
Thorsten Bus b8b92f094e 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)
2026-03-29 17:41:26 +02:00

331 lines
11 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { 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,
required: true,
},
arrangements: {
type: Array,
required: true,
},
availableGroups: {
type: Array,
required: true,
},
selectedArrangementId: {
type: [Number, String],
default: null,
},
})
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 ?? MASTER_ID,
)
const isMasterSelected = computed(() => selectedId.value === MASTER_ID)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
allArrangements.value.find((arrangement) => arrangement.id === selectedId.value)
?? allArrangements.value.find((arrangement) => arrangement.id === Number(selectedId.value))
?? null,
)
watch(
() => props.availableGroups,
(groups) => {
poolGroups.value = groups.map((group) => ({ ...group }))
},
{ immediate: true, deep: true },
)
watch(
selectedArrangement,
(arrangement) => {
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
},
{ immediate: true },
)
watch(
selectedId,
(arrangementId) => {
emit('arrangement-selected', arrangementId === MASTER_ID ? null : Number(arrangementId))
},
)
const groupPillStyle = (group) => ({
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
}
},
)
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,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
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,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
function addGroupFromPool(group) {
if (isMasterSelected.value) return
arrangementGroups.value.push({ ...group })
saveArrangement()
}
function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
function saveArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
router.put(
`/arrangements/${selectedArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
order: index + 1,
})),
},
{
preserveScroll: true,
preserveState: true,
},
)
}
function deleteArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
</script>
<template>
<div data-testid="arrangement-configurator" class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-64 flex-1">
<label
for="arrangement-select"
class="mb-1 block text-sm font-medium text-gray-700"
>
Arrangement
</label>
<select
data-testid="arrangement-select"
id="arrangement-select"
v-model="selectedId"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arrangement in allArrangements"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}{{ arrangement.is_default ? ' (Standard)' : '' }}
</option>
</select>
</div>
<button
data-testid="arrangement-add-button"
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="addArrangement"
>
Hinzufügen
</button>
<button
data-testid="arrangement-clone-button"
type="button"
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Klonen
</button>
<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 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement"
>
Löschen
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="space-y-1">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Verfügbare Gruppen
</h4>
<VueDraggable
v-model="poolGroups"
: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 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)"
>
{{ group.name }}
</span>
</VueDraggable>
</div>
<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: !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 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"
@click.stop="removeGroupAt(index)"
>
×
</button>
</span>
</VueDraggable>
</div>
</div>
</div>
</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>