pp-planer/resources/js/Components/SongEditModal.vue
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00

862 lines
35 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 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(() => {
if (!songData.value?.groups) return []
return [...new Set(songData.value.groups.map((group) => group.name).filter(Boolean))]
})
/* ── 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>
<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"
list="section-label-options"
type="text"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. Strophe 3"
required
>
<datalist id="section-label-options">
<option
v-for="labelName in sectionLabelOptions"
:key="labelName"
:value="labelName"
/>
</datalist>
</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>