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

266 lines
8.9 KiB
Vue
Raw 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 },
})
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
}
} 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>