pp-planer/resources/js/Components/CcliPasteDialog.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

270 lines
9.1 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'songdb' }, // 'songdb' | 'service-form' | 'pair-translation'
serviceSongId: { type: Number, default: null },
pairWithSongId: { type: Number, default: null },
prefilledText: { type: String, default: null },
autoEdit: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
const pasteText = ref('')
const preview = ref(null)
const error = ref(null)
const loading = ref(false)
const existingSongId = ref(null)
onMounted(() => {
if (props.prefilledText) {
pasteText.value = props.prefilledText
doPreview()
}
})
function getCsrfToken() {
const match = document.cookie.split('; ').find(r => r.startsWith('XSRF-TOKEN='))
return match ? decodeURIComponent(match.split('=')[1]) : ''
}
async function doPreview() {
if (!pasteText.value.trim()) return
loading.value = true
error.value = null
preview.value = null
existingSongId.value = null
try {
const res = await fetch(route('api.ccli.preview'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': getCsrfToken() },
body: JSON.stringify({ raw_text: pasteText.value }),
})
const data = await res.json()
if (!res.ok) {
error.value = data.message || 'Fehler beim Verarbeiten des Textes.'
} else {
preview.value = data
if (props.autoEdit && (props.mode === 'songdb' || props.mode === 'service-form')) {
await doImport('edit')
}
}
} catch {
error.value = 'Netzwerkfehler. Bitte versuche es erneut.'
} finally {
loading.value = false
}
}
async function doImport(importMode) {
loading.value = true
error.value = null
existingSongId.value = null
const modeMap = {
edit: 'create',
stay: 'create',
assign: 'assign-to-service-song',
pair: 'pair-with-song',
}
const targetMap = {
assign: props.serviceSongId,
pair: props.pairWithSongId,
}
try {
const res = await fetch(route('api.ccli.import'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': getCsrfToken() },
body: JSON.stringify({
raw_text: pasteText.value,
mode: modeMap[importMode],
target_id: targetMap[importMode] ?? null,
}),
})
const data = await res.json()
if (res.status === 409) {
existingSongId.value = data.existing_song_id
error.value = data.message
return
}
if (!res.ok) {
error.value = data.message || 'Import fehlgeschlagen.'
return
}
if (importMode === 'edit') {
emit('edit-song', data.song_id)
emit('close')
} else if (importMode === 'pair') {
router.visit('/songs/' + props.pairWithSongId + '/translate?prefilled=true')
} else {
emit('imported', data.song_id, importMode)
emit('close')
}
} catch {
error.value = 'Netzwerkfehler. Bitte versuche es erneut.'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
{{ mode === 'pair-translation' ? 'Übersetzung aus SongSelect übernehmen' : 'Song aus SongSelect importieren' }}
</h2>
<button
data-testid="ccli-close-button"
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Instructions -->
<ol class="text-sm text-gray-600 mb-4 space-y-1 list-decimal list-inside">
<li>Öffne die Liedseite auf <strong>songselect.ccli.com</strong></li>
<li>Klicke das <strong>Kopier-Symbol</strong> neben dem Liedtext es kopiert Titel, Liedtext und CCLI-Infos</li>
<li>Füge alles unten ein und klicke auf <strong>„Vorschau"</strong></li>
</ol>
<!-- Textarea -->
<textarea
data-testid="ccli-paste-textarea"
v-model="pasteText"
rows="10"
class="w-full border border-gray-300 rounded-md p-3 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Liedtext hier einfügen..."
/>
<!-- Preview button + spinner -->
<div class="mt-3 flex items-center gap-3">
<button
data-testid="ccli-preview-button"
@click="doPreview"
:disabled="!pasteText.trim() || loading"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Vorschau
</button>
<span
v-if="loading"
data-testid="ccli-loading-spinner"
class="inline-block animate-spin text-blue-600 text-xl"
>⟳</span>
</div>
<!-- Error message -->
<div
v-if="error"
data-testid="ccli-error-message"
class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700"
>
{{ error }}
<button
v-if="existingSongId"
type="button"
@click="emit('edit-song', existingSongId); emit('close')"
data-testid="ccli-existing-song-link"
class="ml-2 underline font-medium"
>Vorhandenen Song bearbeiten</button>
</div>
<!-- Preview pane -->
<div v-if="preview" class="mt-4 p-4 bg-gray-50 rounded-md border border-gray-200">
<div class="grid grid-cols-2 gap-2 text-sm mb-3">
<div><span class="text-gray-500">Titel:</span> <strong>{{ preview.title }}</strong></div>
<div><span class="text-gray-500">Autor:</span> {{ preview.author || '' }}</div>
<div><span class="text-gray-500">CCLI-Nr.:</span> {{ preview.ccliId || '' }}</div>
<div><span class="text-gray-500">Jahr:</span> {{ preview.year || '' }}</div>
</div>
<div class="max-h-72 overflow-auto space-y-3">
<div
v-for="(section, si) in preview.sections"
:key="si"
data-testid="ccli-preview-section"
>
<div class="flex items-center gap-2">
<h4 class="text-xs font-semibold text-gray-700">{{ section.label }}</h4>
<span
v-if="section.hasTranslation"
class="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700"
>mit Übersetzung</span>
</div>
<p class="mt-0.5 whitespace-pre-wrap text-xs text-gray-500">{{ section.lines?.join('\n') }}</p>
</div>
</div>
</div>
<!-- Action buttons (shown after preview) -->
<div v-if="preview" class="mt-4 flex gap-3">
<!-- songdb mode -->
<template v-if="mode === 'songdb'">
<button
data-testid="ccli-import-edit-button"
@click="doImport('edit')"
:disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700 disabled:opacity-50"
>
Importieren &amp; Bearbeiten
</button>
<button
data-testid="ccli-import-stay-button"
@click="doImport('stay')"
:disabled="loading"
class="px-4 py-2 bg-gray-600 text-white rounded-md text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
Importieren
</button>
</template>
<!-- service-form mode -->
<template v-else-if="mode === 'service-form'">
<button
data-testid="ccli-import-edit-button"
@click="doImport('edit')"
:disabled="loading"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700 disabled:opacity-50"
>
Importieren &amp; Bearbeiten
</button>
<button
data-testid="ccli-import-assign-button"
@click="doImport('assign')"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
Importieren &amp; Zuweisen
</button>
</template>
<!-- pair-translation mode -->
<template v-else-if="mode === 'pair-translation'">
<button
data-testid="ccli-pair-translation-button"
@click="doImport('pair')"
:disabled="loading"
class="px-4 py-2 bg-purple-600 text-white rounded-md text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
>
Übersetzung übernehmen
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>