From 3decfaae3ea0180415851cacfc88674b50418c01 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 15 Jun 2026 11:41:11 +0800 Subject: [PATCH] feat: clean preview button with progress bar, add document preview support --- apps/api/src/routes/file-preview.ts | 87 ++++++++++- .../web/src/components/files/file-details.tsx | 137 +++++++++++++++--- apps/web/src/styles/globals.css | 5 + 3 files changed, 201 insertions(+), 28 deletions(-) diff --git a/apps/api/src/routes/file-preview.ts b/apps/api/src/routes/file-preview.ts index 206b35f2..15f6d6d4 100644 --- a/apps/api/src/routes/file-preview.ts +++ b/apps/api/src/routes/file-preview.ts @@ -1,13 +1,14 @@ /** * GET /api/v1/files/:id/preview * - * Server-side preview generation for non-native video/audio formats. - * Generates a browser-playable H.264 MP4 (video) or MP3 (audio) preview - * and caches it on disk for subsequent requests. + * Server-side preview generation for non-native video/audio formats + * and document files. Generates browser-playable H.264 MP4 (video), + * MP3 (audio), or PDF (documents) previews and caches them on disk. */ import { createReadStream } from "node:fs"; -import { access, mkdir } from "node:fs/promises"; +import { access, copyFile, mkdir, rename, rm } from "node:fs/promises"; import { join } from "node:path"; +import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine"; import { runFfmpeg } from "@snapotter/media-engine"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -43,6 +44,18 @@ async function fileExists(path: string): Promise { } } +const OFFICE_MIMES = new Set([ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", +]); + export async function filePreviewRoutes(app: FastifyInstance): Promise { app.get( "/api/v1/files/:id/preview", @@ -63,13 +76,71 @@ export async function filePreviewRoutes(app: FastifyInstance): Promise { const isVideo = file.mimeType.startsWith("video/"); const isAudio = file.mimeType.startsWith("audio/"); + const isPdf = file.mimeType === "application/pdf"; + const isOfficeDoc = OFFICE_MIMES.has(file.mimeType); - if (!isVideo && !isAudio) { - return reply - .status(400) - .send({ error: "Preview only supported for video and audio files" }); + if (!isVideo && !isAudio && !isPdf && !isOfficeDoc) { + return reply.status(400).send({ error: "Preview not supported for this file type" }); } + // PDF: stream the original file directly + if (isPdf) { + const inputPath = getStoredFilePath(file.storedName); + return reply + .header("Content-Type", "application/pdf") + .header("Cache-Control", "public, max-age=86400, immutable") + .send(createReadStream(inputPath)); + } + + // Office documents: convert to PDF via LibreOffice + if (isOfficeDoc) { + const cachedPath = previewPath(id, ".pdf"); + + if (await fileExists(cachedPath)) { + return reply + .header("Content-Type", "application/pdf") + .header("Cache-Control", "public, max-age=86400, immutable") + .send(createReadStream(cachedPath)); + } + + if (!sofficeAvailable()) { + return reply + .status(422) + .send({ error: "LibreOffice is not available for document preview" }); + } + + await ensurePreviewDir(); + const inputPath = getStoredFilePath(file.storedName); + + // Copy to a temp file with the original extension so LibreOffice + // can detect the format correctly from the extension. + const origExt = file.originalName.match(/\.[^.]+$/)?.[0] ?? ""; + const tempInput = join(previewDirPath(), `${id}-input${origExt}`); + await copyFile(inputPath, tempInput); + + try { + await convertDocument(tempInput, previewDirPath(), "pdf", { + timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000, + }); + + // convertDocument outputs next to the temp file; rename to cached path + const producedPath = join(previewDirPath(), `${id}-input.pdf`); + await rename(producedPath, cachedPath); + } catch (err) { + request.log.error({ err, fileId: id }, "Document preview generation failed"); + return reply.status(422).send({ error: "Could not generate document preview" }); + } finally { + // Clean up temp input copy + await rm(tempInput, { force: true }).catch(() => {}); + } + + return reply + .header("Content-Type", "application/pdf") + .header("Cache-Control", "public, max-age=86400, immutable") + .send(createReadStream(cachedPath)); + } + + // Video / Audio preview via FFmpeg const previewExt = isVideo ? ".mp4" : ".mp3"; const contentType = isVideo ? "video/mp4" : "audio/mpeg"; const cachedPath = previewPath(id, previewExt); diff --git a/apps/web/src/components/files/file-details.tsx b/apps/web/src/components/files/file-details.tsx index 905af8b2..7642f7c0 100644 --- a/apps/web/src/components/files/file-details.tsx +++ b/apps/web/src/components/files/file-details.tsx @@ -1,5 +1,5 @@ import { TOOLS } from "@snapotter/shared"; -import { FileImage, FileText, ImageIcon, Loader2, Music, Play, Video } from "lucide-react"; +import { FileImage, FileText, ImageIcon, Music, Play, Video } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "@/contexts/i18n-context"; @@ -68,6 +68,18 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam const NATIVE_VIDEO = new Set(["mp4", "webm", "ogg", "ogv", "m4v"]); const NATIVE_AUDIO = new Set(["mp3", "wav", "ogg", "oga", "opus", "aac", "m4a", "flac", "webm"]); +const OFFICE_MIMES = new Set([ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", +]); + function isNativePlayable(mimeType: string, filename: string): boolean { const ext = filename.split(".").pop()?.toLowerCase() ?? ""; if (mimeType.startsWith("video/")) return NATIVE_VIDEO.has(ext); @@ -91,8 +103,10 @@ function FilePreview({ const isMedia = mimeType.startsWith("video/") || mimeType.startsWith("audio/"); const nativePlayable = isMedia && isNativePlayable(mimeType, name); + const isPdf = mimeType === "application/pdf"; + const isOfficeDoc = OFFICE_MIMES.has(mimeType); - // Fetch native-playable media directly from the download endpoint. + // Fetch native-playable media or PDF directly from the download endpoint. // Also resets server-side preview state when the file changes. useEffect(() => { // Reset server-side preview state on every file change @@ -103,6 +117,23 @@ function FilePreview({ setPreviewLoading(false); setPreviewError(false); + if (isPdf) { + let revoked = false; + fetch(getFileDownloadUrl(fileId), { headers: formatHeaders() }) + .then((res) => res.blob()) + .then((blob) => { + if (!revoked) setMediaSrc(URL.createObjectURL(blob)); + }) + .catch(() => {}); + return () => { + revoked = true; + setMediaSrc((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + }; + } + if (!isMedia || !nativePlayable) return; let revoked = false; const url = getFileDownloadUrl(fileId); @@ -119,7 +150,7 @@ function FilePreview({ return null; }); }; - }, [isMedia, nativePlayable, fileId]); + }, [isMedia, nativePlayable, isPdf, fileId]); const handleGeneratePreview = useCallback(() => { setPreviewLoading(true); @@ -140,7 +171,61 @@ function FilePreview({ }); }, [fileId]); - const ext = name.split(".").pop()?.toLowerCase() ?? ""; + // PDF files -- render inline in iframe + if (isPdf) { + return mediaSrc ? ( +