diff --git a/backend/Dockerfile b/backend/Dockerfile
index 70e99b8..42faeae 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -21,7 +21,6 @@ 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 31f8e11..d26ca4b 100644
--- a/backend/workers/thumbnails.go
+++ b/backend/workers/thumbnails.go
@@ -82,7 +82,7 @@ func (m *ThumbnailManager) ScanMissingThumbnails() {
mimeType = "video/"
} else if ext == ".mp3" || ext == ".flac" || ext == ".wav" || ext == ".m4a" || ext == ".aac" {
mimeType = "audio/"
- } else if ext == ".docx" || ext == ".pptx" || ext == ".xlsx" || ext == ".pdf" {
+ } else if ext == ".docx" || ext == ".pptx" || ext == ".xlsx" {
mimeType = "application/"
} else {
continue
@@ -148,8 +148,6 @@ 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(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 {
@@ -213,38 +211,6 @@ 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
diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx
index 1982cab..f064d5f 100644
--- a/frontend/src/components/FileExplorer.tsx
+++ b/frontend/src/components/FileExplorer.tsx
@@ -1159,7 +1159,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
- const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls', 'pdf'].includes(ext) || file.mime_type?.includes('officedocument') || file.mime_type === 'application/pdf';
+ const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(ext) || file.mime_type?.includes('officedocument');
const isAudio = ['mp3', 'flac', 'wav', 'm4a', 'wma', 'aac'].includes(ext) || file.mime_type?.startsWith('audio/');
const isMedia = isImage || isVideo || isOffice || isAudio;
@@ -1240,7 +1240,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
})();
if (isMedia && file.checksum && viewMode === 'grid') {
- const docType = (file.name.endsWith('.docx') || file.name.endsWith('.pdf')) ? 'docs' : file.name.endsWith('.pptx') ? 'slides' : (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'sheets' : null;
+ const docType = file.name.endsWith('.docx') ? 'docs' : file.name.endsWith('.pptx') ? 'slides' : (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) ? 'sheets' : null;
return (
diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx
index 841677d..4b4bbee 100644
--- a/frontend/src/components/PdfViewer.tsx
+++ b/frontend/src/components/PdfViewer.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
@@ -17,9 +17,8 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
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;
+ const pageWidth = Math.min(containerWidth * 0.9, 900);
+ const estimatedHeight = pageWidth * 1.414 * scale;
return (
@@ -47,7 +46,7 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
) : (
)}
@@ -57,13 +56,10 @@ function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; s
export default function PdfViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState();
const [scale, setScale] = useState(1.0);
- const [blobUrl, setBlobUrl] = useState(null);
- const [fetchError, setFetchError] = useState(null);
- const [fetchingPdf, setFetchingPdf] = useState(true);
+ const [loadError, setLoadError] = useState(null);
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(800);
- // Measure container width for responsive page sizing
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -74,57 +70,20 @@ export default function PdfViewer({ url }: { url: string }) {
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);
+ setLoadError(null);
}
- if (fetchingPdf) {
- return (
-
-
- Loading PDF...
-
- );
- }
-
- if (fetchError) {
+ if (loadError) {
return (
-
{fetchError}
+
{loadError}
@@ -167,12 +126,20 @@ export default function PdfViewer({ url }: { url: string }) {
{/* PDF Content */}
console.error('Failed to load PDF:', error)}
+ onLoadError={(error) => {
+ console.error('Failed to load PDF:', error);
+ setLoadError('Failed to load PDF file.');
+ }}
+ options={{
+ disableAutoFetch: true,
+ disableStream: true,
+ }}
loading={
-
+
+ Loading PDF...
}
className="flex flex-col items-center gap-2 w-full"
diff --git a/frontend/src/components/ThreeMfPreview.tsx b/frontend/src/components/ThreeMfPreview.tsx
index 414a629..ac75f22 100644
--- a/frontend/src/components/ThreeMfPreview.tsx
+++ b/frontend/src/components/ThreeMfPreview.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo, useRef } from 'react';
+import React, { useState, useEffect } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
@@ -15,7 +15,6 @@ function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onEr
useEffect(() => {
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();
@@ -25,32 +24,38 @@ function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onEr
(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
+ // Compute bounding box and center the model
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
+ // Adjust camera clipping planes and position based on model size
const maxDim = Math.max(size.x, size.y, size.z);
+ const perspCam = camera as THREE.PerspectiveCamera;
+
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);
+ // Set clipping planes generously based on model size
+ perspCam.near = maxDim * 0.001;
+ perspCam.far = maxDim * 100;
+
+ // Position camera to see the whole model
+ const fov = perspCam.fov * (Math.PI / 180);
+ const dist = (maxDim / 2) / Math.tan(fov / 2) * 2.0;
+ perspCam.position.set(dist * 0.6, dist * 0.4, dist * 0.8);
+ perspCam.lookAt(0, 0, 0);
+ perspCam.updateProjectionMatrix();
}
setModel(group);
@@ -97,7 +102,12 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
)}
-