feat(ui): redesign slide grid with larger previews and add collapsible JSON log viewer

- Slide grid now 1-2-3 columns (4x larger thumbnails)
- Delete button left, fullscreen right, always visible (no hover)
- Upload area appears inline as last grid item (no side-by-side layout)
- SlideUploader supports inline mode for grid integration
- Add recursive collapsible JsonTreeViewer component (no external deps)
- Replace raw JSON pre tags in API logs with tree viewer
This commit is contained in:
Thorsten Bus 2026-03-02 14:10:50 +01:00
parent a36841f920
commit 32e9577d4d
7 changed files with 341 additions and 92 deletions

View file

@ -101,31 +101,27 @@ function handleSlideUpdated() {
</div> </div>
</div> </div>
<!-- Side-by-side: grid left (~70%), uploader right (~30%) --> <!-- Slide grid with inline upload card -->
<div class="flex flex-col lg:flex-row-reverse gap-6">
<!-- Slide uploader information slides are GLOBAL (service_id = null) -->
<div class="lg:w-1/3">
<SlideUploader
data-testid="information-block-uploader"
type="information"
:service-id="null"
:show-expire-date="true"
@uploaded="handleSlideUploaded"
/>
</div>
<!-- Slide grid with prominent expire dates -->
<div class="flex-1 lg:w-2/3">
<SlideGrid <SlideGrid
data-testid="information-block-grid" data-testid="information-block-grid"
:slides="informationSlides" :slides="informationSlides"
type="information" type="information"
:show-expire-date="true" :show-expire-date="true"
:show-uploader="true"
@deleted="handleSlideDeleted" @deleted="handleSlideDeleted"
@updated="handleSlideUpdated" @updated="handleSlideUpdated"
>
<template #upload-card>
<SlideUploader
data-testid="information-block-uploader"
type="information"
:service-id="null"
:show-expire-date="true"
:inline="true"
@uploaded="handleSlideUploaded"
/> />
</div> </template>
</div> </SlideGrid>
</div> </div>
</template> </template>

View file

@ -48,31 +48,27 @@ function handleSlideUpdated() {
</p> </p>
</div> </div>
<!-- Side-by-side: grid left (~70%), uploader right (~30%) --> <!-- Slide grid with inline upload card -->
<div class="flex flex-col lg:flex-row-reverse gap-6">
<!-- Slide uploader -->
<div class="lg:w-1/3">
<SlideUploader
data-testid="moderation-block-uploader"
type="moderation"
:service-id="serviceId"
:show-expire-date="false"
@uploaded="handleSlideUploaded"
/>
</div>
<!-- Slide grid -->
<div class="flex-1 lg:w-2/3">
<SlideGrid <SlideGrid
data-testid="moderation-block-grid" data-testid="moderation-block-grid"
:slides="moderationSlides" :slides="moderationSlides"
type="moderation" type="moderation"
:show-expire-date="false" :show-expire-date="false"
:show-uploader="true"
@deleted="handleSlideDeleted" @deleted="handleSlideDeleted"
@updated="handleSlideUpdated" @updated="handleSlideUpdated"
>
<template #upload-card>
<SlideUploader
data-testid="moderation-block-uploader"
type="moderation"
:service-id="serviceId"
:show-expire-date="false"
:inline="true"
@uploaded="handleSlideUploaded"
/> />
</div> </template>
</div> </SlideGrid>
</div> </div>
</template> </template>

View file

@ -48,31 +48,27 @@ function handleSlideUpdated() {
</p> </p>
</div> </div>
<!-- Side-by-side: grid left (~70%), uploader right (~30%) --> <!-- Slide grid with inline upload card -->
<div class="flex flex-col lg:flex-row-reverse gap-6">
<!-- Slide uploader -->
<div class="lg:w-1/3">
<SlideUploader
data-testid="sermon-block-uploader"
type="sermon"
:service-id="serviceId"
:show-expire-date="false"
@uploaded="handleSlideUploaded"
/>
</div>
<!-- Slide grid -->
<div class="flex-1 lg:w-2/3">
<SlideGrid <SlideGrid
data-testid="sermon-block-grid" data-testid="sermon-block-grid"
:slides="sermonSlides" :slides="sermonSlides"
type="sermon" type="sermon"
:show-expire-date="false" :show-expire-date="false"
:show-uploader="true"
@deleted="handleSlideDeleted" @deleted="handleSlideDeleted"
@updated="handleSlideUpdated" @updated="handleSlideUpdated"
>
<template #upload-card>
<SlideUploader
data-testid="sermon-block-uploader"
type="sermon"
:service-id="serviceId"
:show-expire-date="false"
:inline="true"
@uploaded="handleSlideUploaded"
/> />
</div> </template>
</div> </SlideGrid>
</div> </div>
</template> </template>

View file

@ -0,0 +1,157 @@
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: { type: [Object, Array, String, Number, Boolean, null], default: null },
expandDepth: { type: Number, default: 2 },
label: { type: String, default: '' },
depth: { type: Number, default: 0 },
isRoot: { type: Boolean, default: true },
})
const expanded = ref(props.depth < props.expandDepth)
const dataType = computed(() => {
if (props.data === null || props.data === undefined) return 'null'
if (Array.isArray(props.data)) return 'array'
return typeof props.data
})
const isExpandable = computed(() => {
return dataType.value === 'object' || dataType.value === 'array'
})
const entries = computed(() => {
if (!isExpandable.value || !props.data) return []
return Object.entries(props.data)
})
const previewText = computed(() => {
if (dataType.value === 'array') return `Array[${props.data.length}]`
if (dataType.value === 'object') {
const keys = Object.keys(props.data)
return `{${keys.length}}`
}
return ''
})
function formatValue(val) {
if (val === null || val === undefined) return 'null'
if (typeof val === 'string') return `"${val}"`
if (typeof val === 'boolean') return val ? 'true' : 'false'
return String(val)
}
function valueClass(val) {
if (val === null || val === undefined) return 'text-gray-400 italic'
if (typeof val === 'string') return 'text-emerald-700'
if (typeof val === 'number') return 'text-blue-700'
if (typeof val === 'boolean') return 'text-amber-700'
return 'text-gray-700'
}
function toggle() {
if (isExpandable.value) {
expanded.value = !expanded.value
}
}
function copyToClipboard() {
const text = JSON.stringify(props.data, null, 2)
navigator.clipboard.writeText(text).catch(() => {})
}
</script>
<template>
<div :class="['json-tree', { 'json-tree--root': isRoot }]">
<!-- Root-level copy button -->
<div v-if="isRoot && data && typeof data === 'object'" class="flex items-center justify-end mb-1.5">
<button
@click="copyToClipboard"
class="flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-medium text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
title="JSON kopieren"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
Kopieren
</button>
</div>
<!-- String data (not object/array) at root level -->
<pre v-if="isRoot && typeof data === 'string'" class="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{{ data }}</pre>
<!-- Null/empty at root level -->
<span v-else-if="isRoot && (data === null || data === undefined)" class="text-sm italic text-gray-400">Keine Daten</span>
<!-- Primitive at root level -->
<span v-else-if="isRoot && !isExpandable" :class="['text-xs font-mono', valueClass(data)]">{{ formatValue(data) }}</span>
<!-- Tree view for objects/arrays -->
<div v-else-if="isExpandable" class="font-mono text-xs leading-relaxed">
<!-- Expandable node header -->
<div
class="json-node-header group flex items-center gap-1 cursor-pointer rounded px-1 -mx-1 transition-colors hover:bg-gray-50"
@click="toggle"
>
<!-- Triangle -->
<svg
class="h-3 w-3 shrink-0 text-gray-400 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
<!-- Key label -->
<span v-if="label" class="text-purple-700 font-medium">{{ label }}</span>
<span v-if="label" class="text-gray-400">:</span>
<!-- Collapsed preview -->
<span class="text-gray-400">
{{ dataType === 'array' ? '[' : '{' }}
</span>
<span v-if="!expanded" class="text-gray-400">
{{ previewText }}
{{ dataType === 'array' ? ']' : '}' }}
</span>
</div>
<!-- Children -->
<div v-if="expanded" class="ml-4 border-l border-gray-200 pl-3">
<template v-for="([key, val], idx) in entries" :key="key">
<!-- Nested expandable -->
<JsonTreeViewer
v-if="val !== null && typeof val === 'object'"
:data="val"
:expand-depth="expandDepth"
:label="key"
:depth="depth + 1"
:is-root="false"
/>
<!-- Leaf value -->
<div v-else class="flex items-center gap-1 py-0.5 px-1 -mx-1 rounded transition-colors hover:bg-gray-50">
<span class="h-3 w-3 shrink-0" />
<span class="text-purple-700 font-medium">{{ key }}</span>
<span class="text-gray-400">:</span>
<span :class="valueClass(val)">{{ formatValue(val) }}</span>
</div>
</template>
</div>
<!-- Closing bracket -->
<div v-if="expanded" class="text-gray-400 px-1 -mx-1">
<span class="ml-0">{{ dataType === 'array' ? ']' : '}' }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.json-tree--root {
padding: 8px;
border-radius: 8px;
background: rgb(249 250 251 / 0.5);
}
</style>

View file

@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showUploader: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['deleted', 'updated']) const emit = defineEmits(['deleted', 'updated'])
@ -141,9 +145,9 @@ function isExpired(expireDate) {
<template> <template>
<div data-testid="slide-grid" class="slide-grid"> <div data-testid="slide-grid" class="slide-grid">
<!-- Empty state --> <!-- Empty state (only when no slides AND no uploader) -->
<div <div
v-if="sortedSlides.length === 0" v-if="sortedSlides.length === 0 && !showUploader"
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50/50 py-10" class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50/50 py-10"
> >
<svg class="h-10 w-10 text-gray-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"> <svg class="h-10 w-10 text-gray-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
@ -154,13 +158,13 @@ function isExpired(expireDate) {
<!-- Grid of thumbnails --> <!-- Grid of thumbnails -->
<div <div
v-else v-if="sortedSlides.length > 0 || showUploader"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"
> >
<div <div
v-for="slide in sortedSlides" v-for="slide in sortedSlides"
:key="slide.id" :key="slide.id"
class="slide-card group relative overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-all duration-300 hover:shadow-md hover:border-gray-300 hover:-translate-y-0.5" class="slide-card group relative overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-all duration-300 hover:shadow-lg hover:border-gray-300 hover:-translate-y-0.5"
> >
<!-- PPT processing indicator --> <!-- PPT processing indicator -->
<div <div
@ -184,52 +188,52 @@ function isExpired(expireDate) {
v-else v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900" class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900"
> >
<svg class="h-8 w-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"> <svg class="h-10 w-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg> </svg>
</div> </div>
<!-- Delete button overlay --> <!-- Delete button LEFT side, always visible -->
<button <button
data-testid="slide-grid-delete-button" data-testid="slide-grid-delete-button"
@click.stop="promptDelete(slide)" @click.stop="promptDelete(slide)"
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:text-white" class="absolute left-2.5 top-2.5 flex h-8 w-8 items-center justify-center rounded-lg bg-black/60 text-white shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:scale-110"
title="Löschen" title="Löschen"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg> </svg>
</button> </button>
<!-- Full image link overlay --> <!-- Fullscreen button RIGHT side, always visible -->
<a <a
data-testid="slide-grid-fullimage-link" data-testid="slide-grid-fullimage-link"
v-if="fullImageUrl(slide)" v-if="fullImageUrl(slide)"
:href="fullImageUrl(slide)" :href="fullImageUrl(slide)"
target="_blank" target="_blank"
class="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-white/90 hover:text-gray-800 group-hover:opacity-100" class="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center rounded-lg bg-black/60 text-white shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-white/90 hover:text-gray-800 hover:scale-110"
title="Vollbild öffnen" title="Vollbild öffnen"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <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="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg> </svg>
</a> </a>
</div> </div>
<!-- Metadata --> <!-- Metadata -->
<div class="px-3 py-2.5"> <div class="px-3.5 py-3">
<!-- Upload info (muted) --> <!-- Upload info (muted) -->
<div class="flex items-center gap-1.5 text-[11px] text-gray-400"> <div class="flex items-center gap-1.5 text-xs text-gray-400">
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span> <span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span>
</div> </div>
<div <div
v-if="slide.uploader_name" v-if="slide.uploader_name"
class="mt-0.5 flex items-center gap-1.5 text-[11px] text-gray-400" class="mt-0.5 flex items-center gap-1.5 text-xs text-gray-400"
> >
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
</svg> </svg>
<span class="truncate">{{ slide.uploader_name }}</span> <span class="truncate">{{ slide.uploader_name }}</span>
@ -317,6 +321,24 @@ function isExpired(expireDate) {
</div> </div>
</div> </div>
</div> </div>
<!-- Inline upload card (last grid item) -->
<div
v-if="showUploader"
class="slide-upload-card relative overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gradient-to-br from-gray-50/80 to-amber-50/40 transition-all duration-300 hover:border-amber-400 hover:shadow-md hover:from-amber-50/60 hover:to-orange-50/40"
>
<div class="aspect-video flex items-center justify-center">
<slot name="upload-card">
<!-- Fallback if no slot content -->
<div class="flex flex-col items-center gap-2 text-gray-400">
<svg class="h-10 w-10" 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>
<span class="text-sm font-medium">Folien hinzufügen</span>
</div>
</slot>
</div>
</div>
</div> </div>
<!-- Delete confirmation dialog --> <!-- Delete confirmation dialog -->
@ -343,4 +365,8 @@ function isExpired(expireDate) {
.slide-card img { .slide-card img {
image-rendering: auto; image-rendering: auto;
} }
.slide-upload-card {
min-height: 0;
}
</style> </style>

View file

@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
inline: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['uploaded']) const emit = defineEmits(['uploaded'])
@ -132,10 +136,10 @@ function dismissError() {
</script> </script>
<template> <template>
<div data-testid="slide-uploader" class="slide-uploader"> <div data-testid="slide-uploader" :class="['slide-uploader', { 'slide-uploader--inline': inline }]">
<!-- Expire date picker for information slides --> <!-- Expire date picker for information slides (full mode only) -->
<div <div
v-if="showExpireDate" v-if="showExpireDate && !inline"
class="mb-4 flex items-center gap-3" class="mb-4 flex items-center gap-3"
> >
<label class="text-sm font-medium text-gray-600"> <label class="text-sm font-medium text-gray-600">
@ -160,18 +164,21 @@ function dismissError() {
> >
<div <div
v-if="uploadError" v-if="uploadError"
class="mb-4 flex items-center gap-3 rounded-xl border border-red-200 bg-red-50/80 px-4 py-3 text-sm text-red-700" :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-5 w-5 shrink-0 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <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" /> <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> </svg>
<span class="flex-1">{{ uploadError }}</span> <span class="flex-1 text-xs">{{ uploadError }}</span>
<button <button
data-testid="slide-uploader-error-dismiss" data-testid="slide-uploader-error-dismiss"
@click="dismissError" @click="dismissError"
class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-600" class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-600"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@ -189,7 +196,7 @@ function dismissError() {
> >
<div <div
v-if="isUploading || (uploadProgress > 0 && uploadProgress <= 100)" v-if="isUploading || (uploadProgress > 0 && uploadProgress <= 100)"
class="mb-4" :class="inline ? 'mb-2' : 'mb-4'"
> >
<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5"> <div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">
<span class="font-medium"> <span class="font-medium">
@ -206,6 +213,23 @@ function dismissError() {
</div> </div>
</Transition> </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 --> <!-- Dropzone -->
<Vue3Dropzone <Vue3Dropzone
:key="dropzoneKey" :key="dropzoneKey"
@ -217,35 +241,37 @@ function dismissError() {
:max-files="20" :max-files="20"
:disabled="isUploading" :disabled="isUploading"
:show-select-button="false" :show-select-button="false"
class="slide-dropzone" :class="['slide-dropzone', { 'slide-dropzone--inline': inline }]"
@change="processFiles" @change="processFiles"
> >
<template #placeholder-img> <template #placeholder-img>
<div class="flex flex-col items-center gap-3 pointer-events-none"> <div class="flex flex-col items-center gap-2 pointer-events-none">
<!-- Big plus icon -->
<div class="relative"> <div class="relative">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 shadow-sm ring-1 ring-amber-200/60"> <div :class="[
<svg class="h-8 w-8 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> '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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
</div> </div>
<!-- Animated pulse ring --> <!-- Animated pulse ring (full mode only) -->
<div class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" /> <div v-if="!inline" class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" />
</div> </div>
</div> </div>
</template> </template>
<template #title> <template #title>
<span class="mt-3 block text-sm font-semibold text-gray-700"> <span :class="[inline ? 'mt-1.5 text-xs' : 'mt-3 text-sm', 'block font-semibold text-gray-700']">
Dateien hier ablegen {{ inline ? 'Folien hinzufügen' : 'Dateien hier ablegen' }}
</span> </span>
</template> </template>
<template #description> <template #description>
<span class="mt-1 block text-xs text-gray-400"> <span :class="[inline ? 'mt-0.5 text-[10px]' : 'mt-1 text-xs', 'block text-gray-400']">
oder klicken zum Auswählen oder klicken zum Auswählen
</span> </span>
<span class="mt-2 block text-[11px] text-gray-300"> <span v-if="!inline" class="mt-2 block text-[11px] text-gray-300">
PNG, JPG, PPT, PPTX, ZIP max 50 MB PNG, JPG, PPT, PPTX, ZIP max 50 MB
</span> </span>
</template> </template>
@ -294,4 +320,44 @@ function dismissError() {
.slide-dropzone :deep(.v3-dropzone__preview) { .slide-dropzone :deep(.v3-dropzone__preview) {
display: none; 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> </style>

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import JsonTreeViewer from '@/Components/JsonTreeViewer.vue'
import { Head, Link, router } from '@inertiajs/vue3' import { Head, Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
@ -69,6 +70,13 @@ const expandedId = ref(null)
const detailData = ref({}) const detailData = ref({})
const loadingDetail = ref(null) const loadingDetail = ref(null)
function parseJson(str) {
if (!str) return null
if (typeof str === 'object') return str
try { return JSON.parse(str) }
catch { return str }
}
async function toggleExpanded(id) { async function toggleExpanded(id) {
if (expandedId.value === id) { if (expandedId.value === id) {
expandedId.value = null expandedId.value = null
@ -189,7 +197,9 @@ async function toggleExpanded(id) {
<svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showContext }" 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> <svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showContext }" 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>
Anfrage-Kontext Anfrage-Kontext
</button> </button>
<pre v-if="detailData[log.id]._showContext && detailData[log.id].request_context" class="mt-1 max-h-96 overflow-x-auto overflow-y-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800">{{ JSON.stringify(detailData[log.id].request_context, null, 2) }}</pre> <div v-if="detailData[log.id]._showContext && detailData[log.id].request_context" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3">
<JsonTreeViewer :data="detailData[log.id].request_context" :expand-depth="2" />
</div>
<p v-if="detailData[log.id]._showContext && !detailData[log.id].request_context" class="mt-1 text-sm italic text-gray-400">Kein Kontext verfügbar</p> <p v-if="detailData[log.id]._showContext && !detailData[log.id].request_context" class="mt-1 text-sm italic text-gray-400">Kein Kontext verfügbar</p>
</div> </div>
<div> <div>
@ -200,7 +210,9 @@ async function toggleExpanded(id) {
<svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showResponse }" 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> <svg class="h-3 w-3 transition-transform" :class="{ 'rotate-90': detailData[log.id]._showResponse }" 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>
Antwort-Daten Antwort-Daten
</button> </button>
<pre v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-x-auto overflow-y-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800">{{ detailData[log.id].response_body }}</pre> <div v-if="detailData[log.id]._showResponse && detailData[log.id].response_body" class="mt-1 max-h-96 overflow-y-auto rounded-lg bg-gray-50 p-3">
<JsonTreeViewer :data="parseJson(detailData[log.id].response_body)" :expand-depth="1" />
</div>
<p v-if="detailData[log.id]._showResponse && !detailData[log.id].response_body" class="mt-1 text-sm italic text-gray-400">Keine Antwort-Daten verfügbar</p> <p v-if="detailData[log.id]._showResponse && !detailData[log.id].response_body" class="mt-1 text-sm italic text-gray-400">Keine Antwort-Daten verfügbar</p>
</div> </div>
</div> </div>