- 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)
417 lines
15 KiB
Vue
417 lines
15 KiB
Vue
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import { router } from '@inertiajs/vue3'
|
|
import axios from 'axios'
|
|
import Vue3Dropzone from '@jaxtheprime/vue3-dropzone'
|
|
import '@jaxtheprime/vue3-dropzone/dist/style.css'
|
|
|
|
const props = defineProps({
|
|
type: {
|
|
type: String,
|
|
required: true,
|
|
validator: (v) => ['information', 'moderation', 'sermon', 'agenda_item'].includes(v),
|
|
},
|
|
serviceId: {
|
|
type: [Number, null],
|
|
default: null,
|
|
},
|
|
agendaItemId: {
|
|
type: Number,
|
|
default: null,
|
|
},
|
|
showExpireDate: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
inline: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(['uploaded'])
|
|
|
|
const files = ref([])
|
|
const dropzoneKey = ref(0)
|
|
const expireDate = ref('')
|
|
const uploading = ref(false)
|
|
const uploadProgress = ref(0)
|
|
const uploadError = ref('')
|
|
const uploadWarnings = ref([])
|
|
const uploadedCount = ref(0)
|
|
const totalCount = ref(0)
|
|
|
|
const acceptedTypes = [
|
|
'image/png',
|
|
'image/jpg',
|
|
'image/jpeg',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/zip',
|
|
]
|
|
|
|
const acceptedExtensions = ['.png', '.jpg', '.jpeg', '.ppt', '.pptx', '.zip']
|
|
|
|
const isUploading = computed(() => uploading.value)
|
|
const progressPercent = computed(() => Math.round(uploadProgress.value))
|
|
|
|
watch(files, (newFiles) => {
|
|
if (newFiles.length > 0 && !uploading.value) {
|
|
processFiles()
|
|
}
|
|
}, { deep: true })
|
|
|
|
function processFiles() {
|
|
if (files.value.length === 0 || uploading.value) return
|
|
|
|
uploadError.value = ''
|
|
uploadWarnings.value = []
|
|
totalCount.value = files.value.length
|
|
uploadedCount.value = 0
|
|
uploading.value = true
|
|
uploadProgress.value = 0
|
|
|
|
uploadNextFile(0)
|
|
}
|
|
|
|
function uploadNextFile(index) {
|
|
if (index >= files.value.length) {
|
|
uploading.value = false
|
|
uploadProgress.value = 100
|
|
files.value = []
|
|
dropzoneKey.value++
|
|
emit('uploaded')
|
|
|
|
// Reload Inertia page data to reflect newly uploaded slides
|
|
router.reload({ preserveScroll: true })
|
|
|
|
// Reset progress after brief display
|
|
setTimeout(() => {
|
|
uploadProgress.value = 0
|
|
uploadedCount.value = 0
|
|
totalCount.value = 0
|
|
}, 2000)
|
|
return
|
|
}
|
|
|
|
const file = files.value[index]
|
|
const actualFile = file.file || file // Handle Vue3Dropzone wrapper {file: File, id: number}
|
|
|
|
// Validate extension client-side
|
|
const ext = '.' + actualFile.name.split('.').pop().toLowerCase()
|
|
if (!acceptedExtensions.includes(ext)) {
|
|
uploadError.value = `"${actualFile.name}" — Dateityp nicht erlaubt. Nur PNG, JPG, PPT, PPTX und ZIP.`
|
|
uploading.value = false
|
|
return
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', actualFile)
|
|
formData.append('type', props.type)
|
|
|
|
if (props.serviceId) {
|
|
formData.append('service_id', props.serviceId)
|
|
}
|
|
|
|
if (props.agendaItemId) {
|
|
formData.append('service_agenda_item_id', props.agendaItemId)
|
|
}
|
|
|
|
if (props.showExpireDate && expireDate.value) {
|
|
formData.append('expire_date', expireDate.value)
|
|
}
|
|
|
|
axios.post(route('slides.store'), formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: (event) => {
|
|
if (event.total) {
|
|
const percent = Math.round((event.loaded / event.total) * 100)
|
|
const perFileWeight = 100 / totalCount.value
|
|
uploadProgress.value = (index * perFileWeight) + (percent / 100 * perFileWeight)
|
|
}
|
|
},
|
|
}).then((response) => {
|
|
if (response.data?.warnings?.length) {
|
|
uploadWarnings.value.push(...response.data.warnings)
|
|
}
|
|
uploadedCount.value = index + 1
|
|
uploadNextFile(index + 1)
|
|
}).catch((error) => {
|
|
uploading.value = false
|
|
const errors = error.response?.data?.errors
|
|
uploadError.value = errors?.file?.[0] || error.response?.data?.message || 'Upload fehlgeschlagen.'
|
|
})
|
|
}
|
|
|
|
function dismissError() {
|
|
uploadError.value = ''
|
|
}
|
|
|
|
function dismissWarnings() {
|
|
uploadWarnings.value = []
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div data-testid="slide-uploader" :class="['slide-uploader', { 'slide-uploader--inline': inline }]">
|
|
<!-- Expire date picker for information slides (full mode only) -->
|
|
<div
|
|
v-if="showExpireDate && !inline"
|
|
class="mb-4 flex items-center gap-3"
|
|
>
|
|
<label class="text-sm font-medium text-gray-600">
|
|
Ablaufdatum für neue Folien
|
|
</label>
|
|
<input
|
|
data-testid="slide-uploader-expire-input"
|
|
v-model="expireDate"
|
|
type="date"
|
|
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm transition focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<Transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="opacity-0 -translate-y-1"
|
|
enter-to-class="opacity-100 translate-y-0"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="opacity-100 translate-y-0"
|
|
leave-to-class="opacity-0 -translate-y-1"
|
|
>
|
|
<div
|
|
v-if="uploadError"
|
|
:class="[
|
|
'flex items-center gap-3 rounded-xl border border-red-200 bg-red-50/80 text-sm text-red-700',
|
|
inline ? 'px-2 py-1.5 mb-2' : 'px-4 py-3 mb-4'
|
|
]"
|
|
>
|
|
<svg class="h-4 w-4 shrink-0 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
|
</svg>
|
|
<span class="flex-1 text-xs">{{ uploadError }}</span>
|
|
<button
|
|
data-testid="slide-uploader-error-dismiss"
|
|
@click="dismissError"
|
|
class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-600"
|
|
>
|
|
<svg class="h-3.5 w-3.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>
|
|
</Transition>
|
|
|
|
<!-- Dimension warnings -->
|
|
<Transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="opacity-0 -translate-y-1"
|
|
enter-to-class="opacity-100 translate-y-0"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="opacity-100 translate-y-0"
|
|
leave-to-class="opacity-0 -translate-y-1"
|
|
>
|
|
<div
|
|
v-if="uploadWarnings.length > 0"
|
|
:class="[
|
|
'flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50/80 text-sm text-amber-800',
|
|
inline ? 'px-2 py-1.5 mb-2' : 'px-4 py-3 mb-4'
|
|
]"
|
|
data-testid="slide-uploader-warnings"
|
|
>
|
|
<svg class="h-4 w-4 shrink-0 mt-0.5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
</svg>
|
|
<div class="flex-1">
|
|
<p v-for="(warning, i) in [...new Set(uploadWarnings)]" :key="i" class="text-xs leading-relaxed" :class="{ 'mt-1': i > 0 }">
|
|
{{ warning }}
|
|
</p>
|
|
</div>
|
|
<button
|
|
data-testid="slide-uploader-warnings-dismiss"
|
|
@click="dismissWarnings"
|
|
class="shrink-0 rounded-lg p-0.5 text-amber-500 transition hover:text-amber-700"
|
|
>
|
|
<svg class="h-3.5 w-3.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>
|
|
</Transition>
|
|
|
|
<!-- Upload progress bar -->
|
|
<Transition
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="opacity-0 scale-95"
|
|
enter-to-class="opacity-100 scale-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="opacity-100 scale-100"
|
|
leave-to-class="opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-if="isUploading || (uploadProgress > 0 && uploadProgress <= 100)"
|
|
:class="inline ? 'mb-2' : 'mb-4'"
|
|
>
|
|
<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">
|
|
<span class="font-medium">
|
|
Hochladen... {{ uploadedCount }}/{{ totalCount }}
|
|
</span>
|
|
<span class="tabular-nums">{{ progressPercent }}%</span>
|
|
</div>
|
|
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
|
<div
|
|
class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
|
|
:style="{ width: `${progressPercent}%` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Inline expire date (compact, inside the card) -->
|
|
<div
|
|
v-if="showExpireDate && inline"
|
|
class="mb-2 flex items-center gap-2"
|
|
>
|
|
<svg class="h-3.5 w-3.5 shrink-0 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
|
</svg>
|
|
<input
|
|
data-testid="slide-uploader-expire-input"
|
|
v-model="expireDate"
|
|
type="date"
|
|
class="w-full rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 shadow-sm transition focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400/40"
|
|
placeholder="Ablaufdatum"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Dropzone -->
|
|
<Vue3Dropzone
|
|
:key="dropzoneKey"
|
|
data-testid="slide-uploader-dropzone"
|
|
v-model="files"
|
|
:multiple="true"
|
|
:accept="acceptedTypes"
|
|
:max-file-size="50"
|
|
:max-files="20"
|
|
:disabled="isUploading"
|
|
:show-select-button="false"
|
|
:class="['slide-dropzone', { 'slide-dropzone--inline': inline }]"
|
|
>
|
|
<template #placeholder-img>
|
|
<div class="flex flex-col items-center gap-2 pointer-events-none">
|
|
<div class="relative">
|
|
<div :class="[
|
|
'flex items-center justify-center rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 shadow-sm ring-1 ring-amber-200/60',
|
|
inline ? 'h-12 w-12' : 'h-16 w-16'
|
|
]">
|
|
<svg :class="[inline ? 'h-6 w-6' : 'h-8 w-8', 'text-amber-500']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
</div>
|
|
<!-- Animated pulse ring (full mode only) -->
|
|
<div v-if="!inline" class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #title>
|
|
<span :class="[inline ? 'mt-1.5 text-xs' : 'mt-3 text-sm', 'block font-semibold text-gray-700']">
|
|
{{ inline ? 'Folien hinzufügen' : 'Dateien hier ablegen' }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #description>
|
|
<span :class="[inline ? 'mt-0.5 text-[10px]' : 'mt-1 text-xs', 'block text-gray-400']">
|
|
oder klicken zum Auswählen
|
|
</span>
|
|
<span v-if="!inline" class="mt-2 block text-[11px] text-gray-300">
|
|
PNG, JPG, PPT, PPTX, ZIP — max 50 MB
|
|
</span>
|
|
</template>
|
|
</Vue3Dropzone>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.slide-dropzone {
|
|
--v3-dropzone--primary: 245, 158, 11;
|
|
--v3-dropzone--border: 229, 231, 235;
|
|
--v3-dropzone--description: 156, 163, 175;
|
|
--v3-dropzone--overlay: 245, 158, 11;
|
|
--v3-dropzone--overlay-opacity: 0.08;
|
|
}
|
|
|
|
.slide-dropzone :deep(.v3-dropzone) {
|
|
border: 2px dashed rgb(229 231 235);
|
|
border-radius: 1rem;
|
|
background: linear-gradient(135deg, rgb(255 251 235 / 0.5), rgb(254 243 199 / 0.3));
|
|
padding: 1.5rem 1rem;
|
|
min-height: 120px;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.slide-dropzone :deep(.v3-dropzone:hover) {
|
|
border-color: rgb(251 191 36);
|
|
background: linear-gradient(135deg, rgb(255 251 235 / 0.8), rgb(254 243 199 / 0.5));
|
|
box-shadow: 0 0 0 4px rgb(251 191 36 / 0.1);
|
|
}
|
|
|
|
.slide-dropzone :deep(.v3-dropzone.v3-dropzone--active) {
|
|
border-color: rgb(245 158 11);
|
|
background: linear-gradient(135deg, rgb(255 251 235), rgb(254 243 199 / 0.7));
|
|
box-shadow: 0 0 0 4px rgb(245 158 11 / 0.15);
|
|
transform: scale(1.01);
|
|
}
|
|
|
|
.slide-dropzone :deep(.v3-dropzone--disabled) {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Hide the default preview since we handle it in SlideGrid */
|
|
.slide-dropzone :deep(.v3-dropzone__preview) {
|
|
display: none;
|
|
}
|
|
|
|
/* Inline mode: compact card-sized dropzone */
|
|
.slide-dropzone--inline :deep(.v3-dropzone) {
|
|
border: none;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
padding: 0.5rem;
|
|
min-height: 0;
|
|
box-shadow: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
}
|
|
|
|
.slide-dropzone--inline :deep(.v3-dropzone:hover) {
|
|
border: none;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.slide-dropzone--inline :deep(.v3-dropzone.v3-dropzone--active) {
|
|
border: none;
|
|
background: rgb(255 251 235 / 0.6);
|
|
box-shadow: none;
|
|
transform: none;
|
|
}
|
|
|
|
/* Inline uploader fills its parent card */
|
|
.slide-uploader--inline {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.slide-uploader--inline .slide-dropzone--inline {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
</style>
|