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:
tajniak81
2026-07-13 20:18:40 +02:00
co-authored by Claude Opus 4.8
parent 52f84ad6cf
commit 28291e27d7
10 changed files with 132 additions and 29 deletions
+7 -1
View File
@@ -518,6 +518,8 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
// ---------------------------------------------------------------------------
// GET /api/documents/{id}/file — stream the stored blob for a document.
// By default the response is an attachment (download); `?inline=1` serves it
// with an inline disposition so the browser renders it in-place (preview).
func (s *Server) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
@@ -554,7 +556,11 @@ func (s *Server) handleDownloadDocument(w http.ResponseWriter, r *http.Request)
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(rec.File)+"\"")
disposition := "attachment"
if r.URL.Query().Get("inline") == "1" {
disposition = "inline"
}
w.Header().Set("Content-Disposition", disposition+"; filename=\""+sanitizeFilename(rec.File)+"\"")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, resp.Body)
}
+7 -2
View File
@@ -509,10 +509,15 @@ func (a *App) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
}
// GET /bff/documents/{id}/file → API Server /api/documents/{id}/file. Streams
// the blob download, preserving the upstream Content-Type + Content-Disposition.
// the blob, preserving the upstream Content-Type + Content-Disposition. The
// `?inline=1` flag (preview vs. download) is forwarded unchanged.
func (a *App) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id)+"/file", nil)
target := a.apiBaseFor(r) + "/api/documents/" + url.PathEscape(id) + "/file"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
req, _ := http.NewRequest(http.MethodGet, target, nil)
req.Header.Set("Authorization", tokenOf(r))
resp, err := client.Do(req)
if err != nil {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-Bw9Fgeki.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B-aj3yVr.css">
<script type="module" crossorigin src="./assets/index-By3vEu-b.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CoIRbg0g.css">
</head>
<body>
<div id="app"></div>
+6
View File
@@ -378,6 +378,12 @@ export function documentFileUrl(id) {
return `/bff/documents/${encodeURIComponent(id)}/file`
}
// URL that streams a document's stored blob for inline preview (rendered in
// place rather than downloaded).
export function documentPreviewUrl(id) {
return `/bff/documents/${encodeURIComponent(id)}/file?inline=1`
}
export async function sendCommand(id, command, payload) {
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
method: 'POST',
+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',