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:
parent
a36841f920
commit
32e9577d4d
|
|
@ -101,31 +101,27 @@ function handleSlideUpdated() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side: grid left (~70%), uploader right (~30%) -->
|
||||
<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">
|
||||
<!-- Slide grid with inline upload card -->
|
||||
<SlideGrid
|
||||
data-testid="information-block-grid"
|
||||
:slides="informationSlides"
|
||||
type="information"
|
||||
:show-expire-date="true"
|
||||
:show-uploader="true"
|
||||
@deleted="handleSlideDeleted"
|
||||
@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>
|
||||
|
||||
<!-- Slide grid with prominent expire dates -->
|
||||
<div class="flex-1 lg:w-2/3">
|
||||
<SlideGrid
|
||||
data-testid="information-block-grid"
|
||||
:slides="informationSlides"
|
||||
type="information"
|
||||
:show-expire-date="true"
|
||||
@deleted="handleSlideDeleted"
|
||||
@updated="handleSlideUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SlideGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,31 +48,27 @@ function handleSlideUpdated() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side: grid left (~70%), uploader right (~30%) -->
|
||||
<div class="flex flex-col lg:flex-row-reverse gap-6">
|
||||
<!-- Slide uploader -->
|
||||
<div class="lg:w-1/3">
|
||||
<!-- Slide grid with inline upload card -->
|
||||
<SlideGrid
|
||||
data-testid="moderation-block-grid"
|
||||
:slides="moderationSlides"
|
||||
type="moderation"
|
||||
:show-expire-date="false"
|
||||
:show-uploader="true"
|
||||
@deleted="handleSlideDeleted"
|
||||
@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>
|
||||
|
||||
<!-- Slide grid -->
|
||||
<div class="flex-1 lg:w-2/3">
|
||||
<SlideGrid
|
||||
data-testid="moderation-block-grid"
|
||||
:slides="moderationSlides"
|
||||
type="moderation"
|
||||
:show-expire-date="false"
|
||||
@deleted="handleSlideDeleted"
|
||||
@updated="handleSlideUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SlideGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,31 +48,27 @@ function handleSlideUpdated() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side: grid left (~70%), uploader right (~30%) -->
|
||||
<div class="flex flex-col lg:flex-row-reverse gap-6">
|
||||
<!-- Slide uploader -->
|
||||
<div class="lg:w-1/3">
|
||||
<!-- Slide grid with inline upload card -->
|
||||
<SlideGrid
|
||||
data-testid="sermon-block-grid"
|
||||
:slides="sermonSlides"
|
||||
type="sermon"
|
||||
:show-expire-date="false"
|
||||
:show-uploader="true"
|
||||
@deleted="handleSlideDeleted"
|
||||
@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>
|
||||
|
||||
<!-- Slide grid -->
|
||||
<div class="flex-1 lg:w-2/3">
|
||||
<SlideGrid
|
||||
data-testid="sermon-block-grid"
|
||||
:slides="sermonSlides"
|
||||
type="sermon"
|
||||
:show-expire-date="false"
|
||||
@deleted="handleSlideDeleted"
|
||||
@updated="handleSlideUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SlideGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
157
resources/js/Components/JsonTreeViewer.vue
Normal file
157
resources/js/Components/JsonTreeViewer.vue
Normal 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>
|
||||
|
|
@ -19,6 +19,10 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showUploader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deleted', 'updated'])
|
||||
|
|
@ -141,9 +145,9 @@ function isExpired(expireDate) {
|
|||
|
||||
<template>
|
||||
<div data-testid="slide-grid" class="slide-grid">
|
||||
<!-- Empty state -->
|
||||
<!-- Empty state (only when no slides AND no uploader) -->
|
||||
<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"
|
||||
>
|
||||
<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 -->
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
v-if="sortedSlides.length > 0 || showUploader"
|
||||
class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="slide in sortedSlides"
|
||||
: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 -->
|
||||
<div
|
||||
|
|
@ -184,52 +188,52 @@ function isExpired(expireDate) {
|
|||
v-else
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Delete button overlay -->
|
||||
<!-- Delete button — LEFT side, always visible -->
|
||||
<button
|
||||
data-testid="slide-grid-delete-button"
|
||||
@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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Full image link overlay -->
|
||||
<!-- Fullscreen button — RIGHT side, always visible -->
|
||||
<a
|
||||
data-testid="slide-grid-fullimage-link"
|
||||
v-if="fullImageUrl(slide)"
|
||||
:href="fullImageUrl(slide)"
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="px-3 py-2.5">
|
||||
<div class="px-3.5 py-3">
|
||||
<!-- Upload info (muted) -->
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<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" />
|
||||
</svg>
|
||||
<span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span>
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
</svg>
|
||||
<span class="truncate">{{ slide.uploader_name }}</span>
|
||||
|
|
@ -317,6 +321,24 @@ function isExpired(expireDate) {
|
|||
</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>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
|
|
@ -343,4 +365,8 @@ function isExpired(expireDate) {
|
|||
.slide-card img {
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.slide-upload-card {
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['uploaded'])
|
||||
|
|
@ -132,10 +136,10 @@ function dismissError() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="slide-uploader" class="slide-uploader">
|
||||
<!-- Expire date picker for information slides -->
|
||||
<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"
|
||||
v-if="showExpireDate && !inline"
|
||||
class="mb-4 flex items-center gap-3"
|
||||
>
|
||||
<label class="text-sm font-medium text-gray-600">
|
||||
|
|
@ -160,18 +164,21 @@ function dismissError() {
|
|||
>
|
||||
<div
|
||||
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" />
|
||||
</svg>
|
||||
<span class="flex-1">{{ uploadError }}</span>
|
||||
<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-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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -189,7 +196,7 @@ function dismissError() {
|
|||
>
|
||||
<div
|
||||
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">
|
||||
<span class="font-medium">
|
||||
|
|
@ -206,6 +213,23 @@ function dismissError() {
|
|||
</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"
|
||||
|
|
@ -217,35 +241,37 @@ function dismissError() {
|
|||
:max-files="20"
|
||||
:disabled="isUploading"
|
||||
:show-select-button="false"
|
||||
class="slide-dropzone"
|
||||
:class="['slide-dropzone', { 'slide-dropzone--inline': inline }]"
|
||||
@change="processFiles"
|
||||
>
|
||||
<template #placeholder-img>
|
||||
<div class="flex flex-col items-center gap-3 pointer-events-none">
|
||||
<!-- Big plus icon -->
|
||||
<div class="flex flex-col items-center gap-2 pointer-events-none">
|
||||
<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">
|
||||
<svg class="h-8 w-8 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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 -->
|
||||
<div class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" />
|
||||
<!-- 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="mt-3 block text-sm font-semibold text-gray-700">
|
||||
Dateien hier ablegen
|
||||
<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="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
|
||||
</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
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -294,4 +320,44 @@ function dismissError() {
|
|||
.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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import JsonTreeViewer from '@/Components/JsonTreeViewer.vue'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
|
|
@ -69,6 +70,13 @@ const expandedId = ref(null)
|
|||
const detailData = ref({})
|
||||
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) {
|
||||
if (expandedId.value === id) {
|
||||
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>
|
||||
Anfrage-Kontext
|
||||
</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>
|
||||
</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>
|
||||
Antwort-Daten
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue