pp-planer/resources/js/Components/ArrangementDialog.vue
Thorsten Bus e33418f716 feat: song pre/postfix, settings overhaul, export & schedule fixes
Resolves a batch of bugs and feature requests across songs, services,
settings and export:

Songs & sections
- Every song now carries permanent, empty, locked PREFIX (COPYRIGHT) and
  POSTFIX (BLANK) sections, deduplicated on import; locked sections cannot
  be edited or deleted via UI or API.
- Song edit modal: explicit Speichern/Schließen with dirty-tracking,
  editable section headline (combobox + custom values), and a fix for the
  419 CSRF errors after CCLI "Importieren & Bearbeiten" (token read fresh
  per request).
- CCLI bookmarklet "Importieren & Bearbeiten" now opens the edit dialog.

Service schedule & arrangements
- Fixed assigned songs showing no sections (slides loaded for all
  arrangements, not just the default).
- Added "Song entfernen / neu zuordnen" to reassign an assigned song.
- Worship-leader arrangement is created/selected lazily when the
  arrangement dialog opens (only when not user-overridden); the leader is
  resolved from the "Lobpreis" agenda item, and manual create/clone names
  are prefixed with the leader name.

Navigation
- "/" redirects to the next upcoming service's edit page (or the list).
- Service titles link to the edit page.

Settings
- Renamed "Makro-Import"/"Label-Import" menu items; fixed drag-and-drop
  imports (were downloading the dropped file); added label-import hint;
  made the panel scrollable.
- Nametag now uses a single MacroPicker; added song prefix/postfix label
  defaults (COPYRIGHT #24B34C / BLANK #000000); new "Export-Dateien" menu
  to upload prefix/postfix .pro files added to every export.

Export
- Filenames/playlist names are date-first ("YYYY-MM-DD <Title>").
- Keyvisual slide only for the first content-less item after real content;
  all other content-less items render as headlines.
- New "Vorschau herunterladen" for non-finalized services (filename and
  import name prefixed "Vorschau" with export timestamp).
- Uploaded prefix/postfix .pro files wrap every export.

Tests updated to the new behavior; full suite green (569 passed).
2026-06-01 08:56:20 +02:00

949 lines
38 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'
import CcliPasteDialog from '@/Components/CcliPasteDialog.vue'
import SongEditModal from '@/Components/SongEditModal.vue'
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,
},
serviceSongName: {
type: String,
default: '',
},
serviceSongCcliId: {
type: String,
default: '',
},
songsCatalog: {
type: Array,
default: () => [],
},
worshipLeaderName: {
type: String,
default: null,
},
serviceSongArrangementId: {
type: Number,
default: null,
},
defaultArrangementId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['close', 'arrangement-selected', 'unassigned'])
/* ── Song assignment (unmatched songs) ── */
const isUnmatched = computed(() => !props.songId)
const searchQuery = ref(props.serviceSongName ?? '')
const selectedSongId = ref('')
const dropdownOpen = ref(false)
const assignError = ref('')
const ccliDialogOpen = ref(false)
const editSongId = ref(null)
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)
})
// Build the SongSelect URL: prefer the CCLI number (direct song page), else search by name.
const songSelectUrl = computed(() => {
const ccli = (props.serviceSongCcliId ?? '').toString().trim()
if (ccli !== '') {
return `https://songselect.ccli.com/songs/${encodeURIComponent(ccli)}`
}
const query = (searchQuery.value || props.serviceSongName || '').trim()
return `https://songselect.ccli.com/search/results?search=${encodeURIComponent(query)}`
})
// Open SongSelect in a new tab AND open the CCLI import dialog so the user can paste.
function openSongSelect() {
window.open(songSelectUrl.value, '_blank', 'noopener,noreferrer')
ccliDialogOpen.value = true
}
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.'
}
}
async function unassignSong() {
if (!confirm('Diese Songzuordnung wirklich aufheben?')) return
try {
await window.axios.post(`/api/service-songs/${props.serviceSongId}/unassign`)
emit('unassigned')
emit('close')
} catch {
// Silently ignore — parent will reload on close
emit('close')
}
}
/* ── 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))
const arrGroups = arr?.groups ?? (arr?.arrangement_groups
? arr.arrangement_groups.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id)).filter(Boolean)
: [])
if (arrGroups.length) {
arrangementGroups.value = arrGroups.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')
}
}
}
async function selectArrangementForServiceSong(arrangementId) {
try {
await window.axios.patch(`/api/service-songs/${props.serviceSongId}`, {
song_arrangement_id: arrangementId,
})
currentArrangementId.value = arrangementId
} catch {
// Ignore — user can select manually
}
}
async function ensureLeaderArrangement() {
if (!props.worshipLeaderName || isUnmatched.value) return
// Only auto-act if the user hasn't manually chosen an arrangement
const isUnchanged =
props.serviceSongArrangementId === null ||
props.serviceSongArrangementId === props.defaultArrangementId
if (!isUnchanged) return
const leaderName = props.worshipLeaderName.trim()
if (!leaderName) return
// Check if an arrangement with this name already exists
const existing = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (existing) {
await selectArrangementForServiceSong(existing.id)
return
}
// Create by cloning the default arrangement
const defaultArr = props.arrangements.find((a) => a.is_default) ?? props.arrangements[0]
if (!defaultArr) return
try {
pendingAutoSelect.value = true
router.post(
`/arrangements/${defaultArr.id}/clone`,
{ name: leaderName },
{
preserveScroll: true,
onSuccess: async () => {
router.reload({
preserveScroll: true,
onSuccess: async () => {
const created = props.arrangements.find(
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
)
if (created) {
await selectArrangementForServiceSong(created.id)
}
},
})
},
},
)
} catch {
// Ignore
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick)
// For unmatched songs: show search results immediately (prefilled with the song name).
if (isUnmatched.value) {
dropdownOpen.value = true
} else {
// For matched songs: auto-create/select worship-leader arrangement if un-changed.
ensureLeaderArrangement()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
document.removeEventListener('click', onBodyClick)
})
/* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */
function leaderPrefix() {
return props.worshipLeaderName?.trim() ? `${props.worshipLeaderName.trim()} - ` : ''
}
function createArrangement() {
const defaultName = leaderPrefix()
const name = window.prompt('Name für das neue Arrangement:', defaultName)
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 defaultName = leaderPrefix() || 'MASTER Kopie'
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
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 defaultName = leaderPrefix()
? `${leaderPrefix()}${currentArrangement.value?.name ?? ''}`
: `${currentArrangement.value?.name ?? ''} Kopie`
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
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) => ({
label_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-unassign-button"
type="button"
class="rounded-md border border-orange-300 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 shadow hover:bg-orange-100"
@click="unassignSong"
>
Song entfernen / neu zuordnen
</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"
data-testid="song-search-option"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
:class="song.has_content === false ? 'bg-orange-50 hover:bg-orange-100' : 'hover:bg-emerald-50'"
@mousedown.prevent="selectSong(song)"
>
<span class="font-medium text-gray-900">{{ song.title }}</span>
<span
v-if="song.has_content === false"
data-testid="song-search-no-content"
class="inline-flex items-center rounded-full bg-orange-100 px-1.5 py-0.5 text-[10px] font-semibold text-orange-700"
>
Ohne Inhalt
</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>
<div class="flex items-center gap-2">
<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>
<button
type="button"
data-testid="songselect-search-button"
@click="openSongSelect"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
>
Auf SongSelect suchen ↗
</button>
<button
type="button"
data-testid="open-ccli-paste-dialog-button"
@click="ccliDialogOpen = true"
class="inline-flex items-center justify-center rounded-md border border-blue-300 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 shadow-sm transition hover:bg-blue-100"
>
Aus CCLI importieren
</button>
</div>
</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>
<!-- CCLI Paste Dialog -->
<CcliPasteDialog
:open="ccliDialogOpen"
mode="service-form"
:service-song-id="props.serviceSongId"
@close="ccliDialogOpen = false"
@imported="(songId) => { ccliDialogOpen = false; router.reload({ only: ['service'] }) }"
@edit-song="(id) => { ccliDialogOpen = false; editSongId = id }"
/>
<!-- Song Edit Modal -->
<SongEditModal
:show="editSongId !== null"
:song-id="editSongId"
@close="editSongId = null"
@updated="() => router.reload({ only: ['service'] })"
/>
</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>