feat: implement image preview

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-29 12:22:13 -04:00
parent c32906bc12
commit 0ceb6808c4
3 changed files with 104 additions and 8 deletions
+34 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { VNavigationDrawer } from 'vuetify/components' import { VNavigationDrawer } from 'vuetify/components'
import { CollectionObject } from '@DocumentsManager/models/collection' import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity' import { EntityObject } from '@DocumentsManager/models/entity'
@@ -8,8 +8,10 @@ import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
selectedItems: (CollectionObject | EntityObject)[] selectedItems: (CollectionObject | EntityObject)[]
embedded?: boolean embedded?: boolean
previewUrl?: string | null
}>(), { }>(), {
embedded: false, embedded: false,
previewUrl: null,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -69,6 +71,13 @@ const totalSize = computed(() => {
return sum return sum
}, 0) }, 0)
}) })
const previewFailed = ref(false)
const showPreview = computed(() => isEntity.value && !!props.previewUrl && !previewFailed.value)
watch(() => props.previewUrl, () => {
previewFailed.value = false
})
</script> </script>
<template> <template>
@@ -94,9 +103,24 @@ const totalSize = computed(() => {
<!-- Single item selected --> <!-- Single item selected -->
<template v-if="singleSelection && selectedItem"> <template v-if="singleSelection && selectedItem">
<!-- Icon and name --> <!-- Preview/icon and name -->
<div class="text-center mb-4"> <div class="text-center mb-4">
<v-icon :color="itemIconColor" size="64">{{ itemIcon }}</v-icon> <div v-if="showPreview" class="files-info-preview mb-3">
<v-img
:src="previewUrl!"
:alt="selectedItem.properties.label || 'File preview'"
aspect-ratio="1.45"
cover
@error="previewFailed = true"
>
<template #placeholder>
<div class="d-flex fill-height align-center justify-center">
<v-progress-circular indeterminate color="primary" size="28" width="2" />
</div>
</template>
</v-img>
</div>
<v-icon v-else :color="itemIconColor" size="64">{{ itemIcon }}</v-icon>
<div class="text-h6 mt-2 text-truncate">{{ selectedItem.properties.label || String(selectedItem.identifier ?? '') }}</div> <div class="text-h6 mt-2 text-truncate">{{ selectedItem.properties.label || String(selectedItem.identifier ?? '') }}</div>
<div class="text-caption text-grey"> <div class="text-caption text-grey">
{{ isCollection ? 'Folder' : entity?.properties.mime || 'File' }} {{ isCollection ? 'Folder' : entity?.properties.mime || 'File' }}
@@ -205,4 +229,11 @@ const totalSize = computed(() => {
overflow-y: auto; overflow-y: auto;
background-color: rgb(var(--v-theme-surface)); background-color: rgb(var(--v-theme-surface));
} }
.files-info-preview {
overflow: hidden;
border: 1px solid rgb(var(--v-border-color));
border-radius: 8px;
background-color: rgb(var(--v-theme-surface-variant));
}
</style> </style>
+52 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from 'vue'
import { CollectionObject } from '@DocumentsManager/models/collection' import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity' import { EntityObject } from '@DocumentsManager/models/entity'
import { getFileIcon, formatSize } from '@/utils/fileHelpers' import { getFileIcon, formatSize } from '@/utils/fileHelpers'
@@ -7,13 +8,30 @@ import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob> type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
defineProps<{ const props = defineProps<{
collections: CollectionObject[] collections: CollectionObject[]
entities: EntityObject[] entities: EntityObject[]
selectedIds: Set<string> selectedIds: Set<string>
previewSource?: PreviewSource previewSource?: PreviewSource
}>() }>()
// Thumbnails only support the plain-URL form of PreviewSource (what Documents
// actually passes); Blob/Promise results just fall back to the file icon.
const thumbnailErrors = reactive(new Set<string>())
function thumbnailUrl(entity: EntityObject): string | null {
if (!props.previewSource) return null
const result = props.previewSource({
id: String(entity.identifier ?? ''),
meta: { collection: entity.collection ? String(entity.collection) : null },
})
return typeof result === 'string' ? result : null
}
function markThumbnailFailed(id: string) {
thumbnailErrors.add(id)
}
const emit = defineEmits<{ const emit = defineEmits<{
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent] 'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
'open': [item: CollectionObject | EntityObject] 'open': [item: CollectionObject | EntityObject]
@@ -74,7 +92,23 @@ const emit = defineEmits<{
@show-details="emit('show-details', $event)" @show-details="emit('show-details', $event)"
/> />
</div> </div>
<v-icon size="48" color="grey">{{ getFileIcon(entity) }}</v-icon> <div class="files-grid-thumbnail">
<v-img
v-if="thumbnailUrl(entity) && !thumbnailErrors.has(String(entity.identifier))"
:src="thumbnailUrl(entity)!"
:alt="entity.properties.label || 'File preview'"
cover
class="files-grid-thumbnail-img"
@error="markThumbnailFailed(String(entity.identifier))"
>
<template #placeholder>
<div class="d-flex fill-height align-center justify-center">
<v-progress-circular indeterminate color="primary" size="20" width="2" />
</div>
</template>
</v-img>
<v-icon v-else size="48" color="grey">{{ getFileIcon(entity) }}</v-icon>
</div>
<div class="files-grid-label">{{ entity.properties.label || String(entity.identifier ?? '') }}</div> <div class="files-grid-label">{{ entity.properties.label || String(entity.identifier ?? '') }}</div>
<div class="files-grid-size">{{ formatSize(entity.properties.size) }}</div> <div class="files-grid-size">{{ formatSize(entity.properties.size) }}</div>
@@ -136,6 +170,22 @@ const emit = defineEmits<{
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);
} }
.files-grid-thumbnail {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
overflow: hidden;
background-color: rgb(var(--v-theme-surface-variant), 0.4);
}
.files-grid-thumbnail-img {
width: 100%;
height: 100%;
}
.files-grid-label { .files-grid-label {
margin-top: 8px; margin-top: 8px;
font-size: 13px; font-size: 13px;
+18 -3
View File
@@ -132,6 +132,19 @@ function resolveViewerSource(item: ViewerItem): string {
return documentManager.getEntityUrl(item.id, (item.meta?.collection as string | null) ?? null) return documentManager.getEntityUrl(item.id, (item.meta?.collection as string | null) ?? null)
} }
function resolvePreviewSource(item: ViewerItem): string {
return documentManager.getEntityPreviewUrl(item.id, (item.meta?.collection as string | null) ?? null)
}
// Preview shown in the info panel sidebar for the current single selection
const infoPanelPreviewUrl = computed<string | null>(() => {
const items = selection.selectedNodeArray.value
if (items.length !== 1) return null
const item = items[0]
if (!(item instanceof EntityObject)) return null
return documentManager.getEntityPreviewUrl(nodeId(item), item.collection ? String(item.collection) : null)
})
function downloadViewerItem(item: ViewerItem): void { function downloadViewerItem(item: ViewerItem): void {
documentManager.downloadEntity(item.id, (item.meta?.collection as string | null) ?? null) documentManager.downloadEntity(item.id, (item.meta?.collection as string | null) ?? null)
} }
@@ -782,7 +795,7 @@ onMounted(async () => {
:collections="sortedItems.collections" :collections="sortedItems.collections"
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolveViewerSource" :preview-source="resolvePreviewSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"
@@ -798,7 +811,7 @@ onMounted(async () => {
:collections="sortedItems.collections" :collections="sortedItems.collections"
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolveViewerSource" :preview-source="resolvePreviewSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"
@@ -814,7 +827,7 @@ onMounted(async () => {
:collections="sortedItems.collections" :collections="sortedItems.collections"
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolveViewerSource" :preview-source="resolvePreviewSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"
@@ -830,6 +843,7 @@ onMounted(async () => {
v-if="hasSelection && !isMobile" v-if="hasSelection && !isMobile"
embedded embedded
:selected-items="selection.selectedNodeArray.value" :selected-items="selection.selectedNodeArray.value"
:preview-url="infoPanelPreviewUrl"
@close="selection.clear()" @close="selection.clear()"
/> />
</div> </div>
@@ -838,6 +852,7 @@ onMounted(async () => {
<FilesInfoPanel <FilesInfoPanel
v-if="isMobile" v-if="isMobile"
:selected-items="selection.selectedNodeArray.value" :selected-items="selection.selectedNodeArray.value"
:preview-url="infoPanelPreviewUrl"
@close="selection.clear()" @close="selection.clear()"
/> />
</div> </div>