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).
1077 lines
45 KiB
Vue
1077 lines
45 KiB
Vue
<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>
|