- CCLI import: group lyrics into 2-line slides (no blank line per line) - Add-section: searchable label combobox with create-new option - Service edit: show current global key-visual/background default live - Assign dialog: prefill+open search, SongSelect link by CCLI nr/name - "Auf SongSelect suchen" now also opens the CCLI import dialog - SongDB: mark empty songs "Ohne Inhalt", default-on content filter - Translation paste: strip section-mark lines so line mapping holds
372 lines
15 KiB
Vue
372 lines
15 KiB
Vue
<script setup>
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
|
import { Head, router } from '@inertiajs/vue3'
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
const props = defineProps({
|
|
song: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
prefilledTranslation: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
})
|
|
|
|
const sourceUrl = ref('')
|
|
const sourceText = ref(props.prefilledTranslation ?? '')
|
|
const isFetching = ref(false)
|
|
const isSaving = ref(false)
|
|
const infoMessage = ref('')
|
|
const errorMessage = ref('')
|
|
const showPrefillBanner = ref(props.prefilledTranslation !== null)
|
|
|
|
// Auto-distribute prefilled translation on mount
|
|
onMounted(() => {
|
|
if (props.prefilledTranslation) {
|
|
distributeTextToSlides(props.prefilledTranslation)
|
|
infoMessage.value = 'Vorausgefüllt aus CCLI — bitte überprüfen und speichern.'
|
|
}
|
|
})
|
|
|
|
const groups = ref(
|
|
(props.song.groups ?? []).map((group) => ({
|
|
...group,
|
|
slides: (group.slides ?? []).map((slide) => {
|
|
const lineCount = getLineCount(slide.text_content)
|
|
|
|
return {
|
|
...slide,
|
|
line_count: lineCount,
|
|
translated_text: normalizeToLineCount(slide.text_content_translated ?? '', lineCount),
|
|
}
|
|
}),
|
|
})),
|
|
)
|
|
|
|
const hasExistingTranslation = computed(() =>
|
|
groups.value.some((group) =>
|
|
group.slides.some((slide) => (slide.text_content_translated ?? '').trim().length > 0),
|
|
),
|
|
)
|
|
|
|
const editorVisible = computed(() => sourceText.value.trim().length > 0 || hasExistingTranslation.value)
|
|
|
|
function getLineCount(text) {
|
|
return normalizeNewlines(text).split('\n').length
|
|
}
|
|
|
|
function normalizeNewlines(text) {
|
|
return (text ?? '').replace(/\r\n/g, '\n')
|
|
}
|
|
|
|
function normalizeToLineCount(text, lineCount) {
|
|
const maxLines = Math.max(1, lineCount)
|
|
const lines = normalizeNewlines(text).split('\n').slice(0, maxLines)
|
|
|
|
while (lines.length < maxLines) {
|
|
lines.push('')
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function orderedSlides() {
|
|
return groups.value
|
|
.slice()
|
|
.sort((a, b) => a.order - b.order)
|
|
.flatMap((group) =>
|
|
group.slides
|
|
.slice()
|
|
.sort((a, b) => a.order - b.order),
|
|
)
|
|
}
|
|
|
|
function applyManualText() {
|
|
if (sourceText.value.trim().length === 0) {
|
|
errorMessage.value = 'Bitte fuege zuerst einen Text ein.'
|
|
infoMessage.value = ''
|
|
return
|
|
}
|
|
|
|
distributeTextToSlides(sourceText.value)
|
|
errorMessage.value = ''
|
|
infoMessage.value = 'Text wurde auf die Folien verteilt.'
|
|
}
|
|
|
|
async function fetchTextFromUrl() {
|
|
if (sourceUrl.value.trim().length === 0) {
|
|
errorMessage.value = 'Bitte gib eine gueltige URL ein.'
|
|
infoMessage.value = ''
|
|
return
|
|
}
|
|
|
|
isFetching.value = true
|
|
errorMessage.value = ''
|
|
infoMessage.value = ''
|
|
|
|
try {
|
|
const response = await window.axios.post('/api/translation/fetch-url', {
|
|
url: sourceUrl.value,
|
|
})
|
|
|
|
sourceText.value = response.data?.text ?? ''
|
|
distributeTextToSlides(sourceText.value)
|
|
infoMessage.value = 'Text wurde erfolgreich abgerufen und verteilt.'
|
|
} catch {
|
|
errorMessage.value = 'Text konnte nicht von der URL abgerufen werden.'
|
|
} finally {
|
|
isFetching.value = false
|
|
}
|
|
}
|
|
|
|
// Mirrors App\Support\CcliLabels::SECTION_LABEL_PATTERN — section marks in pasted
|
|
// translation text (e.g. "Strophe 1", "Refrain", "Chorus 2") must be ignored so they
|
|
// don't shift the line-by-line mapping onto the original slides.
|
|
const SECTION_LABEL_PATTERN =
|
|
/^(Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu
|
|
|
|
function stripSectionLabelLines(lines) {
|
|
return lines.filter((line) => !SECTION_LABEL_PATTERN.test(line.trim()))
|
|
}
|
|
|
|
function distributeTextToSlides(text) {
|
|
const translatedLines = stripSectionLabelLines(normalizeNewlines(text).split('\n'))
|
|
let offset = 0
|
|
|
|
orderedSlides().forEach((slide) => {
|
|
const chunk = translatedLines.slice(offset, offset + slide.line_count)
|
|
offset += slide.line_count
|
|
slide.translated_text = normalizeToLineCount(chunk.join('\n'), slide.line_count)
|
|
})
|
|
}
|
|
|
|
function onTranslationInput(slide, value) {
|
|
slide.translated_text = normalizeToLineCount(value, slide.line_count)
|
|
}
|
|
|
|
async function saveTranslation() {
|
|
isSaving.value = true
|
|
errorMessage.value = ''
|
|
infoMessage.value = ''
|
|
|
|
const text = orderedSlides()
|
|
.map((slide) => normalizeToLineCount(slide.translated_text, slide.line_count))
|
|
.join('\n')
|
|
|
|
try {
|
|
await window.axios.post(`/api/songs/${props.song.id}/translation/import`, { text })
|
|
router.visit('/songs?success=Uebersetzung+gespeichert')
|
|
} catch {
|
|
errorMessage.value = 'Uebersetzung konnte nicht gespeichert werden.'
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
function rowsForSlide(slide) {
|
|
return Math.max(3, slide.line_count)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="`Uebersetzung: ${song.title}`" />
|
|
|
|
<AuthenticatedLayout>
|
|
<template #header>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 class="text-xl font-semibold leading-tight text-gray-900">Song uebersetzen</h2>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{{ song.title }}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
data-testid="translate-back-button"
|
|
type="button"
|
|
class="inline-flex items-center rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
|
@click="router.visit('/songs')"
|
|
>
|
|
Zurueck
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="py-8">
|
|
<div class="mx-auto max-w-6xl space-y-6 px-4 sm:px-6 lg:px-8">
|
|
|
|
<!-- CCLI Prefill Banner -->
|
|
<div
|
|
v-if="showPrefillBanner"
|
|
data-testid="ccli-prefill-banner"
|
|
class="flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800"
|
|
>
|
|
<span>Vorausgefüllt aus CCLI — bitte überprüfen und speichern.</span>
|
|
<button
|
|
data-testid="ccli-prefill-discard-button"
|
|
type="button"
|
|
@click="showPrefillBanner = false; sourceText = ''; groups.forEach(g => g.slides.forEach(s => s.translated_text = ''))"
|
|
class="ml-4 text-blue-600 underline hover:text-blue-800"
|
|
>
|
|
Verwerfen
|
|
</button>
|
|
</div>
|
|
|
|
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 class="text-base font-semibold text-gray-900">Uebersetzungstext laden</h3>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Du kannst einen Text von einer URL abrufen oder manuell einfuegen.
|
|
</p>
|
|
|
|
<div class="mt-4 grid gap-3 md:grid-cols-[1fr,auto]">
|
|
<input
|
|
data-testid="translate-url-input"
|
|
v-model="sourceUrl"
|
|
type="url"
|
|
placeholder="https://beispiel.de/lyrics"
|
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
|
>
|
|
<button
|
|
data-testid="translate-fetch-button"
|
|
type="button"
|
|
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
:disabled="isFetching"
|
|
@click="fetchTextFromUrl"
|
|
>
|
|
{{ isFetching ? 'Abrufen...' : 'Text abrufen' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
|
Text manuell einfuegen
|
|
</label>
|
|
<textarea
|
|
data-testid="translate-source-textarea"
|
|
v-model="sourceText"
|
|
rows="10"
|
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
|
placeholder="Fuege hier den kompletten Uebersetzungstext ein..."
|
|
/>
|
|
<div class="mt-3 flex justify-end">
|
|
<button
|
|
data-testid="translate-apply-button"
|
|
type="button"
|
|
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
|
@click="applyManualText"
|
|
>
|
|
Text auf Folien verteilen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
v-if="errorMessage"
|
|
class="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
|
>
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="infoMessage"
|
|
class="rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700"
|
|
>
|
|
{{ infoMessage }}
|
|
</div>
|
|
|
|
<section
|
|
v-if="editorVisible"
|
|
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-gray-900">Folien-Editor</h3>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Links siehst du den Originaltext, rechts bearbeitest du die Uebersetzung.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
data-testid="translate-save-button"
|
|
type="button"
|
|
class="inline-flex items-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
:disabled="isSaving"
|
|
@click="saveTranslation"
|
|
>
|
|
{{ isSaving ? 'Speichern...' : 'Speichern' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div
|
|
v-for="group in groups"
|
|
:key="group.id"
|
|
class="overflow-hidden rounded-lg border border-gray-200"
|
|
>
|
|
<div
|
|
class="flex items-center justify-between px-4 py-2"
|
|
:style="{ backgroundColor: group.color || '#334155' }"
|
|
>
|
|
<h4 class="text-sm font-semibold text-white">
|
|
{{ group.name }}
|
|
</h4>
|
|
<span class="text-xs font-medium text-white/90">
|
|
{{ group.slides.length }} Folien
|
|
</span>
|
|
</div>
|
|
|
|
<div class="space-y-4 bg-gray-50 p-4">
|
|
<div
|
|
v-for="slide in group.slides"
|
|
:key="slide.id"
|
|
class="rounded-lg border border-gray-200 bg-white p-4"
|
|
>
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<p class="text-sm font-semibold text-gray-800">
|
|
Folie {{ slide.order }}
|
|
</p>
|
|
<p class="text-xs font-medium text-gray-500">
|
|
Zeilen: {{ slide.line_count }}/{{ slide.line_count }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid gap-4 lg:grid-cols-2">
|
|
<div>
|
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
|
Original
|
|
</label>
|
|
<textarea
|
|
data-testid="translate-original-textarea"
|
|
:value="slide.text_content"
|
|
:rows="rowsForSlide(slide)"
|
|
readonly
|
|
class="block w-full rounded-md border-gray-300 bg-gray-100 text-sm text-gray-700"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
|
Uebersetzung
|
|
</label>
|
|
<textarea
|
|
data-testid="translate-translation-textarea"
|
|
:value="slide.translated_text"
|
|
:rows="rowsForSlide(slide)"
|
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
|
@input="onTranslationInput(slide, $event.target.value)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</AuthenticatedLayout>
|
|
</template>
|