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

408 lines
14 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
song: {
type: Object,
required: true,
},
arrangement: {
type: Object,
required: true,
},
useTranslation: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
/**
* Groups in arrangement order: arrangement.groups contains the ordered
* arrangement_groups with nested group + slides data.
*/
const groupsInOrder = computed(() => {
if (!props.arrangement?.groups) {
return []
}
return props.arrangement.groups
.slice()
.sort((a, b) => a.order - b.order)
.map((ag) => {
const group = ag.group ?? ag
const slides = (group.slides ?? [])
.slice()
.sort((a, b) => a.order - b.order)
return {
name: group.name,
color: group.color ?? '#6b7280',
slides,
}
})
})
const pdfUrl = computed(() => {
if (!props.song?.id || !props.arrangement?.id) {
return '#'
}
return `/songs/${props.song.id}/arrangements/${props.arrangement.id}/pdf`
})
const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
})
const closeOnBackdrop = (e) => {
if (e.target === e.currentTarget) {
emit('close')
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div data-testid="song-preview-modal" class="relative w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-xl font-bold text-gray-900">
{{ song.title }}
</h2>
<p class="text-sm text-gray-500">
Arrangement: {{ arrangement.name }}
</p>
</div>
<div class="flex items-center gap-3">
<a
data-testid="song-preview-modal-pdf-link"
:href="pdfUrl"
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-700"
target="_blank"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF
</a>
<button
data-testid="song-preview-modal-close-button"
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Song Content -->
<div class="max-h-[70vh] overflow-y-auto px-6 py-4">
<div
v-for="(group, groupIndex) in groupsInOrder"
:key="`${groupIndex}-${group.name}`"
class="mb-5"
>
<!-- Group header with color -->
<div
class="mb-2 inline-block rounded-md px-3 py-1 text-sm font-bold text-white"
:style="{ backgroundColor: group.color }"
>
{{ group.name }}
</div>
<!-- Slides -->
<div
v-for="(slide, slideIndex) in group.slides"
:key="slideIndex"
class="mb-3 pl-3"
>
<template v-if="useTranslation && slide.text_content_translated">
<!-- Side-by-side: Original + Translation -->
<div class="grid grid-cols-2 gap-4">
<div class="whitespace-pre-wrap text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</div>
<div class="whitespace-pre-wrap border-l-2 border-gray-200 pl-4 text-sm italic leading-relaxed text-gray-500">{{ slide.text_content_translated }}</div>
</div>
</template>
<template v-else>
<div class="whitespace-pre-wrap text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</div>
</template>
</div>
</div>
<!-- Empty state -->
<div
v-if="groupsInOrder.length === 0"
class="py-12 text-center text-gray-400"
>
Keine Gruppen in diesem Arrangement vorhanden.
</div>
</div>
<!-- Copyright Footer -->
<div
v-if="song.copyright_text || song.ccli_id"
class="border-t border-gray-200 px-6 py-3 text-center text-xs text-gray-400"
>
<span v-if="song.copyright_text">&copy; {{ song.copyright_text }}</span>
<span v-if="song.copyright_text && song.ccli_id"> &middot; </span>
<span v-if="song.ccli_id">CCLI {{ song.ccli_id }}</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import Modal from '@/Components/Modal.vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
songId: {
type: Number,
default: null,
},
arrangementId: {
type: Number,
default: null,
},
useTranslation: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const loading = ref(false)
const error = ref(null)
const previewData = ref(null)
watch(
() => props.show,
async (isVisible) => {
if (!isVisible || !props.songId || !props.arrangementId) {
return
}
loading.value = true
error.value = null
try {
const response = await fetch(`/songs/${props.songId}/arrangements/${props.arrangementId}/preview`)
if (!response.ok) {
throw new Error('Vorschau konnte nicht geladen werden.')
}
previewData.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
},
)
const close = () => {
emit('close')
}
const contrastColor = (hexColor) => {
if (!hexColor) return '#ffffff'
const hex = hexColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? '#1a1a1a' : '#ffffff'
}
</script>
<template>
<Modal
:show="show"
max-width="2xl"
@close="close"
>
<div class="p-6">
<!-- Loading -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<svg
class="h-8 w-8 animate-spin text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="ml-3 text-gray-600">Vorschau wird geladen…</span>
</div>
<!-- Error -->
<div
v-else-if="error"
class="py-12 text-center"
>
<p class="text-red-600">{{ error }}</p>
<button
data-testid="song-preview-modal-error-close-button"
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"
>
Schließen
</button>
</div>
<!-- Preview Content -->
<div v-else-if="previewData">
<!-- Header -->
<div class="mb-5 border-b-2 border-gray-300 pb-4 text-center">
<h2 class="text-2xl font-bold text-gray-900">
{{ previewData.song.title }}
</h2>
<p class="mt-1 text-sm text-gray-500">
Arrangement: {{ previewData.arrangement.name }}
</p>
</div>
<!-- Groups -->
<div
v-for="(group, groupIndex) in previewData.groups"
:key="groupIndex"
class="mb-5"
>
<div
class="mb-2 rounded px-3 py-1.5 text-sm font-bold"
:style="{
backgroundColor: group.color || '#6b7280',
color: contrastColor(group.color),
}"
>
{{ group.name }}
</div>
<div
v-for="(slide, slideIndex) in group.slides"
:key="slideIndex"
class="mb-3 pl-3"
>
<pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</pre>
<div
v-if="useTranslation && slide.text_content_translated"
class="mt-1 border-l-2 border-gray-300 pl-3"
>
<pre class="whitespace-pre-wrap font-sans text-sm italic leading-relaxed text-gray-500">{{ slide.text_content_translated }}</pre>
</div>
</div>
</div>
<!-- Copyright Footer -->
<div
v-if="previewData.song.copyright_text"
class="mt-6 border-t border-gray-200 pt-3 text-center text-xs text-gray-400"
>
&copy; {{ previewData.song.copyright_text }}
<span v-if="previewData.song.ccli_id">
&middot; CCLI {{ previewData.song.ccli_id }}
</span>
</div>
<!-- Close Button -->
<div class="mt-6 flex justify-end">
<button
data-testid="song-preview-modal-bottom-close-button"
type="button"
class="rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"
>
Schließen
</button>
</div>
</div>
</div>
</Modal>
</template>