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:
tajniak81andClaude Opus 4.8 committed 2026-07-13 20:18:40 +02:00
1 parent 52f84ad6cf
commit 28291e27d7
10 files changed
+132 -29

No files matched your search

+88 -3
View File
@@ -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>
+1
View File
@@ -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',