- 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)
331 lines
11 KiB
Vue
331 lines
11 KiB
Vue
<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>
|