pp-planer/resources/js/Components/ArrangementConfigurator.vue
Thorsten Bus af0c72ebcc feat(ui): improve arrangement configurator, song preview, and downloads
Simplify ArrangementConfigurator: replace color pickers with compact
pills, add click-to-add from pool, use watcher-based auto-select for
new/cloned arrangements, remove group_colors from save payload.

Enhance SongsBlock preview: color-coded group headers with tinted
backgrounds, PDF download button inside preview modal, .pro download
link per matched song, show DB ccli_id with fallback to CTS ccli_id.

Fix Modal z-index for nested dialogs. Fix SlideUploader duplicate
upload on watch by adding deep option and upload guard. Expand API
log detail sections by default and increase JSON tree depth. Convert
song download button from emit to direct .pro download link.
2026-03-02 23:02:51 +01:00

271 lines
8.4 KiB
Vue
Raw 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 props = defineProps({
songId: {
type: Number,
required: true,
},
arrangements: {
type: Array,
required: true,
},
availableGroups: {
type: Array,
required: true,
},
selectedArrangementId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['arrangement-selected'])
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
props.arrangements.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', 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
}
},
)
const addArrangement = () => {
const name = window.prompt('Name des neuen Arrangements')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, { preserveScroll: true })
}
const cloneArrangement = () => {
if (!selectedArrangement.value) 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 })
}
const addGroupFromPool = (group) => {
arrangementGroups.value.push({ ...group })
saveArrangement()
}
const removeGroupAt = (index) => {
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
const saveArrangement = () => {
if (!selectedArrangement.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,
},
)
}
const deleteArrangement = () => {
if (!selectedArrangement.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
})
}
</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 arrangements"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}
</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"
@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: 'clone', put: false }"
:sort="false"
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"
>
<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"
: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
</h4>
<VueDraggable
v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: true }"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex min-h-10 flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-gray-50 p-2"
@end="saveArrangement"
>
<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"
:style="groupPillStyle(group)"
>
{{ group.name }}
<button
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>