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:
co-authored by
Claude Opus 4.8
parent
52f84ad6cf
commit
28291e27d7
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
-20
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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