- 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
331 lines
13 KiB
Vue
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>
|