pp-planer/resources/js/Pages/Settings.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

287 lines
16 KiB
Vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import AgendaSettings from './Settings/AgendaSettings.vue'
import LabelImport from './Settings/LabelImport.vue'
import MacroAssignments from './Settings/MacroAssignments.vue'
import MacroImport from './Settings/MacroImport.vue'
import { Head, router } from '@inertiajs/vue3'
import { computed, onMounted, ref } from 'vue'
const props = defineProps({
settings: { type: Object, default: () => ({}) },
assignments: { type: Array, default: () => [] },
macros: { type: Array, default: () => [] },
labels: { type: Array, default: () => [] },
collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) },
last_labels_import: { type: Object, default: () => ({}) },
})
const submenus = [
{ key: 'assignments', label: 'Makro-Zuweisungen' },
{ key: 'macros', label: 'Makro-Import' },
{ key: 'labels', label: 'Label-Import' },
{ key: 'agenda', label: 'Agenda' },
{ key: 'ccli', label: 'CCLI Import' },
{ key: 'namenseinblender', label: 'Namenseinblender' },
]
const activeSubmenu = ref('assignments')
onMounted(() => {
const hash = window.location.hash.replace('#', '')
if (submenus.some((s) => s.key === hash)) {
activeSubmenu.value = hash
}
})
function switchSubmenu(key) {
activeSubmenu.value = key
window.location.hash = key
}
// Fetch bookmarklet href from the server endpoint
const bookmarkletHref = ref('javascript:void(0)')
onMounted(async () => {
try {
const res = await fetch(route('bookmarklets.ccli'))
if (res.ok) {
bookmarkletHref.value = await res.text()
}
} catch {
// fallback: keep void
}
})
async function updateSetting(key, value) {
try {
await fetch(route('settings.update'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.split('; ').find(r => r.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? ''),
'Accept': 'application/json',
},
body: JSON.stringify({ key, value }),
})
} catch {
// silent fail
}
}
</script>
<template>
<Head title="Einstellungen" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800">
Einstellungen
</h2>
</template>
<div class="py-8">
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm">
<div class="flex min-h-[400px]">
<!-- Sidebar (desktop) -->
<div class="hidden w-48 shrink-0 border-r border-gray-100 sm:block">
<nav class="flex flex-col gap-1 p-2">
<button
v-for="item in submenus"
:key="item.key"
:data-testid="'settings-submenu-' + item.key"
@click="switchSubmenu(item.key)"
:class="[
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',
activeSubmenu === item.key
? 'border-l-2 border-amber-500 bg-amber-50 text-amber-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
]"
>
{{ item.label }}
</button>
</nav>
</div>
<!-- Mobile tab bar -->
<div class="w-full border-b border-gray-100 sm:hidden">
<nav class="flex overflow-x-auto">
<button
v-for="item in submenus"
:key="item.key"
:data-testid="'settings-submenu-' + item.key"
@click="switchSubmenu(item.key)"
:class="[
'shrink-0 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors',
activeSubmenu === item.key
? 'border-amber-500 text-amber-700'
: 'border-transparent text-gray-600 hover:text-gray-900',
]"
>
{{ item.label }}
</button>
</nav>
</div>
<!-- Content panel -->
<div class="flex-1 p-6" data-testid="settings-active-panel">
<MacroAssignments
v-if="activeSubmenu === 'assignments'"
:assignments="assignments"
:macros="macros"
:labels="labels"
:collections="collections"
@switch-submenu="switchSubmenu"
/>
<MacroImport
v-if="activeSubmenu === 'macros'"
:macros="macros"
:collections="collections"
:last_macros_import="last_macros_import"
@switch-submenu="switchSubmenu"
/>
<LabelImport
v-if="activeSubmenu === 'labels'"
:labels="labels"
:last_labels_import="last_labels_import"
/>
<AgendaSettings
v-if="activeSubmenu === 'agenda'"
:settings="settings"
/>
<!-- CCLI Import Settings -->
<div v-if="activeSubmenu === 'ccli'" class="space-y-6">
<div>
<h3 class="text-base font-semibold text-gray-900">CCLI SongSelect Import</h3>
<p class="mt-1 text-sm text-gray-500">
Importiere Songs direkt aus SongSelect in deine Song-Datenbank.
</p>
</div>
<!-- Default Translation Language -->
<div class="rounded-lg border border-gray-200 p-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Standard-Übersetzungssprache
</label>
<p class="text-xs text-gray-500 mb-3">
Wenn ein Song auf SongSelect in mehreren Sprachen verfügbar ist, wird diese Sprache als Übersetzung importiert.
</p>
<select
data-testid="default-translation-language"
:value="settings.default_translation_language || 'DE'"
@change="updateSetting('default_translation_language', $event.target.value)"
class="block w-48 rounded-md border-gray-300 text-sm shadow-sm focus:border-amber-500 focus:ring-amber-500"
>
<option value="DE">Deutsch (DE)</option>
<option value="EN">Englisch (EN)</option>
<option value="FR">Französisch (FR)</option>
<option value="ES">Spanisch (ES)</option>
<option value="NL">Niederländisch (NL)</option>
<option value="IT">Italienisch (IT)</option>
</select>
</div>
<!-- Bookmarklet Installer -->
<div class="rounded-lg border border-blue-100 bg-blue-50 p-4">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Browser-Lesezeichen installieren</h4>
<p class="text-sm text-blue-800 mb-3">
Mit diesem Lesezeichen kannst du Songs direkt von SongSelect in pp-planer importieren — ohne Copy-Paste.
</p>
<ol class="text-sm text-blue-800 space-y-1 list-decimal list-inside mb-4">
<li>Ziehe den Button unten in deine Lesezeichen-Leiste</li>
<li>Öffne ein Lied auf <strong>songselect.ccli.com</strong> (du musst eingeloggt sein)</li>
<li>Klicke das Lesezeichen — der Liedtext wird automatisch übertragen</li>
<li>Klicke auf „Importieren" — fertig!</li>
</ol>
<p class="text-xs text-blue-600 mb-3">
Lesezeichen-Leiste aktivieren: <kbd class="px-1 py-0.5 bg-blue-100 rounded">Strg+Umschalt+B</kbd> (Mac: <kbd class="px-1 py-0.5 bg-blue-100 rounded">Cmd+Shift+B</kbd>)
</p>
<a
data-testid="ccli-bookmarklet-drag-link"
:href="bookmarkletHref"
class="inline-flex items-center gap-2 rounded-md border border-blue-300 bg-white px-4 py-2 text-sm font-medium text-blue-700 shadow-sm cursor-grab hover:bg-blue-50"
@click.prevent
draggable="true"
>
📥 CCLI Import
</a>
<p class="mt-2 text-xs text-blue-500">
Diesen Button in die Lesezeichen-Leiste ziehen.
</p>
<!-- Troubleshooting -->
<details class="mt-4">
<summary class="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Was tun, wenn der Liedtext nicht erkannt wird?</summary>
<p class="mt-2 text-xs text-blue-700">
Falle zurück auf das manuelle Einfügen: Öffne das Lied auf SongSelect, klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext (es kopiert Titel, Liedtext und CCLI-Infos), und klicke dann auf „Aus CCLI importieren" in der Song-Datenbank oder im Gottesdienst-Formular.
</p>
</details>
</div>
</div>
<!-- Namenseinblender Macro Settings -->
<div v-if="activeSubmenu === 'namenseinblender'" class="space-y-6">
<div>
<h3 class="text-base font-semibold text-gray-900">Namenseinblender-Makro</h3>
<p class="mt-1 text-sm text-gray-500">
Konfiguriere das ProPresenter-Makro, das für die Namenseinblendung bei Moderation und Predigt verwendet wird. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
</p>
</div>
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-Name</label>
<input
type="text"
:value="settings.namenseinblender_macro_name || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="z.B. Namenseinblender"
data-testid="namenseinblender-macro-name"
@change="updateSetting('namenseinblender_macro_name', $event.target.value)"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-UUID</label>
<input
type="text"
:value="settings.namenseinblender_macro_uuid || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
placeholder="UUID des Makros"
data-testid="namenseinblender-macro"
@change="updateSetting('namenseinblender_macro_uuid', $event.target.value)"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-Name</label>
<input
type="text"
:value="settings.namenseinblender_macro_collection_name || '--MAIN--'"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="--MAIN--"
@change="updateSetting('namenseinblender_macro_collection_name', $event.target.value)"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-UUID</label>
<input
type="text"
:value="settings.namenseinblender_macro_collection_uuid || ''"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
placeholder="UUID der Collection"
@change="updateSetting('namenseinblender_macro_collection_uuid', $event.target.value)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>