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.
287 lines
16 KiB
Vue
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>
|