diff --git a/backend/Dockerfile b/backend/Dockerfile
index 42faeae..70e99b8 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -21,6 +21,7 @@ RUN apk add --no-cache \
ffmpeg \
exiftool \
sqlite \
+ ghostscript \
ca-certificates \
mailcap \
tzdata
diff --git a/backend/workers/thumbnails.go b/backend/workers/thumbnails.go
index 7544f0d..31f8e11 100644
--- a/backend/workers/thumbnails.go
+++ b/backend/workers/thumbnails.go
@@ -148,7 +148,9 @@ func (m *ThumbnailManager) worker(id int) {
err = generateVideoThumbnail(fullSourcePath, destPath)
} else if strings.HasPrefix(job.MimeType, "audio/") {
err = generateAudioThumbnail(fullSourcePath, destPath)
- } else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") || strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
+ } else if strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
+ err = generatePdfThumbnail(fullSourcePath, destPath)
+ } else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") {
err = generateOfficeThumbnail(job.FilePath, destPath, m.Config)
} else {
continue
@@ -211,6 +213,39 @@ func generateAudioThumbnail(src, dest string) error {
return nil
}
+func generatePdfThumbnail(src, dest string) error {
+ // Use Ghostscript to render the first page of the PDF as a JPEG
+ tmpFile := dest + ".tmp.jpg"
+ cmd := exec.Command("gs",
+ "-dNOPAUSE", "-dBATCH", "-dSAFER", "-dQUIET",
+ "-sDEVICE=jpeg",
+ "-dFirstPage=1", "-dLastPage=1",
+ "-r72", // 72 DPI is enough for a thumbnail
+ "-dJPEGQ=85",
+ fmt.Sprintf("-sOutputFile=%s", tmpFile),
+ src,
+ )
+
+ timer := time.AfterFunc(30*time.Second, func() {
+ if cmd.Process != nil {
+ cmd.Process.Kill()
+ }
+ })
+
+ err := cmd.Run()
+ timer.Stop()
+
+ if err != nil {
+ os.Remove(tmpFile)
+ return fmt.Errorf("ghostscript failed: %w", err)
+ }
+
+ // Resize the rendered page to standard thumbnail size
+ err = generateImageThumbnail(tmpFile, dest)
+ os.Remove(tmpFile)
+ return err
+}
+
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error {
// Generate a download token for the Conversion API to fetch the file from our backend
token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret, filePath)
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 153f07c..81f9fd3 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -5,6 +5,7 @@ const nextConfig = {
serverActions: {
bodySizeLimit: '100gb',
},
+ proxyTimeout: 300000, // 5 minutes for large file proxying
},
async rewrites() {
return [
diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx
index e9f4203..841677d 100644
--- a/frontend/src/components/PdfViewer.tsx
+++ b/frontend/src/components/PdfViewer.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
@@ -11,24 +11,44 @@ if (typeof window !== 'undefined') {
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
}
-function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number }) {
+function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; scale: number; containerWidth: number }) {
const { ref, inView } = useInView({
triggerOnce: false,
- rootMargin: '200% 0px', // load pages 2 screens ahead/behind
+ rootMargin: '150% 0px',
});
+ // Estimate the page height based on typical A4 ratio (1:1.414)
+ const estimatedWidth = Math.min(containerWidth * 0.9, 900) * scale;
+ const estimatedHeight = estimatedWidth * 1.414;
+
return (
-
);
@@ -36,24 +56,96 @@ function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number
export default function PdfViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState();
- const [scale, setScale] = useState(1.2);
+ const [scale, setScale] = useState(1.0);
+ const [blobUrl, setBlobUrl] = useState(null);
+ const [fetchError, setFetchError] = useState(null);
+ const [fetchingPdf, setFetchingPdf] = useState(true);
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(800);
+
+ // Measure container width for responsive page sizing
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const ro = new ResizeObserver(([entry]) => {
+ setContainerWidth(entry.contentRect.width);
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // Fetch the PDF as a blob to avoid Next.js proxy streaming issues with large files
+ useEffect(() => {
+ let cancelled = false;
+ setFetchingPdf(true);
+ setFetchError(null);
+
+ fetch(url)
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.blob();
+ })
+ .then((blob) => {
+ if (cancelled) return;
+ const objectUrl = URL.createObjectURL(blob);
+ setBlobUrl(objectUrl);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setFetchError(`Failed to load PDF: ${err.message}`);
+ })
+ .finally(() => {
+ if (!cancelled) setFetchingPdf(false);
+ });
+
+ return () => {
+ cancelled = true;
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
+ };
+ }, [url]); // eslint-disable-line react-hooks/exhaustive-deps
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
}
+ if (fetchingPdf) {
+ return (
+
+
+ Loading PDF...
+
+ );
+ }
+
+ if (fetchError) {
+ return (
+
+ );
+ }
+
return (
{/* PDF Toolbar */}
- {numPages ? `${numPages} Pages` : 'Loading...'}
+ {numPages ? `${numPages} page${numPages !== 1 ? 's' : ''}` : 'Loading...'}
{/* PDF Content */}
-
+
console.error('Failed to load PDF:', error)}
loading={
@@ -85,8 +177,13 @@ export default function PdfViewer({ url }: { url: string }) {
}
className="flex flex-col items-center gap-2 w-full"
>
- {numPages && Array.from(new Array(numPages), (el, index) => (
-
+ {numPages && Array.from(new Array(numPages), (_, index) => (
+
))}
diff --git a/frontend/src/components/ThreeMfPreview.tsx b/frontend/src/components/ThreeMfPreview.tsx
index f10834c..414a629 100644
--- a/frontend/src/components/ThreeMfPreview.tsx
+++ b/frontend/src/components/ThreeMfPreview.tsx
@@ -1,7 +1,6 @@
-import React, { useState, useEffect } from 'react';
-import { Canvas } from '@react-three/fiber';
-import { OrbitControls, Center } from '@react-three/drei';
-import { ThreeMFLoader } from 'three-stdlib';
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import { Canvas, useThree } from '@react-three/fiber';
+import { OrbitControls } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
@@ -11,29 +10,64 @@ interface ThreeMfPreviewProps {
function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onError: (e: any) => void }) {
const [model, setModel] = useState
(null);
+ const { camera } = useThree();
useEffect(() => {
- const loader = new ThreeMFLoader();
- loader.load(
- url,
- (group) => {
- // Adjust materials for shadow casting
- group.traverse((child) => {
- if ((child as THREE.Mesh).isMesh) {
- const mesh = child as THREE.Mesh;
- mesh.castShadow = true;
- mesh.receiveShadow = true;
+ let cancelled = false;
+
+ // Dynamically import the official Three.js 3MFLoader
+ import('three/examples/jsm/loaders/3MFLoader.js').then(({ ThreeMFLoader }) => {
+ if (cancelled) return;
+ const loader = new ThreeMFLoader();
+
+ loader.load(
+ url,
+ (group: THREE.Group) => {
+ if (cancelled) return;
+
+ // Ensure all meshes have materials and shadows
+ group.traverse((child) => {
+ if ((child as THREE.Mesh).isMesh) {
+ const mesh = child as THREE.Mesh;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ // Ensure material exists
+ if (!mesh.material) {
+ mesh.material = new THREE.MeshStandardMaterial({ color: 0x888888 });
+ }
+ }
+ });
+
+ // Center and fit model to camera
+ const box = new THREE.Box3().setFromObject(group);
+ const center = box.getCenter(new THREE.Vector3());
+ const size = box.getSize(new THREE.Vector3());
+ group.position.sub(center);
+
+ // Adjust camera to fit
+ const maxDim = Math.max(size.x, size.y, size.z);
+ if (maxDim > 0) {
+ const fov = (camera as THREE.PerspectiveCamera).fov * (Math.PI / 180);
+ const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.5;
+ camera.position.set(dist * 0.7, dist * 0.5, dist);
+ camera.lookAt(0, 0, 0);
}
- });
- setModel(group);
- onLoad();
- },
- undefined,
- (err) => {
- onError(err);
- }
- );
- }, [url, onLoad, onError]);
+
+ setModel(group);
+ onLoad();
+ },
+ undefined,
+ (err: any) => {
+ if (cancelled) return;
+ onError(err instanceof Error ? err : new Error(String(err)));
+ }
+ );
+ }).catch((err) => {
+ if (!cancelled) onError(err);
+ });
+
+ return () => { cancelled = true; };
+ }, [url]); // eslint-disable-line react-hooks/exhaustive-deps
if (!model) return null;
return ;
@@ -64,21 +98,20 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {