pp-planer/resources/js/Components/ArrangementDialog.vue

756 lines
30 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 MASTER_ID = 'master'
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 ── */
// 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)))
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 ?? MASTER_ID,
)
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)
const clickedIndex = ref(null)
let clickedTimeout = null
const rightItems = ref([])
function setRightItemRef(el, index) {
if (el) rightItems.value[index] = el
}
function scrollToRight(index) {
const el = rightItems.value[index]
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
clickedIndex.value = index
if (clickedTimeout) clearTimeout(clickedTimeout)
clickedTimeout = setTimeout(() => {
clickedIndex.value = null
}, 1500)
}
watch(
currentArrangementId,
(id) => {
rightItems.value = []
clickedIndex.value = null
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, slides: g.slides ?? [], _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,
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,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
}
function deleteArrangement() {
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,
onSuccess: () => {
router.reload({ preserveScroll: true })
},
})
}
function saveArrangement() {
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)
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) {
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)
saveArrangement()
}
function addGroupAt(index, group) {
if (isMasterSelected.value) return
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) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
/* ── Selection emit ── */
watch(currentArrangementId, (id) => {
emit('arrangement-selected', id === MASTER_ID ? null : 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 allArrangements"
: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 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@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
<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 items-center gap-2 rounded-lg border-2 px-3 py-2 transition-shadow"
:class="[
{ 'ring-2 ring-indigo-400 ring-offset-1': hoveredIndex === index || clickedIndex === index },
isMasterSelected ? 'cursor-default' : 'cursor-grab',
]"
:style="{
borderColor: element.color ?? '#6b7280',
backgroundColor: (element.color ?? '#6b7280') + '20',
}"
@click="scrollToRight(index)"
>
<!-- Group name -->
<span class="flex-1 text-sm font-medium">
{{ element.name }}
</span>
<template v-if="!isMasterSelected">
<!-- 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>
</template>
</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"
:ref="(el) => setRightItemRef(el, index)"
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 || clickedIndex === 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>