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) { )}
- + @@ -113,7 +123,7 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) { }} /> - +