pp-planer/resources/js/Pages/Settings.vue
Thorsten Bus e33418f716 feat: song pre/postfix, settings overhaul, export & schedule fixes
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).
2026-06-01 08:56:20 +02:00

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.&nbsp;B. COPYRIGHT) und Postfix (z.&nbsp;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>