- 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)
497 lines
20 KiB
Vue
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>
|