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).
304 lines
16 KiB
Vue
304 lines
16 KiB
Vue
<script setup>
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
|
import MacroPicker from '@/Components/MacroPicker.vue'
|
|
import LabelPicker from '@/Components/LabelPicker.vue'
|
|
import AgendaSettings from './Settings/AgendaSettings.vue'
|
|
import ExportProFiles from './Settings/ExportProFiles.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, watch } 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: () => ({}) },
|
|
export_pro_files: { type: Object, default: () => ({ prefix: [], postfix: [] }) },
|
|
})
|
|
|
|
const submenus = [
|
|
{ key: 'assignments', label: 'Makro-Zuweisungen' },
|
|
{ key: 'macros', label: 'Makro' },
|
|
{ key: 'labels', label: 'Label' },
|
|
{ key: 'agenda', label: 'Agenda' },
|
|
{ key: 'ccli', label: 'CCLI Import' },
|
|
{ key: 'export-files', label: 'Export-Dateien' },
|
|
]
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// Task 5: Namenseinblender macro picker (moved into assignments panel)
|
|
const nametagMacroId = ref(props.settings.namenseinblender_macro_id ? Number(props.settings.namenseinblender_macro_id) : null)
|
|
watch(nametagMacroId, (value) => {
|
|
updateSetting('namenseinblender_macro_id', value === null ? '' : String(value))
|
|
})
|
|
|
|
// Task 6: Song prefix/postfix label defaults
|
|
const songPrefixLabelId = ref(props.settings.song_prefix_label_id ? Number(props.settings.song_prefix_label_id) : null)
|
|
const songPostfixLabelId = ref(props.settings.song_postfix_label_id ? Number(props.settings.song_postfix_label_id) : null)
|
|
watch(songPrefixLabelId, (value) => {
|
|
updateSetting('song_prefix_label_id', value === null ? '' : String(value))
|
|
})
|
|
watch(songPostfixLabelId, (value) => {
|
|
updateSetting('song_postfix_label_id', value === null ? '' : String(value))
|
|
})
|
|
</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 overflow-y-auto p-6 max-h-[calc(100vh-12rem)]" data-testid="settings-active-panel">
|
|
<!-- Makro-Zuweisungen: includes Namenseinblender at top -->
|
|
<div v-if="activeSubmenu === 'assignments'" class="space-y-6">
|
|
<!-- Task 5: Namenseinblender MacroPicker at top of assignments -->
|
|
<div class="rounded-lg border border-gray-200 p-4">
|
|
<h3 class="mb-1 text-sm font-semibold text-gray-900">Namenseinblender</h3>
|
|
<p class="mb-3 text-xs text-gray-500">
|
|
Wähle das ProPresenter-Makro für die Namenseinblendung bei Moderation und Predigt. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
|
|
</p>
|
|
<MacroPicker
|
|
v-model="nametagMacroId"
|
|
:macros="macros"
|
|
:collections="collections"
|
|
data-testid="namenseinblender-macro-picker"
|
|
/>
|
|
</div>
|
|
|
|
<MacroAssignments
|
|
:assignments="assignments"
|
|
:macros="macros"
|
|
:labels="labels"
|
|
:collections="collections"
|
|
@switch-submenu="switchSubmenu"
|
|
/>
|
|
</div>
|
|
|
|
<MacroImport
|
|
v-if="activeSubmenu === 'macros'"
|
|
:macros="macros"
|
|
:collections="collections"
|
|
:last_macros_import="last_macros_import"
|
|
@switch-submenu="switchSubmenu"
|
|
/>
|
|
|
|
<!-- Label panel: import + prefix/postfix defaults -->
|
|
<div v-if="activeSubmenu === 'labels'" class="space-y-6">
|
|
<LabelImport
|
|
:labels="labels"
|
|
:last_labels_import="last_labels_import"
|
|
/>
|
|
|
|
<!-- Task 6: Song prefix/postfix label defaults -->
|
|
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900">Standard-Labels für Songs</h3>
|
|
<p class="mt-1 text-xs text-gray-500">
|
|
Diese Labels werden bei jedem Song-Export automatisch als Prefix (z. B. COPYRIGHT) und Postfix (z. B. BLANK) angehängt.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Prefix-Label (Standard: COPYRIGHT)</label>
|
|
<LabelPicker
|
|
v-model="songPrefixLabelId"
|
|
:labels="labels"
|
|
data-testid="song-prefix-label-picker"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Postfix-Label (Standard: BLANK)</label>
|
|
<LabelPicker
|
|
v-model="songPostfixLabelId"
|
|
:labels="labels"
|
|
data-testid="song-postfix-label-picker"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<!-- Task 7: Export-Dateien submenu -->
|
|
<ExportProFiles
|
|
v-if="activeSubmenu === 'export-files'"
|
|
:prefix-files="export_pro_files.prefix"
|
|
:postfix-files="export_pro_files.postfix"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AuthenticatedLayout>
|
|
</template>
|