pp-planer/resources/js/Components/JsonTreeViewer.vue
Thorsten Bus 32e9577d4d 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
2026-03-02 14:10:50 +01:00

158 lines
6.3 KiB
Vue

<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>