pp-planer/resources/js/Pages/Songs/Translate.vue
Thorsten Bus 4520c1ce5f test(e2e): add data-testid attributes to all Vue components
- Add data-testid to 18 Vue components (Pages, Blocks, Features, Layouts, Primitives)
- Naming convention: {component-kebab}-{element-description}
- 98 total data-testid attributes added
- Target elements: buttons, links, inputs, modals, navigation
- No logic/styling changes - attributes only
2026-03-01 22:45:13 +01:00

331 lines
13 KiB
Vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, router } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
const props = defineProps({
song: {
type: Object,
required: true,
},
})
const sourceUrl = ref('')
const sourceText = ref('')
const isFetching = ref(false)
const isSaving = ref(false)
const infoMessage = ref('')
const errorMessage = ref('')
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
}
}
function distributeTextToSlides(text) {
const translatedLines = 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">
<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>