pp-planer/resources/js/Components/ArrangementDialog.vue
Thorsten Bus 852231ae01 fix(ui): ArrangementDialog drag whole box, persist changes across switch, hover highlight
- Remove drag handle restriction — entire pill box is now draggable
- Keep local copy of arrangements so edits survive switching between
  arrangements (was reading stale props after switch-back)
- Highlight corresponding left pill when hovering right lyric preview
2026-03-29 16:34:30 +02:00

665 lines
27 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, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const props = defineProps({
songId: {
type: Number,
default: null,
},
arrangements: {
type: Array,
default: () => [],
},
availableGroups: {
type: Array,
default: () => [],
},
selectedArrangementId: {
type: Number,
default: null,
},
serviceSongId: {
type: Number,
default: null,
},
songsCatalog: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close', 'arrangement-selected'])
/* ── Song assignment (unmatched songs) ── */
const isUnmatched = computed(() => !props.songId)
const searchQuery = ref('')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
function normalize(value) {
return (value ?? '').toString().toLowerCase().trim()
}
const filteredCatalog = computed(() => {
const term = normalize(searchQuery.value)
if (term === '') return props.songsCatalog.slice(0, 100)
return props.songsCatalog
.filter((song) => normalize(song.title).includes(term) || normalize(song.ccli_id).includes(term))
.slice(0, 100)
})
function openSearchDropdown() {
dropdownOpen.value = true
}
function closeSearchDropdown() {
setTimeout(() => {
dropdownOpen.value = false
}, 200)
}
function selectSong(song) {
selectedSongId.value = song.id
searchQuery.value = song.title
dropdownOpen.value = false
}
async function assignSong() {
const songId = Number(selectedSongId.value)
if (!songId) {
assignError.value = 'Bitte wähle zuerst einen Song aus.'
return
}
assignError.value = ''
try {
await window.axios.post(`/api/service-songs/${props.serviceSongId}/assign`, {
song_id: songId,
})
emit('close')
} catch {
assignError.value = 'Zuordnung fehlgeschlagen.'
}
}
/* ── State ── */
// Local copy of arrangements so changes survive switching between arrangements
const localArrangements = ref(JSON.parse(JSON.stringify(props.arrangements)))
watch(
() => props.arrangements,
(newArr) => {
// Merge server updates (e.g. after create/clone/delete) but keep local edits for existing arrangements
const localById = Object.fromEntries(localArrangements.value.map((a) => [a.id, a]))
localArrangements.value = newArr.map((a) => localById[a.id] ?? JSON.parse(JSON.stringify(a)))
// Add any new arrangements not in local
for (const a of newArr) {
if (!localById[a.id]) {
localArrangements.value.push(JSON.parse(JSON.stringify(a)))
}
}
},
{ deep: true },
)
const currentArrangementId = ref(
props.selectedArrangementId ?? props.arrangements.find((a) => a.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
const currentArrangement = computed(() =>
localArrangements.value.find((a) => a.id === Number(currentArrangementId.value)) ?? null,
)
const arrangementGroups = ref([])
const hoveredIndex = ref(null)
watch(
currentArrangementId,
(id) => {
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()}` }))
}
},
{ immediate: true },
)
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))
currentArrangementId.value = newest.id
pendingAutoSelect.value = false
}
},
)
/* ── Scroll sync ── */
const leftCol = ref(null)
const rightCol = ref(null)
let syncing = false
function syncScroll(from) {
if (syncing) return
syncing = true
if (from === 'left' && rightCol.value) {
rightCol.value.scrollTop = leftCol.value.scrollTop
} else if (from === 'right' && leftCol.value) {
leftCol.value.scrollTop = rightCol.value.scrollTop
}
nextTick(() => {
syncing = false
})
}
/* ── Group picker ── */
const groupPickerOpen = ref(null)
function toggleGroupPicker(index) {
groupPickerOpen.value = groupPickerOpen.value === index ? null : index
}
/* ── Close on outside click for group picker ── */
function onBodyClick(e) {
if (groupPickerOpen.value !== null && !e.target.closest('[data-group-picker]')) {
groupPickerOpen.value = null
}
}
/* ── Keyboard: Escape ── */
function closeOnEscape(e) {
if (e.key === 'Escape') {
if (groupPickerOpen.value !== null) {
groupPickerOpen.value = null
} else {
emit('close')
}
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
document.removeEventListener('click', onBodyClick)
})
/* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */
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 })
}
function cloneArrangement() {
if (!currentArrangementId.value) 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 })
}
function deleteArrangement() {
if (!currentArrangementId.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 })
}
function saveArrangement() {
if (!currentArrangement.value) return
// Update local arrangements copy so changes survive switching
const localArr = localArrangements.value.find((a) => a.id === currentArrangement.value.id)
if (localArr) {
localArr.groups = arrangementGroups.value.map((g, i) => ({
...g,
order: i + 1,
}))
}
router.put(
`/arrangements/${currentArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
order: index + 1,
})),
},
{
preserveScroll: true,
preserveState: true,
},
)
}
/* ── Group operations ── */
function duplicateGroup(index) {
const item = arrangementGroups.value[index]
const newItem = { ...item, _uid: `${item.id}-dup-${Date.now()}` }
arrangementGroups.value.splice(index + 1, 0, newItem)
saveArrangement()
}
function addGroupAt(index, group) {
const newItem = {
id: group.id,
name: group.name,
color: group.color,
slides: group.slides ?? [],
_uid: `${group.id}-add-${Date.now()}`,
}
arrangementGroups.value.splice(index + 1, 0, newItem)
groupPickerOpen.value = null
saveArrangement()
}
function removeGroupAt(index) {
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
/* ── Selection emit ── */
watch(currentArrangementId, (id) => {
emit('arrangement-selected', Number(id))
})
/* ── Close on backdrop ── */
function closeOnBackdrop(e) {
if (e.target === e.currentTarget) {
emit('close')
}
}
</script>
<template>
<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
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click="closeOnBackdrop"
>
<div
data-testid="arrangement-dialog"
:class="isUnmatched
? 'flex w-full max-w-lg flex-col rounded-xl bg-white shadow-2xl'
: 'flex h-[80vh] w-full max-w-5xl flex-col rounded-xl bg-white shadow-2xl'"
>
<!-- Header: Unmatched song assignment -->
<div v-if="isUnmatched" class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<h3 class="text-base font-semibold text-gray-900">Song zuordnen</h3>
<button
data-testid="arrangement-dialog-close-btn"
type="button"
class="ml-auto rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('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>
<!-- Header: Arrangement editor -->
<div v-else class="flex items-center gap-3 border-b border-gray-200 px-6 py-4">
<div class="min-w-48 flex-1">
<select
v-model="currentArrangementId"
data-testid="arrangement-select"
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"
:key="arr.id"
:value="arr.id"
>
{{ arr.name }}{{ arr.is_default ? ' (Standard)' : '' }}
</option>
</select>
</div>
<button
data-testid="arrangement-new-btn"
type="button"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="createArrangement"
>
Neu
</button>
<button
data-testid="arrangement-clone-btn"
type="button"
class="rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Duplizieren
</button>
<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"
@click="deleteArrangement"
>
Löschen
</button>
<button
data-testid="arrangement-dialog-close-btn"
type="button"
class="ml-auto rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('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>
<!-- Body: Unmatched song search & assign -->
<div v-if="isUnmatched" class="flex-1 overflow-y-auto p-6">
<div class="mx-auto max-w-lg space-y-4">
<p class="text-sm text-gray-600">
Dieser CTS-Song ist noch nicht in der Song-DB verknüpft. Suche den passenden Song und ordne ihn zu.
</p>
<div class="relative">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-600">
Song suchen und auswählen
</label>
<input
v-model="searchQuery"
type="text"
placeholder="Titel oder CCLI eingeben…"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
data-testid="song-search-input"
@focus="openSearchDropdown"
@input="openSearchDropdown"
@blur="closeSearchDropdown"
/>
<!-- Selected indicator -->
<div v-if="selectedSongId" class="mt-1 flex items-center gap-2">
<span class="text-xs font-medium text-emerald-700">
Ausgewählt: {{ songsCatalog.find((s) => s.id === Number(selectedSongId))?.title || '' }}
</span>
<button
type="button"
class="text-xs text-gray-400 hover:text-red-500"
@click="selectedSongId = ''; searchQuery = ''"
>
&#10005;
</button>
</div>
<!-- Dropdown -->
<div
v-if="dropdownOpen && filteredCatalog.length > 0"
class="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="song in filteredCatalog"
:key="song.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-emerald-50"
@mousedown.prevent="selectSong(song)"
>
<span class="font-medium text-gray-900">{{ song.title }}</span>
<span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span>
</button>
</div>
</div>
<p v-if="assignError" class="text-sm text-red-600">{{ assignError }}</p>
<button
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
data-testid="song-assign-button"
@click="assignSong"
>
Zuordnen
</button>
</div>
</div>
<!-- Body: Two-column arrangement editor -->
<div v-else class="flex flex-1 overflow-hidden">
<!-- Left: Pill list with DnD -->
<div
ref="leftCol"
data-testid="arrangement-pill-list"
class="flex w-1/2 flex-col gap-2 overflow-y-auto border-r border-gray-200 p-4"
@scroll="syncScroll('left')"
>
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge
</h4>
<VueDraggable
v-model="arrangementGroups"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex flex-col gap-2"
@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 }"
:style="{
borderColor: element.color ?? '#6b7280',
backgroundColor: (element.color ?? '#6b7280') + '20',
}"
>
<!-- Group name -->
<span class="flex-1 text-sm font-medium">
{{ 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
>
<button
data-testid="arrangement-add-group-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)"
>
+
</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>
</div>
</VueDraggable>
<!-- Empty state -->
<div
v-if="arrangementGroups.length === 0"
class="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-8 text-center text-sm text-gray-400"
>
Noch keine Gruppen zugeordnet.
</div>
</div>
<!-- Right: Lyric preview (scroll-synced) -->
<div
ref="rightCol"
data-testid="arrangement-lyric-preview"
class="flex w-1/2 flex-col gap-2 overflow-y-auto bg-gray-50 p-4"
@scroll="syncScroll('right')"
>
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Textvorschau
</h4>
<div
v-for="(element, index) in arrangementGroups"
:key="element._uid"
class="rounded-r-lg border-l-4 bg-white p-3 shadow-sm transition-shadow"
:class="{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index }"
:style="{ borderColor: element.color ?? '#6b7280' }"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = null"
>
<!-- Group name pill -->
<span
class="mb-2 inline-block rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: element.color ?? '#6b7280' }"
>
{{ element.name }}
</span>
<!-- Slides -->
<template v-if="element.slides?.length > 0">
<div
v-for="(slide, si) in element.slides"
:key="slide.id ?? si"
>
<div
v-if="si > 0"
class="my-1.5 border-t border-gray-200"
/>
<p class="whitespace-pre-wrap text-sm">
{{ slide.text_content }}
</p>
<p
v-if="slide.text_content_translated"
class="mt-0.5 whitespace-pre-wrap text-xs italic text-gray-400"
>
{{ slide.text_content_translated }}
</p>
</div>
</template>
<!-- No slides -->
<p
v-else
class="text-xs italic text-gray-300"
>
Kein Text vorhanden
</p>
</div>
<!-- Empty state -->
<div
v-if="arrangementGroups.length === 0"
class="flex flex-1 items-center justify-center text-sm text-gray-400"
>
Wähle ein Arrangement aus, um die Vorschau zu sehen.
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
:deep(.drag-ghost) {
opacity: 0.4;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 0 4px rgba(99, 102, 241, 0.6);
border-radius: 0.5rem;
}
:deep(.drag-chosen) {
opacity: 0.7;
}
:deep(.drag-active) {
opacity: 1 !important;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.8);
border-radius: 0.5rem;
z-index: 50;
}
</style>