Simplify ArrangementConfigurator: replace color pickers with compact pills, add click-to-add from pool, use watcher-based auto-select for new/cloned arrangements, remove group_colors from save payload. Enhance SongsBlock preview: color-coded group headers with tinted backgrounds, PDF download button inside preview modal, .pro download link per matched song, show DB ccli_id with fallback to CTS ccli_id. Fix Modal z-index for nested dialogs. Fix SlideUploader duplicate upload on watch by adding deep option and upload guard. Expand API log detail sections by default and increase JSON tree depth. Convert song download button from emit to direct .pro download link.
363 lines
13 KiB
Vue
363 lines
13 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'].includes(v),
|
|
},
|
|
serviceId: {
|
|
type: [Number, null],
|
|
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 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 = ''
|
|
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.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(() => {
|
|
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 = ''
|
|
}
|
|
</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>
|
|
|
|
<!-- 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>
|