- CCLI import: group lyrics into 2-line slides (no blank line per line) - Add-section: searchable label combobox with create-new option - Service edit: show current global key-visual/background default live - Assign dialog: prefill+open search, SongSelect link by CCLI nr/name - "Auf SongSelect suchen" now also opens the CCLI import dialog - SongDB: mark empty songs "Ohne Inhalt", default-on content filter - Translation paste: strip section-mark lines so line mapping holds
915 lines
38 KiB
Vue
915 lines
38 KiB
Vue
<script setup>
|
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
|
import { useDebounceFn } from '@vueuse/core'
|
|
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 title = ref('')
|
|
const ccliId = ref('')
|
|
const copyrightText = ref('')
|
|
const showAddSectionForm = ref(false)
|
|
const newSectionLabel = ref('')
|
|
const newSectionText = ref('')
|
|
const sectionLabelDropdownOpen = ref(false)
|
|
|
|
const saving = ref(false)
|
|
const saved = ref(false)
|
|
let savedTimeout = null
|
|
const sectionSaveDebouncers = new Map()
|
|
|
|
/* ── 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 = {}
|
|
|
|
;(data?.groups ?? []).forEach((group) => {
|
|
drafts[sectionKey(group)] = {
|
|
text: slidesToText(group.slides, 'text_content'),
|
|
translated: slidesToText(group.slides, 'text_content_translated'),
|
|
}
|
|
})
|
|
|
|
sectionDrafts.value = drafts
|
|
}
|
|
|
|
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 ?? ''
|
|
} 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 = {}
|
|
error.value = null
|
|
showAddSectionForm.value = false
|
|
newSectionLabel.value = ''
|
|
newSectionText.value = ''
|
|
}
|
|
},
|
|
)
|
|
|
|
/* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */
|
|
|
|
const performSave = async (data) => {
|
|
if (!props.songId) return
|
|
|
|
startSaving()
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
|
|
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': csrfToken,
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(data),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Speichern fehlgeschlagen')
|
|
}
|
|
|
|
finishSaving()
|
|
|
|
emit('updated')
|
|
} catch {
|
|
stopSaving()
|
|
}
|
|
}
|
|
|
|
const buildPayload = () => ({
|
|
title: title.value,
|
|
ccli_id: ccliId.value || null,
|
|
copyright_text: copyrightText.value || null,
|
|
})
|
|
|
|
// 500ms debounce for text inputs
|
|
const debouncedSave = useDebounceFn((data) => {
|
|
performSave(data)
|
|
}, 500)
|
|
|
|
const onTextInput = () => {
|
|
debouncedSave(buildPayload())
|
|
}
|
|
|
|
// Immediate save on blur (cancel pending debounce)
|
|
const onFieldBlur = () => {
|
|
debouncedSave.cancel?.()
|
|
performSave(buildPayload())
|
|
}
|
|
|
|
/* ── 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,
|
|
}))
|
|
}
|
|
|
|
async function saveSection(sectionId) {
|
|
if (!props.songId || !sectionDrafts.value[sectionId]) return
|
|
|
|
startSaving()
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
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': csrfToken,
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
slides: buildSectionSlides(sectionId),
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Sektion konnte nicht gespeichert werden.')
|
|
}
|
|
|
|
const json = await response.json()
|
|
songData.value = json.data
|
|
setSectionDrafts(json.data)
|
|
finishSaving()
|
|
emit('updated')
|
|
} catch {
|
|
stopSaving()
|
|
}
|
|
}
|
|
|
|
function onSectionInput(sectionId) {
|
|
if (!sectionSaveDebouncers.has(sectionId)) {
|
|
sectionSaveDebouncers.set(sectionId, useDebounceFn(() => {
|
|
saveSection(sectionId)
|
|
}, 600))
|
|
}
|
|
|
|
sectionSaveDebouncers.get(sectionId)()
|
|
}
|
|
|
|
function onSectionBlur(sectionId) {
|
|
sectionSaveDebouncers.get(sectionId)?.cancel?.()
|
|
saveSection(sectionId)
|
|
}
|
|
|
|
async function deleteSection(sectionId) {
|
|
if (!window.confirm('Möchtest Du diese Sektion wirklich löschen?')) return
|
|
|
|
startSaving()
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
},
|
|
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)
|
|
finishSaving()
|
|
emit('updated')
|
|
} catch {
|
|
stopSaving()
|
|
}
|
|
}
|
|
|
|
async function addSection() {
|
|
if (!props.songId || !newSectionLabel.value.trim()) return
|
|
|
|
startSaving()
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
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': csrfToken,
|
|
},
|
|
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) {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
const closeOnBackdrop = (e) => {
|
|
if (e.target === e.currentTarget) {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
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="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>
|
|
</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…"
|
|
@input="onTextInput"
|
|
@blur="onFieldBlur"
|
|
>
|
|
</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"
|
|
@input="onTextInput"
|
|
@blur="onFieldBlur"
|
|
>
|
|
</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…"
|
|
@input="onTextInput"
|
|
@blur="onFieldBlur"
|
|
/>
|
|
</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. Änderungen speichern automatisch.
|
|
</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 border-gray-200 bg-white p-4 shadow-sm"
|
|
>
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<span
|
|
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm"
|
|
:style="{ backgroundColor: group.color ?? '#6b7280' }"
|
|
>
|
|
{{ group.name }}
|
|
</span>
|
|
|
|
<button
|
|
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 :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…"
|
|
@input="onSectionInput(group.section_id ?? group.id)"
|
|
@blur="onSectionBlur(group.section_id ?? group.id)"
|
|
/>
|
|
</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…"
|
|
@input="onSectionInput(group.section_id ?? group.id)"
|
|
@blur="onSectionBlur(group.section_id ?? group.id)"
|
|
/>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|