pp-planer/resources/js/Components/AgendaItemRow.vue
Thorsten Bus 0e3c647cfc feat: probundle export with media, image upscaling, upload dimension warnings
- Fix probundle exports missing images (double slides/ prefix in storage path)
- Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin
- Add per-agenda-item download route and buttons for songs and slide items
- Remove text layer from image-only slides in .pro generation
- Fix image conversion: upscale small images, black bars on 2 sides max (contain)
- Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking)
- Update SlideFactory and all tests to use slides/ prefix in stored_filename
- Add 11 new tests (agenda download, image conversion, upload warnings)
2026-03-30 10:29:37 +02:00

497 lines
20 KiB
Vue

<script setup>
import { computed, ref } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({
agendaItem: { type: Object, required: true },
serviceId: { type: Number, required: true },
serviceDate: { type: String, default: null },
})
const emit = defineEmits(['slides-updated', 'scroll-to-info'])
const showUploader = ref(false)
const downloading = ref(false)
async function downloadBundle() {
downloading.value = true
try {
const response = await fetch(
route('services.agenda-item.download', {
service: props.serviceId,
agendaItem: props.agendaItem.id,
}),
{
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
},
)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
alert(data.message || 'Fehler beim Herunterladen.')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'element.probundle'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
alert('Fehler beim Herunterladen.')
} finally {
downloading.value = false
}
}
// Preview state
const previewSlide = ref(null)
const previewIndex = ref(-1)
// Delete confirmation state
const confirmingDelete = ref(false)
const slideToDelete = ref(null)
const deleting = ref(false)
// Hover preview state
const hoverSlide = ref(null)
const hoverPosition = ref({ x: 0, y: 0 })
const allSlides = computed(() => props.agendaItem.slides ?? [])
const responsibleNames = computed(() => {
const r = props.agendaItem.responsible
if (!r) return ''
if (r.persons && Array.isArray(r.persons)) {
const names = r.persons
.map((p) => p.person?.title || p.name || '')
.filter(Boolean)
if (names.length > 0) return [...new Set(names)].join(', ')
}
if (r.text) return r.text
if (Array.isArray(r)) {
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
}
return ''
})
const formattedStart = computed(() => {
const s = props.agendaItem.start
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' })
})
const formattedDuration = computed(() => {
const dur = props.agendaItem.duration
if (!dur || dur === '0') return ''
const totalSeconds = parseInt(dur, 10)
if (isNaN(totalSeconds) || totalSeconds <= 0) return dur
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}` : `0:${String(minutes).padStart(2, '0')}`
})
const rowBgClass = computed(() => {
if (props.agendaItem.is_announcement_position) return 'bg-blue-50'
if (props.agendaItem.is_sermon) return 'bg-purple-50'
if (props.agendaItem.slides?.length > 0) return 'bg-emerald-50'
return ''
})
const borderClass = computed(() => {
if (props.agendaItem.is_announcement_position) return 'border-l-4 border-l-blue-400'
if (props.agendaItem.is_sermon) return 'border-l-4 border-l-purple-400'
if (props.agendaItem.slides?.length > 0) return 'border-l-4 border-l-emerald-500'
return ''
})
function thumbnailUrl(slide) {
if (!slide.thumbnail_filename) return null
return `/storage/${slide.thumbnail_filename}`
}
function fullImageUrl(slide) {
if (!slide.stored_filename) return null
return `/storage/${slide.stored_filename}`
}
function onUploaded() {
emit('slides-updated')
}
// Preview with navigation
function openPreview(slide) {
previewSlide.value = slide
previewIndex.value = allSlides.value.findIndex((s) => s.id === slide.id)
}
function closePreview() {
previewSlide.value = null
previewIndex.value = -1
}
function prevPreview() {
if (previewIndex.value <= 0) return
previewIndex.value--
previewSlide.value = allSlides.value[previewIndex.value]
}
function nextPreview() {
if (previewIndex.value >= allSlides.value.length - 1) return
previewIndex.value++
previewSlide.value = allSlides.value[previewIndex.value]
}
const hasPrev = computed(() => previewIndex.value > 0)
const hasNext = computed(() => previewIndex.value < allSlides.value.length - 1)
// Delete
function promptDelete(slide, event) {
event.stopPropagation()
slideToDelete.value = slide
confirmingDelete.value = true
}
function cancelDelete() {
confirmingDelete.value = false
slideToDelete.value = null
}
function confirmDelete() {
if (!slideToDelete.value) return
deleting.value = true
const deletedId = slideToDelete.value.id
axios.delete(route('slides.destroy', deletedId))
.then(() => {
confirmingDelete.value = false
slideToDelete.value = null
deleting.value = false
if (previewSlide.value?.id === deletedId) {
previewSlide.value = null
previewIndex.value = -1
}
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deleting.value = false
})
}
// Hover preview
function onThumbMouseEnter(slide, event) {
const rect = event.target.getBoundingClientRect()
hoverPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top,
}
hoverSlide.value = slide
}
function onThumbMouseLeave() {
hoverSlide.value = null
}
// Scroll to info block
function scrollToInfo() {
emit('scroll-to-info')
}
</script>
<template>
<!-- Main row -->
<tr
class="border-b border-gray-100"
:class="rowBgClass || 'hover:bg-gray-50'"
data-testid="agenda-item-row"
>
<!-- Nr -->
<td class="py-2.5 pr-3 align-top" :class="borderClass">
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.position || agendaItem.sort_order }}</span>
</td>
<!-- Zeit -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedStart" class="text-xs text-gray-500 tabular-nums">{{ formattedStart }}</span>
</td>
<!-- Dauer -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedDuration" class="text-xs text-gray-500 tabular-nums">{{ formattedDuration }}</span>
</td>
<!-- Titel -->
<td class="py-2.5 pr-3 align-top">
<span class="font-medium text-gray-900" data-testid="agenda-item-title">{{ agendaItem.title }}</span>
<!-- Badges inline -->
<span
v-if="agendaItem.is_announcement_position"
class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700"
>
Ankündigungen
</span>
<span
v-if="agendaItem.is_sermon"
class="ml-2 inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-700"
>
Predigt
</span>
<!-- Note -->
<p v-if="agendaItem.note" class="mt-0.5 text-xs text-gray-400">
{{ agendaItem.note }}
</p>
<!-- Slide thumbnails -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
<div
v-for="slide in agendaItem.slides.slice(0, 4)"
:key="slide.id"
class="group/thumb relative"
>
<img
:src="thumbnailUrl(slide)"
class="h-8 w-14 cursor-pointer rounded border border-gray-200 object-cover transition-all hover:border-amber-400 hover:ring-1 hover:ring-amber-400/40"
:alt="slide.original_filename"
data-testid="agenda-slide-thumbnail"
@click="openPreview(slide)"
@mouseenter="onThumbMouseEnter(slide, $event)"
@mouseleave="onThumbMouseLeave"
/>
<!-- Delete X button -->
<button
data-testid="agenda-slide-delete-btn"
class="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white shadow-sm transition hover:bg-red-600 group-hover/thumb:flex"
title="Folie entfernen"
@click="promptDelete(slide, $event)"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<span
v-if="agendaItem.slides.length > 4"
class="flex h-8 w-14 items-center justify-center rounded border border-gray-200 text-[10px] text-gray-500"
>
+{{ agendaItem.slides.length - 4 }}
</span>
</div>
</td>
<!-- Verantwortlich -->
<td class="py-2.5 pr-3 align-top">
<span v-if="responsibleNames" class="text-xs text-gray-500">{{ responsibleNames }}</span>
</td>
<!-- Aktionen -->
<td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1">
<!-- Download bundle -->
<button
v-if="agendaItem.slides?.length"
type="button"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-emerald-100 hover:text-emerald-600 disabled:opacity-50"
data-testid="agenda-item-download-bundle"
title="Als .probundle herunterladen"
:disabled="downloading"
@click="downloadBundle"
>
<svg v-if="downloading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else 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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<!-- Announcement row: scroll to info block instead of + button -->
<button
v-if="agendaItem.is_announcement_position"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
data-testid="agenda-item-scroll-to-info"
title="Zu Info-Folien springen"
@click="scrollToInfo"
>
<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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
</button>
<!-- Normal rows: + / x toggle for uploader -->
<button
v-else
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-500"
data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@click="showUploader = !showUploader"
>
<svg v-if="!showUploader" 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 4.5v15m7.5-7.5h-15" />
</svg>
<svg v-else 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</td>
</tr>
<!-- Uploader row (spanning all columns) — not for announcement rows -->
<tr v-if="showUploader && !agendaItem.is_announcement_position">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader
type="agenda_item"
:service-id="serviceId"
:agenda-item-id="agendaItem.id"
:show-expire-date="false"
:inline="true"
@uploaded="onUploaded"
/>
</td>
</tr>
<!-- Hover preview tooltip (doubled size: w-[512px]) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="hoverSlide"
class="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full"
:style="{ left: hoverPosition.x + 'px', top: (hoverPosition.y - 8) + 'px' }"
>
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<img
:src="thumbnailUrl(hoverSlide)"
:alt="hoverSlide.original_filename"
class="h-auto w-[512px] object-contain"
/>
</div>
</div>
</Transition>
</Teleport>
<!-- Full preview overlay with prev/next navigation -->
<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="previewSlide"
data-testid="agenda-slide-preview"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm"
@click.self="closePreview"
>
<!-- Image + controls -->
<div class="relative max-h-[90vh] max-w-[90vw]">
<img
:src="fullImageUrl(previewSlide)"
:alt="previewSlide.original_filename"
class="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
/>
<!-- Previous arrow (left edge of image) -->
<button
v-if="hasPrev"
data-testid="agenda-slide-preview-prev"
class="absolute left-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Vorherige Folie"
@click="prevPreview"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<!-- Next arrow (right edge of image) -->
<button
v-if="hasNext"
data-testid="agenda-slide-preview-next"
class="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-lg transition hover:bg-black/70"
title="Nächste Folie"
@click="nextPreview"
>
<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="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<!-- Slide counter -->
<div
v-if="allSlides.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white tabular-nums"
>
{{ previewIndex + 1 }} / {{ allSlides.length }}
</div>
<div class="absolute -top-3 -right-3 flex gap-2">
<!-- Download button -->
<a
:href="fullImageUrl(previewSlide)"
:download="previewSlide.original_filename"
class="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg transition hover:bg-emerald-700"
title="Herunterladen"
data-testid="agenda-slide-preview-download"
>
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</a>
<!-- Close button -->
<button
@click="closePreview"
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-800 text-white shadow-lg transition hover:bg-gray-700"
title="Schließen"
data-testid="agenda-slide-preview-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>
</div>
</Transition>
</Teleport>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="confirmingDelete"
title="Folie löschen?"
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
confirm-label="Löschen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</template>