Add in-app document preview
Adds an inline preview for stored document files alongside download.
- API Server: GET /api/documents/{id}/file honours ?inline=1, serving an
inline Content-Disposition so the browser renders the file instead of
forcing a download (default stays attachment).
- Web App: BFF forwards the inline flag; documentPreviewUrl() helper; an eye
icon; and a preview modal (Teleported to body) that picks a viewer from the
file extension — images -> <img>, PDFs/text -> <iframe>, else a
download-instead fallback. Closes on backdrop click or Escape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
52f84ad6cf
commit
28291e27d7
10 files changed
+132
-29
No files matched your search
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import {
|
||||
getDocuments, createDocument, updateDocument, deleteDocument, documentFileUrl,
|
||||
getDrones,
|
||||
getDocuments, createDocument, updateDocument, deleteDocument,
|
||||
documentFileUrl, documentPreviewUrl, getDrones,
|
||||
} from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -134,6 +134,37 @@ function ownerLabel(d) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
/* ---------------- preview ---------------- */
|
||||
|
||||
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']
|
||||
const FRAME_EXT = ['pdf', 'txt', 'csv', 'log', 'json', 'md', 'html', 'htm', 'xml']
|
||||
|
||||
// Which in-browser viewer suits this file, inferred from its extension. Images
|
||||
// render in <img>; PDFs / text-ish files render in an <iframe>; anything else
|
||||
// can't be previewed inline (download instead).
|
||||
function previewKind(fileName) {
|
||||
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||
if (IMG_EXT.includes(ext)) return 'image'
|
||||
if (FRAME_EXT.includes(ext)) return 'frame'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const previewDoc = ref(null)
|
||||
const previewKindFor = computed(() => (previewDoc.value ? previewKind(previewDoc.value.fileName) : 'none'))
|
||||
const previewSrc = computed(() => (previewDoc.value ? documentPreviewUrl(previewDoc.value.id) : ''))
|
||||
|
||||
function openPreview(d) {
|
||||
previewDoc.value = d
|
||||
}
|
||||
function closePreview() {
|
||||
previewDoc.value = null
|
||||
}
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape' && previewDoc.value) closePreview()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
/* ---------------- document form ---------------- */
|
||||
|
||||
function blankDoc() {
|
||||
@@ -442,6 +473,9 @@ const stats = computed(() => {
|
||||
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDoc(d)">Delete</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="d.hasFile" class="btn-ghost mr-1 inline-flex items-center gap-1" title="Preview" @click="openPreview(d)">
|
||||
<Icon name="eye" :size="13" />
|
||||
</button>
|
||||
<a v-if="d.hasFile" :href="documentFileUrl(d.id)" class="btn-ghost mr-1 inline-flex items-center gap-1" title="Download file">
|
||||
<Icon name="download" :size="13" />
|
||||
</a>
|
||||
@@ -476,5 +510,56 @@ const stats = computed(() => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ preview modal ============ -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="previewDoc"
|
||||
class="fixed inset-0 z-50 grid place-items-center p-4"
|
||||
style="background: color-mix(in srgb, black 60%, transparent)"
|
||||
@click.self="closePreview"
|
||||
>
|
||||
<div class="flex max-h-[90vh] w-full max-w-[920px] flex-col overflow-hidden rounded-lg border border-line bg-surface-1 shadow-2xl">
|
||||
<div class="flex items-center gap-3 border-b border-line px-5 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-ink">{{ previewDoc.title }}</div>
|
||||
<div class="truncate font-mono text-[11px] text-ink-muted">{{ previewDoc.fileName }}</div>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<a :href="previewSrc" target="_blank" rel="noopener" class="btn-ghost inline-flex items-center gap-1.5" title="Open in new tab">
|
||||
<Icon name="globe" :size="14" /> New tab
|
||||
</a>
|
||||
<a :href="documentFileUrl(previewDoc.id)" class="btn-ghost inline-flex items-center gap-1.5" title="Download">
|
||||
<Icon name="download" :size="14" /> Download
|
||||
</a>
|
||||
<button class="btn-icon" title="Close" @click="closePreview"><Icon name="x" :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-surface-2">
|
||||
<img
|
||||
v-if="previewKindFor === 'image'"
|
||||
:src="previewSrc"
|
||||
:alt="previewDoc.title"
|
||||
class="mx-auto block max-w-full"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="previewKindFor === 'frame'"
|
||||
:src="previewSrc"
|
||||
class="h-[74vh] w-full border-0 bg-white"
|
||||
:title="previewDoc.title"
|
||||
></iframe>
|
||||
<div v-else class="grid place-items-center px-6 py-16 text-center">
|
||||
<Icon name="fileText" :size="28" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">Preview isn't available for this file type</div>
|
||||
<div class="mt-1 text-xs text-ink-muted">{{ previewDoc.fileName }}</div>
|
||||
<a :href="documentFileUrl(previewDoc.id)" class="btn-accent mt-4 inline-flex items-center gap-2">
|
||||
<Icon name="download" :size="15" /> Download instead
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,6 +33,7 @@ const P = {
|
||||
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z',
|
||||
smartphone: 'M7 2h10a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM11 18h2',
|
||||
image: 'M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
|
||||
eye: 'M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||
type: 'M4 7V4h16v3M9 20h6M12 4v16',
|
||||
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
|
||||
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
|
||||
|
||||
Reference in new issue
Block a user