feat: implement common file preview component
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user