mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: clean preview button with progress bar, add document preview support
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/v1/files/:id/preview
|
* GET /api/v1/files/:id/preview
|
||||||
*
|
*
|
||||||
* Server-side preview generation for non-native video/audio formats.
|
* Server-side preview generation for non-native video/audio formats
|
||||||
* Generates a browser-playable H.264 MP4 (video) or MP3 (audio) preview
|
* and document files. Generates browser-playable H.264 MP4 (video),
|
||||||
* and caches it on disk for subsequent requests.
|
* MP3 (audio), or PDF (documents) previews and caches them on disk.
|
||||||
*/
|
*/
|
||||||
import { createReadStream } from "node:fs";
|
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 { join } from "node:path";
|
||||||
|
import { convertDocument, sofficeAvailable } from "@snapotter/doc-engine";
|
||||||
import { runFfmpeg } from "@snapotter/media-engine";
|
import { runFfmpeg } from "@snapotter/media-engine";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
@@ -43,6 +44,18 @@ async function fileExists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> {
|
export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/files/:id/preview",
|
"/api/v1/files/:id/preview",
|
||||||
@@ -63,13 +76,71 @@ export async function filePreviewRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const isVideo = file.mimeType.startsWith("video/");
|
const isVideo = file.mimeType.startsWith("video/");
|
||||||
const isAudio = file.mimeType.startsWith("audio/");
|
const isAudio = file.mimeType.startsWith("audio/");
|
||||||
|
const isPdf = file.mimeType === "application/pdf";
|
||||||
|
const isOfficeDoc = OFFICE_MIMES.has(file.mimeType);
|
||||||
|
|
||||||
if (!isVideo && !isAudio) {
|
if (!isVideo && !isAudio && !isPdf && !isOfficeDoc) {
|
||||||
return reply
|
return reply.status(400).send({ error: "Preview not supported for this file type" });
|
||||||
.status(400)
|
|
||||||
.send({ error: "Preview only supported for video and audio files" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 previewExt = isVideo ? ".mp4" : ".mp3";
|
||||||
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
|
const contentType = isVideo ? "video/mp4" : "audio/mpeg";
|
||||||
const cachedPath = previewPath(id, previewExt);
|
const cachedPath = previewPath(id, previewExt);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TOOLS } from "@snapotter/shared";
|
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 { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
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_VIDEO = new Set(["mp4", "webm", "ogg", "ogv", "m4v"]);
|
||||||
const NATIVE_AUDIO = new Set(["mp3", "wav", "ogg", "oga", "opus", "aac", "m4a", "flac", "webm"]);
|
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 {
|
function isNativePlayable(mimeType: string, filename: string): boolean {
|
||||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
if (mimeType.startsWith("video/")) return NATIVE_VIDEO.has(ext);
|
if (mimeType.startsWith("video/")) return NATIVE_VIDEO.has(ext);
|
||||||
@@ -91,8 +103,10 @@ function FilePreview({
|
|||||||
|
|
||||||
const isMedia = mimeType.startsWith("video/") || mimeType.startsWith("audio/");
|
const isMedia = mimeType.startsWith("video/") || mimeType.startsWith("audio/");
|
||||||
const nativePlayable = isMedia && isNativePlayable(mimeType, name);
|
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.
|
// Also resets server-side preview state when the file changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset server-side preview state on every file change
|
// Reset server-side preview state on every file change
|
||||||
@@ -103,6 +117,23 @@ function FilePreview({
|
|||||||
setPreviewLoading(false);
|
setPreviewLoading(false);
|
||||||
setPreviewError(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;
|
if (!isMedia || !nativePlayable) return;
|
||||||
let revoked = false;
|
let revoked = false;
|
||||||
const url = getFileDownloadUrl(fileId);
|
const url = getFileDownloadUrl(fileId);
|
||||||
@@ -119,7 +150,7 @@ function FilePreview({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [isMedia, nativePlayable, fileId]);
|
}, [isMedia, nativePlayable, isPdf, fileId]);
|
||||||
|
|
||||||
const handleGeneratePreview = useCallback(() => {
|
const handleGeneratePreview = useCallback(() => {
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
@@ -140,7 +171,61 @@ function FilePreview({
|
|||||||
});
|
});
|
||||||
}, [fileId]);
|
}, [fileId]);
|
||||||
|
|
||||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
// PDF files -- render inline in iframe
|
||||||
|
if (isPdf) {
|
||||||
|
return mediaSrc ? (
|
||||||
|
<iframe src={mediaSrc} className="w-full h-48 rounded-lg border border-border" title={name} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-48 rounded-lg bg-muted flex items-center justify-center">
|
||||||
|
<div className="h-4 w-4 border-2 border-muted-foreground/30 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Office documents -- convert to PDF via preview endpoint
|
||||||
|
if (isOfficeDoc) {
|
||||||
|
if (previewSrc) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={previewSrc}
|
||||||
|
className="w-full h-48 rounded-lg border border-border"
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-lg bg-muted flex flex-col items-center justify-center gap-3 p-6">
|
||||||
|
<div className="w-full max-w-48 h-1 rounded-full bg-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full w-1/3 rounded-full bg-primary"
|
||||||
|
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Generating preview...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-32 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGeneratePreview}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
Generate Preview
|
||||||
|
</button>
|
||||||
|
{previewError && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Preview generation failed. Try again.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Video files
|
// Video files
|
||||||
if (mimeType.startsWith("video/")) {
|
if (mimeType.startsWith("video/")) {
|
||||||
@@ -168,8 +253,14 @@ function FilePreview({
|
|||||||
|
|
||||||
if (previewLoading) {
|
if (previewLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
<div className="w-full rounded-lg bg-muted flex flex-col items-center justify-center gap-3 p-6">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
<div className="w-full max-w-48 h-1 rounded-full bg-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full w-1/3 rounded-full bg-primary"
|
||||||
|
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Generating preview...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -181,14 +272,14 @@ function FilePreview({
|
|||||||
onClick={handleGeneratePreview}
|
onClick={handleGeneratePreview}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Play className="h-5 w-5" />
|
<Play className="h-4 w-4" />
|
||||||
Generate Preview
|
Generate Preview
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
{previewError && (
|
||||||
{previewError
|
<span className="text-xs text-muted-foreground">
|
||||||
? "Preview generation failed. Try again."
|
Preview generation failed. Try again.
|
||||||
: `${ext.toUpperCase()} requires server-side conversion`}
|
</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -219,8 +310,14 @@ function FilePreview({
|
|||||||
|
|
||||||
if (previewLoading) {
|
if (previewLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-32 rounded-lg bg-muted flex items-center justify-center">
|
<div className="w-full rounded-lg bg-muted flex flex-col items-center justify-center gap-3 p-6">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
<div className="w-full max-w-48 h-1 rounded-full bg-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full w-1/3 rounded-full bg-primary"
|
||||||
|
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Generating preview...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,14 +329,14 @@ function FilePreview({
|
|||||||
onClick={handleGeneratePreview}
|
onClick={handleGeneratePreview}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Play className="h-5 w-5" />
|
<Play className="h-4 w-4" />
|
||||||
Generate Preview
|
Generate Preview
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
{previewError && (
|
||||||
{previewError
|
<span className="text-xs text-muted-foreground">
|
||||||
? "Preview generation failed. Try again."
|
Preview generation failed. Try again.
|
||||||
: `${ext.toUpperCase()} requires server-side conversion`}
|
</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ input[type="range"]::-moz-range-track {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(400%); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Slide-in animation for settings panel */
|
/* Slide-in animation for settings panel */
|
||||||
@keyframes settings-slide-in {
|
@keyframes settings-slide-in {
|
||||||
from {
|
from {
|
||||||
|
|||||||
Reference in New Issue
Block a user