feat: implement common file preview component

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-29 19:13:51 -04:00
parent 0ceb6808c4
commit c5a6461f5c
6 changed files with 108 additions and 47 deletions
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { EntityObject } from '@DocumentsManager/models/entity'
import { getFileIcon } from '@/utils/fileHelpers'
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
const props = withDefaults(defineProps<{
entity: EntityObject
source?: PreviewSource
size?: number
}>(), {
source: undefined,
size: 32,
})
const failed = ref(false)
const url = computed(() => {
if (!props.source) return null
const result = props.source({
id: String(props.entity.identifier ?? ''),
meta: { collection: props.entity.collection ? String(props.entity.collection) : null },
})
return typeof result === 'string' ? result : null
})
watch(url, () => {
failed.value = false
})
</script>
<template>
<div
class="file-preview"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<v-img
v-if="url && !failed"
:src="url"
:alt="entity.properties.label || 'File preview'"
contain
width="100%"
height="100%"
@error="failed = true"
/>
<v-icon v-else :size="size * 0.75" color="grey">{{ getFileIcon(entity) }}</v-icon>
</div>
</template>
<style scoped>
.file-preview {
flex: none;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 6px;
background-color: rgb(var(--v-theme-surface-variant), 0.4);
}
</style>
+1 -1
View File
@@ -110,7 +110,7 @@ watch(() => props.previewUrl, () => {
:src="previewUrl!" :src="previewUrl!"
:alt="selectedItem.properties.label || 'File preview'" :alt="selectedItem.properties.label || 'File preview'"
aspect-ratio="1.45" aspect-ratio="1.45"
cover contain
@error="previewFailed = true" @error="previewFailed = true"
> >
<template #placeholder> <template #placeholder>
+9 -2
View File
@@ -2,8 +2,9 @@
import { computed } from 'vue' import { computed } 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, formatDate } from '@/utils/fileHelpers' import { formatSize, formatDate } from '@/utils/fileHelpers'
import FileActionsMenu from '@/components/FileActionsMenu.vue' import FileActionsMenu from '@/components/FileActionsMenu.vue'
import FilePreview from '@/components/FilePreview.vue'
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue' import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
type ItemWithType = { type ItemWithType = {
@@ -18,6 +19,7 @@ const props = defineProps<{
entities: EntityObject[] entities: EntityObject[]
selectedIds: Set<string> selectedIds: Set<string>
previewSource?: PreviewSource previewSource?: PreviewSource
thumbnailSource?: PreviewSource
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -93,7 +95,12 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
@dblclick.stop="emit('open', wrapped.item)" @dblclick.stop="emit('open', wrapped.item)"
> >
<div class="files-details-cell files-details-name"> <div class="files-details-cell files-details-name">
<v-icon color="grey" size="small" class="mr-2">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon> <FilePreview
:entity="(wrapped.item as EntityObject)"
:source="thumbnailSource"
:size="24"
class="mr-2"
/>
{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }} {{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}
</div> </div>
<div class="files-details-cell files-details-size">{{ formatSize(wrapped.item.properties.size) }}</div> <div class="files-details-cell files-details-size">{{ formatSize(wrapped.item.properties.size) }}</div>
+4 -39
View File
@@ -1,9 +1,9 @@
<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 { formatSize } from '@/utils/fileHelpers'
import FileActionsMenu from '@/components/FileActionsMenu.vue' import FileActionsMenu from '@/components/FileActionsMenu.vue'
import FilePreview from '@/components/FilePreview.vue'
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue' 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>
@@ -13,25 +13,9 @@ const props = defineProps<{
entities: EntityObject[] entities: EntityObject[]
selectedIds: Set<string> selectedIds: Set<string>
previewSource?: PreviewSource previewSource?: PreviewSource
thumbnailSource?: 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]
@@ -93,21 +77,7 @@ const emit = defineEmits<{
/> />
</div> </div>
<div class="files-grid-thumbnail"> <div class="files-grid-thumbnail">
<v-img <FilePreview :entity="entity" :source="thumbnailSource" :size="64" />
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>
<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>
@@ -181,11 +151,6 @@ const emit = defineEmits<{
background-color: rgb(var(--v-theme-surface-variant), 0.4); 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;
+8 -2
View File
@@ -2,8 +2,9 @@
import { computed } from 'vue' import { computed } 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 { formatSize } from '@/utils/fileHelpers'
import FileActionsMenu from '@/components/FileActionsMenu.vue' import FileActionsMenu from '@/components/FileActionsMenu.vue'
import FilePreview from '@/components/FilePreview.vue'
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue' import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
type ItemWithType = { type ItemWithType = {
@@ -18,6 +19,7 @@ const props = defineProps<{
entities: EntityObject[] entities: EntityObject[]
selectedIds: Set<string> selectedIds: Set<string>
previewSource?: PreviewSource previewSource?: PreviewSource
thumbnailSource?: PreviewSource
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -79,7 +81,11 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
@dblclick.stop="emit('open', wrapped.item)" @dblclick.stop="emit('open', wrapped.item)"
> >
<template #prepend> <template #prepend>
<v-icon color="grey">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon> <FilePreview
:entity="(wrapped.item as EntityObject)"
:source="thumbnailSource"
:size="32"
/>
</template> </template>
<v-list-item-title>{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}</v-list-item-title> <v-list-item-title>{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatSize(wrapped.item.properties.size) }}</v-list-item-subtitle> <v-list-item-subtitle>{{ formatSize(wrapped.item.properties.size) }}</v-list-item-subtitle>
+26 -3
View File
@@ -129,11 +129,27 @@ function entityToViewerItem(entity: EntityObject): ViewerItem {
} }
function resolveViewerSource(item: ViewerItem): string { function resolveViewerSource(item: ViewerItem): string {
return documentManager.getEntityUrl(item.id, (item.meta?.collection as string | null) ?? null) return documentManager.getEntityPreviewUrl(
item.id,
(item.meta?.collection as string | null) ?? null,
'fullscreen',
)
} }
function resolvePreviewSource(item: ViewerItem): string { function resolvePreviewSource(item: ViewerItem): string {
return documentManager.getEntityPreviewUrl(item.id, (item.meta?.collection as string | null) ?? null) return documentManager.getEntityPreviewUrl(
item.id,
(item.meta?.collection as string | null) ?? null,
'inline',
)
}
function resolveThumbnailSource(item: ViewerItem): string {
return documentManager.getEntityPreviewUrl(
item.id,
(item.meta?.collection as string | null) ?? null,
'thumbnail',
)
} }
// Preview shown in the info panel sidebar for the current single selection // Preview shown in the info panel sidebar for the current single selection
@@ -142,7 +158,11 @@ const infoPanelPreviewUrl = computed<string | null>(() => {
if (items.length !== 1) return null if (items.length !== 1) return null
const item = items[0] const item = items[0]
if (!(item instanceof EntityObject)) return null if (!(item instanceof EntityObject)) return null
return documentManager.getEntityPreviewUrl(nodeId(item), item.collection ? String(item.collection) : null) return documentManager.getEntityPreviewUrl(
nodeId(item),
item.collection ? String(item.collection) : null,
'inline',
)
}) })
function downloadViewerItem(item: ViewerItem): void { function downloadViewerItem(item: ViewerItem): void {
@@ -796,6 +816,7 @@ onMounted(async () => {
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolvePreviewSource" :preview-source="resolvePreviewSource"
:thumbnail-source="resolveThumbnailSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"
@@ -812,6 +833,7 @@ onMounted(async () => {
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolvePreviewSource" :preview-source="resolvePreviewSource"
:thumbnail-source="resolveThumbnailSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"
@@ -828,6 +850,7 @@ onMounted(async () => {
:entities="sortedItems.entities" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:preview-source="resolvePreviewSource" :preview-source="resolvePreviewSource"
:thumbnail-source="resolveThumbnailSource"
@item-click="handleItemClick" @item-click="handleItemClick"
@open="handleOpenItem" @open="handleOpenItem"
@edit="handleEditItem" @edit="handleEditItem"