pp-planer/resources/js/Components/SongEditModal.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

1077 lines
45 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, onMounted, onUnmounted } from 'vue'
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
songId: {
type: Number,
default: null,
},
})
const emit = defineEmits(['close', 'updated'])
/* ── State ── */
const loading = ref(false)
const error = ref(null)
const songData = ref(null)
const sectionDrafts = ref({})
const sectionLabels = ref({})
const title = ref('')
const ccliId = ref('')
const copyrightText = ref('')
const showAddSectionForm = ref(false)
const newSectionLabel = ref('')
const newSectionText = ref('')
const sectionLabelDropdownOpen = ref(false)
const headlineDropdownFor = ref(null)
const saving = ref(false)
const saved = ref(false)
let savedTimeout = null
/* ── Pristine snapshot for dirty tracking ── */
const pristine = ref(null)
function snapshotPristine() {
pristine.value = {
title: title.value,
ccliId: ccliId.value,
copyrightText: copyrightText.value,
sections: JSON.parse(JSON.stringify(sectionDrafts.value)),
labels: { ...sectionLabels.value },
}
}
const isDirty = computed(() => {
if (!pristine.value) return false
if (
title.value !== pristine.value.title ||
ccliId.value !== pristine.value.ccliId ||
copyrightText.value !== pristine.value.copyrightText
) {
return true
}
const keys = new Set([
...Object.keys(pristine.value.sections),
...Object.keys(sectionDrafts.value),
])
for (const key of keys) {
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
if ((before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')) {
return true
}
if ((pristine.value.labels[key] ?? '') !== (sectionLabels.value[key] ?? '')) {
return true
}
}
return false
})
/* ── CSRF (read fresh on every request to avoid stale token after CCLI import) ── */
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || ''
}
/* ── Save status ── */
function startSaving() {
saving.value = true
saved.value = false
}
function finishSaving() {
saving.value = false
saved.value = true
if (savedTimeout) clearTimeout(savedTimeout)
savedTimeout = setTimeout(() => {
saved.value = false
}, 2000)
}
function stopSaving() {
saving.value = false
}
/* ── Data fetching ── */
function slidesToText(slides = [], key) {
return slides
.map((slide) => slide[key] ?? '')
.filter((text) => text !== '')
.join('\n\n')
}
function sectionKey(group) {
return group.section_id ?? group.id
}
function setSectionDrafts(data) {
const drafts = {}
const labels = {}
;(data?.groups ?? []).forEach((group) => {
const key = sectionKey(group)
drafts[key] = {
text: slidesToText(group.slides, 'text_content'),
translated: slidesToText(group.slides, 'text_content_translated'),
}
labels[key] = group.name ?? ''
})
sectionDrafts.value = drafts
sectionLabels.value = labels
}
function draftFor(group) {
const key = sectionKey(group)
if (!sectionDrafts.value[key]) {
sectionDrafts.value[key] = { text: '', translated: '' }
}
return sectionDrafts.value[key]
}
const fetchSong = async () => {
if (!props.songId) return
loading.value = true
error.value = null
try {
const response = await fetch(`/api/songs/${props.songId}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Song konnte nicht geladen werden.')
}
const json = await response.json()
songData.value = json.data
setSectionDrafts(json.data)
title.value = json.data.title ?? ''
ccliId.value = json.data.ccli_id ?? ''
copyrightText.value = json.data.copyright_text ?? ''
snapshotPristine()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
watch(
() => props.show,
(isVisible) => {
if (isVisible && props.songId) {
fetchSong()
}
if (!isVisible) {
songData.value = null
sectionDrafts.value = {}
sectionLabels.value = {}
pristine.value = null
error.value = null
showAddSectionForm.value = false
newSectionLabel.value = ''
newSectionText.value = ''
headlineDropdownFor.value = null
}
},
)
/* ── Save metadata ── */
const performSaveMetadata = async () => {
const response = await fetch(`/api/songs/${props.songId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
title: title.value,
ccli_id: ccliId.value || null,
copyright_text: copyrightText.value || null,
}),
})
if (!response.ok) {
throw new Error('Speichern fehlgeschlagen')
}
}
/* ── Save all changes (manual Save button) ── */
async function saveAll() {
if (!props.songId || !isDirty.value || saving.value) return
startSaving()
try {
const metadataDirty =
title.value !== pristine.value.title ||
ccliId.value !== pristine.value.ccliId ||
copyrightText.value !== pristine.value.copyrightText
if (metadataDirty) {
await performSaveMetadata()
}
for (const group of songData.value?.groups ?? []) {
if (group.locked) continue
const key = sectionKey(group)
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
const labelBefore = pristine.value.labels[key] ?? ''
const labelNow = sectionLabels.value[key] ?? ''
const textChanged =
(before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')
const labelChanged = labelBefore !== labelNow
if (textChanged || labelChanged) {
await persistSection(key, labelChanged ? labelNow : null)
}
}
await fetchSong()
finishSaving()
emit('updated')
} catch {
stopSaving()
error.value = 'Speichern fehlgeschlagen.'
}
}
async function persistSection(sectionId, labelName) {
const body = {
slides: buildSectionSlides(sectionId),
}
if (labelName) {
body.label_name = labelName
}
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error('Sektion konnte nicht gespeichert werden.')
}
}
/* ── Close handling (guard unsaved changes) ── */
function requestClose() {
if (isDirty.value && !window.confirm('Es gibt ungespeicherte Änderungen. Wirklich schließen?')) {
return
}
emit('close')
}
/* ── Arrangement props ── */
const arrangements = computed(() => {
if (!songData.value?.arrangements) return []
return songData.value.arrangements.map((arr) => ({
id: arr.id,
name: arr.name,
is_default: arr.is_default,
groups: arr.arrangement_groups.map((ag) => {
const group = songData.value.groups.find((g) => g.id === (ag.section_id ?? ag.label_id))
return {
id: ag.section_id ?? ag.label_id,
section_id: ag.section_id ?? ag.label_id,
label_id: ag.label_id,
name: group?.name ?? 'Unbekannt',
color: group?.color ?? '#6b7280',
order: ag.order,
}
}),
}))
})
const availableGroups = computed(() => {
if (!songData.value?.groups) return []
return songData.value.groups.map((group) => ({
id: group.id,
section_id: group.section_id ?? group.id,
label_id: group.label_id,
name: group.name,
color: group.color,
}))
})
const sectionLabelOptions = computed(() => {
const fromLabels = (songData.value?.available_labels ?? []).map((label) => label.name)
const fromGroups = (songData.value?.groups ?? []).map((group) => group.name)
return [...new Set([...fromLabels, ...fromGroups].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'))
})
const filteredSectionLabelOptions = computed(() => {
const term = newSectionLabel.value.trim().toLowerCase()
if (term === '') return sectionLabelOptions.value
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
})
const canCreateNewLabel = computed(() => {
const term = newSectionLabel.value.trim()
if (term === '') return false
return !sectionLabelOptions.value.some((name) => name.toLowerCase() === term.toLowerCase())
})
function selectSectionLabel(name) {
newSectionLabel.value = name
sectionLabelDropdownOpen.value = false
}
function openSectionLabelDropdown() {
sectionLabelDropdownOpen.value = true
}
function closeSectionLabelDropdown() {
setTimeout(() => {
sectionLabelDropdownOpen.value = false
}, 150)
}
/* ── Section editing ── */
function splitSectionText(value) {
const trimmed = (value ?? '').replace(/\r\n/g, '\n').replace(/\s+$/u, '')
const blocks = trimmed
.split(/\n\s*\n+/u)
.map((block) => block.trim())
.filter((block) => block !== '')
return blocks.length ? blocks : ['']
}
function buildSectionSlides(sectionId) {
const draft = sectionDrafts.value[sectionId] ?? { text: '', translated: '' }
const textBlocks = splitSectionText(draft.text)
const translatedBlocks = (draft.translated ?? '').trim() === '' ? [] : splitSectionText(draft.translated)
return textBlocks.map((text, index) => ({
text_content: text,
text_content_translated: translatedBlocks[index] ?? null,
}))
}
/* ── Section headline combobox ── */
function headlineDraftFor(group) {
return sectionLabels.value[sectionKey(group)] ?? group.name ?? ''
}
function setHeadline(group, name) {
sectionLabels.value[sectionKey(group)] = name
}
function selectHeadline(group, name) {
setHeadline(group, name)
headlineDropdownFor.value = null
}
function openHeadlineDropdown(group) {
headlineDropdownFor.value = sectionKey(group)
}
function closeHeadlineDropdown() {
setTimeout(() => {
headlineDropdownFor.value = null
}, 150)
}
function filteredHeadlineOptions(group) {
const term = (sectionLabels.value[sectionKey(group)] ?? '').trim().toLowerCase()
if (term === '') return sectionLabelOptions.value
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
}
async function deleteSection(sectionId) {
if (!window.confirm('Möchtest Du diese Sektion wirklich löschen?')) return
startSaving()
try {
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
method: 'DELETE',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Sektion konnte nicht gelöscht werden.')
}
const json = await response.json()
songData.value = json.data
setSectionDrafts(json.data)
snapshotPristine()
finishSaving()
emit('updated')
} catch {
stopSaving()
}
}
async function addSection() {
if (!props.songId || !newSectionLabel.value.trim()) return
startSaving()
try {
const response = await fetch(route('songs.sections.store', props.songId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
label_name: newSectionLabel.value,
slides: splitSectionText(newSectionText.value).map((text) => ({ text_content: text })),
}),
})
if (!response.ok) {
throw new Error('Sektion konnte nicht hinzugefügt werden.')
}
newSectionLabel.value = ''
newSectionText.value = ''
showAddSectionForm.value = false
await fetchSong()
finishSaving()
emit('updated')
} catch {
stopSaving()
}
}
/* ── Close handling ── */
const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) {
requestClose()
}
}
const closeOnBackdrop = (e) => {
if (e.target === e.currentTarget) {
requestClose()
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
})
</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
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div data-testid="song-edit-modal" class="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<svg
class="h-5 w-5 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900">
Song bearbeiten
</h2>
<p class="text-sm text-gray-500">
Metadaten und Arrangements verwalten
</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Save status indicator -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="saving"
class="inline-flex items-center gap-1.5 text-sm text-gray-400"
>
<svg
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Speichert…
</span>
<span
v-else-if="saved"
class="inline-flex items-center gap-1.5 text-sm text-emerald-600"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
Gespeichert
</span>
</Transition>
<button
data-testid="song-edit-modal-close-button"
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="requestClose"
>
<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>
</div>
<!-- Loading State -->
<div
v-if="loading"
class="flex items-center justify-center py-16"
>
<svg
class="h-8 w-8 animate-spin text-amber-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="ml-3 text-gray-600">Song wird geladen…</span>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="px-6 py-16 text-center"
>
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg
class="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p class="text-red-600">{{ error }}</p>
<button
data-testid="song-edit-modal-error-close-button"
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="emit('close')"
>
Schließen
</button>
</div>
<!-- Content -->
<div
v-else-if="songData"
class="max-h-[80vh] overflow-y-auto"
>
<!-- Metadata Fields -->
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
Metadaten
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label
for="song-edit-title"
class="mb-1 block text-sm font-medium text-gray-700"
>
Titel
</label>
<input
data-testid="song-edit-modal-title-input"
id="song-edit-title"
v-model="title"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Songtitel eingeben…"
>
</div>
<div>
<label
for="song-edit-ccli"
class="mb-1 block text-sm font-medium text-gray-700"
>
CCLI-ID
</label>
<input
data-testid="song-edit-modal-ccli-input"
id="song-edit-ccli"
v-model="ccliId"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. 123456"
>
</div>
<div class="self-end pb-2 text-sm text-gray-400">
<span
v-if="songData.has_translation"
class="inline-flex items-center gap-1 text-emerald-600"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
Übersetzung vorhanden
</span>
<span
v-else
class="text-gray-400"
>
Keine Übersetzung
</span>
</div>
<div class="sm:col-span-2">
<label
for="song-edit-copyright"
class="mb-1 block text-sm font-medium text-gray-700"
>
Copyright-Text
</label>
<textarea
data-testid="song-edit-modal-copyright-textarea"
id="song-edit-copyright"
v-model="copyrightText"
rows="3"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Copyright-Informationen…"
/>
</div>
</div>
</div>
<!-- Section editing -->
<div class="border-b border-gray-100 px-6 py-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Sektionen
</h3>
<p class="mt-1 text-sm text-gray-500">
Leerzeilen trennen einzelne Folien. Klicke „Speichern", um Deine Änderungen zu sichern.
</p>
</div>
<button
data-testid="section-add-button"
type="button"
class="rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600"
@click="showAddSectionForm = !showAddSectionForm"
>
Neue Sektion
</button>
</div>
<form
v-if="showAddSectionForm"
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4"
@submit.prevent="addSection"
>
<div class="grid gap-4 sm:grid-cols-3">
<div class="relative">
<label for="section-add-label" class="mb-1 block text-sm font-medium text-gray-700">
Name
</label>
<input
data-testid="section-add-label-input"
id="section-add-label"
v-model="newSectionLabel"
type="text"
autocomplete="off"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Abschnitt wählen oder neuen erstellen…"
required
@focus="openSectionLabelDropdown"
@input="openSectionLabelDropdown"
@blur="closeSectionLabelDropdown"
>
<!-- Combobox dropdown -->
<div
v-if="sectionLabelDropdownOpen && (filteredSectionLabelOptions.length > 0 || canCreateNewLabel)"
data-testid="section-label-dropdown"
class="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="labelName in filteredSectionLabelOptions"
:key="labelName"
type="button"
data-testid="section-label-option"
class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(labelName)"
>
{{ labelName }}
</button>
<button
v-if="canCreateNewLabel"
type="button"
data-testid="section-label-create-option"
class="block w-full border-t border-gray-100 px-3 py-2 text-left text-sm font-medium text-amber-700 hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(newSectionLabel.trim())"
>
Neu erstellen: „{{ newSectionLabel.trim() }}"
</button>
</div>
</div>
<div class="sm:col-span-2">
<label for="section-add-text" class="mb-1 block text-sm font-medium text-gray-700">
Text
</label>
<textarea
data-testid="section-add-text-input"
id="section-add-text"
v-model="newSectionText"
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext eingeben…"
/>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-white/70"
@click="showAddSectionForm = false"
>
Abbrechen
</button>
<button
data-testid="section-add-submit"
type="submit"
class="rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-gray-800"
>
Hinzufügen
</button>
</div>
</form>
<div class="space-y-4">
<div
v-for="group in songData.groups"
:key="group.section_id ?? group.id"
data-testid="section-block"
:class="[
'rounded-lg border p-4 shadow-sm',
group.locked ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white',
]"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="relative flex items-center gap-2">
<span
class="inline-block h-4 w-4 shrink-0 rounded-full shadow-sm"
:style="{ backgroundColor: group.color ?? '#6b7280' }"
/>
<span
v-if="group.locked"
data-testid="section-headline-locked"
class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-sm font-semibold text-gray-500"
>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ group.name }}
</span>
<template v-else>
<input
data-testid="section-headline-input"
:value="headlineDraftFor(group)"
type="text"
autocomplete="off"
class="w-48 rounded-md border-gray-300 text-sm font-semibold shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Abschnitt wählen…"
@input="setHeadline(group, $event.target.value)"
@focus="openHeadlineDropdown(group)"
@blur="closeHeadlineDropdown"
>
<div
v-if="headlineDropdownFor === (group.section_id ?? group.id) && filteredHeadlineOptions(group).length > 0"
data-testid="section-headline-dropdown"
class="absolute left-6 top-full z-30 mt-1 max-h-56 w-48 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="labelName in filteredHeadlineOptions(group)"
:key="labelName"
type="button"
data-testid="section-headline-option"
class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
@mousedown.prevent="selectHeadline(group, labelName)"
>
{{ labelName }}
</button>
</div>
</template>
</div>
<button
v-if="!group.locked"
data-testid="section-delete-button"
type="button"
class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700"
title="Sektion löschen"
@click="deleteSection(group.section_id ?? group.id)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7h6m2 0H7m3-3h4a1 1 0 011 1v2H9V5a1 1 0 011-1z" />
</svg>
</button>
</div>
<div
v-if="group.locked"
class="flex items-center gap-1.5 text-xs text-gray-400"
>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Gesperrt wird automatisch verwaltet
</div>
<div
v-else
:class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''"
>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700">
Originaltext
</label>
<textarea
data-testid="section-text-input"
v-model="draftFor(group).text"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Folientext…"
/>
</div>
<div v-if="songData.has_translation">
<label class="mb-1 block text-sm font-medium text-gray-700">
Übersetzung
</label>
<textarea
data-testid="section-translated-input"
v-model="draftFor(group).translated"
rows="6"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="Übersetzter Folientext…"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Arrangement Configurator -->
<div class="px-6 py-5">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
Arrangements
</h3>
<ArrangementConfigurator
:song-id="songData.id"
:arrangements="arrangements"
:available-groups="availableGroups"
@arrangements-changed="fetchSong"
/>
</div>
</div>
<!-- Footer: Save / Close -->
<div
v-if="songData"
class="flex items-center justify-end gap-3 rounded-b-xl border-t border-gray-200 bg-gray-50 px-6 py-4"
>
<button
data-testid="song-edit-close"
type="button"
class="rounded-md px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200"
@click="requestClose"
>
Schließen
</button>
<button
data-testid="song-edit-save"
type="button"
class="rounded-md bg-amber-500 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDirty || saving"
@click="saveAll"
>
Speichern
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>