diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx deleted file mode 100644 index b436cd87b..000000000 --- a/frontend/src/components/FileManager.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Box, Flex, Text, Notification } from "@mantine/core"; -import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; -import { useTranslation } from "react-i18next"; - -import { GlobalWorkerOptions } from "pdfjs-dist"; -import { StorageStats } from "../services/fileStorage"; -import { FileWithUrl, defaultStorageConfig } from "../types/file"; - -// Refactored imports -import { fileOperationsService } from "../services/fileOperationsService"; -import { checkStorageWarnings } from "../utils/storageUtils"; -import StorageStatsCard from "./StorageStatsCard"; -import FileCard from "./FileCard.standalone"; - -GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; - -interface FileManagerProps { - files: FileWithUrl[]; - setFiles: React.Dispatch>; - allowMultiple?: boolean; - setPdfFile?: (fileObj: { file: File; url: string }) => void; - setCurrentView?: (view: string) => void; -} - -const FileManager: React.FC = ({ - files = [], - setFiles, - allowMultiple = true, - setPdfFile, - setCurrentView, -}) => { - const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [storageStats, setStorageStats] = useState(null); - const [notification, setNotification] = useState(null); - const [filesLoaded, setFilesLoaded] = useState(false); - - // Extract operations from service for cleaner code - const { - loadStorageStats, - forceReloadFiles, - loadExistingFiles, - uploadFiles, - removeFile, - clearAllFiles, - createBlobUrlForFile, - checkForPurge, - updateStorageStatsIncremental - } = fileOperationsService; - - // Add CSS for spinner animation - useEffect(() => { - if (!document.querySelector('#spinner-animation')) { - const style = document.createElement('style'); - style.id = 'spinner-animation'; - style.textContent = ` - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - `; - document.head.appendChild(style); - } - }, []); - - // Load existing files from IndexedDB on mount - useEffect(() => { - if (!filesLoaded) { - handleLoadExistingFiles(); - } - }, [filesLoaded]); - - // Load storage stats and set up periodic updates - useEffect(() => { - handleLoadStorageStats(); - - const interval = setInterval(async () => { - await handleLoadStorageStats(); - await handleCheckForPurge(); - }, 10000); // Update every 10 seconds - - return () => clearInterval(interval); - }, []); - - // Sync UI with IndexedDB whenever storage stats change - useEffect(() => { - const syncWithStorage = async () => { - if (storageStats && filesLoaded) { - // If file counts don't match, force reload - if (storageStats.fileCount !== files.length) { - console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload'); - const reloadedFiles = await forceReloadFiles(); - setFiles(reloadedFiles); - } - } - }; - - syncWithStorage(); - }, [storageStats, filesLoaded, files.length]); - - // Handlers using extracted operations - const handleLoadStorageStats = async () => { - const stats = await loadStorageStats(); - if (stats) { - setStorageStats(stats); - - // Check for storage warnings - const warning = checkStorageWarnings(stats); - if (warning) { - setNotification(warning); - } - } - }; - - const handleLoadExistingFiles = async () => { - try { - const loadedFiles = await loadExistingFiles(filesLoaded, files); - setFiles(loadedFiles); - setFilesLoaded(true); - } catch (error) { - console.error('Failed to load existing files:', error); - setFilesLoaded(true); - } - }; - - const handleCheckForPurge = async () => { - try { - const isPurged = await checkForPurge(files); - if (isPurged) { - console.warn('IndexedDB purge detected - forcing UI reload'); - setNotification('Browser cleared storage. Files have been removed. Please re-upload.'); - const reloadedFiles = await forceReloadFiles(); - setFiles(reloadedFiles); - setFilesLoaded(true); - } - } catch (error) { - console.error('Error checking for purge:', error); - } - }; - - const handleDrop = async (uploadedFiles: File[]) => { - setLoading(true); - - try { - const newFiles = await uploadFiles(uploadedFiles, defaultStorageConfig.useIndexedDB); - - // Update files state - setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles)); - - // Update storage stats incrementally - if (storageStats) { - const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles); - setStorageStats(updatedStats); - - // Check for storage warnings - const warning = checkStorageWarnings(updatedStats); - if (warning) { - setNotification(warning); - } - } - } catch (error) { - console.error('Error handling file drop:', error); - setNotification(t("fileManager.uploadError", "Failed to upload some files.")); - } finally { - setLoading(false); - } - }; - - const handleRemoveFile = async (index: number) => { - const file = files[index]; - - try { - await removeFile(file); - - // Update storage stats incrementally - if (storageStats) { - const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]); - setStorageStats(updatedStats); - } - - setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); - } catch (error) { - console.error('Failed to remove file:', error); - } - }; - - const handleClearAll = async () => { - try { - await clearAllFiles(files); - - // Reset storage stats - if (storageStats) { - const clearedStats = updateStorageStatsIncremental(storageStats, 'clear'); - setStorageStats(clearedStats); - } - - setFiles([]); - } catch (error) { - console.error('Failed to clear all files:', error); - } - }; - - const handleReloadFiles = () => { - setFilesLoaded(false); - setFiles([]); - }; - - const handleFileDoubleClick = async (file: FileWithUrl) => { - if (setPdfFile) { - try { - const url = await createBlobUrlForFile(file); - setPdfFile({ file: file, url: url }); - setCurrentView && setCurrentView("viewer"); - } catch (error) { - console.error('Failed to create blob URL for file:', error); - setNotification('Failed to open file. It may have been removed from storage.'); - } - } - }; - - return ( -
- - {/* File Upload Dropzone */} - - - {t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")} - - - - {/* Storage Stats Card */} - - - {/* Files Display */} - {files.length === 0 ? ( - - {t("noFileSelected", "No files uploaded yet.")} - - ) : ( - - - {files.map((file, idx) => ( - handleRemoveFile(idx)} - onDoubleClick={() => handleFileDoubleClick(file)} - as FileWithUrl /> - ))} - - - )} - - {/* Notifications */} - {notification && ( - setNotification(null)} - style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }} - > - {notification} - - )} -
- ); -}; - -export default FileManager; diff --git a/frontend/src/components/Viewer.tsx b/frontend/src/components/Viewer.tsx deleted file mode 100644 index 14143b916..000000000 --- a/frontend/src/components/Viewer.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core"; -import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; -import { useTranslation } from "react-i18next"; -import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; -import FirstPageIcon from "@mui/icons-material/FirstPage"; -import LastPageIcon from "@mui/icons-material/LastPage"; -import ViewSidebarIcon from "@mui/icons-material/ViewSidebar"; -import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book) -import DescriptionIcon from "@mui/icons-material/Description"; // for single page -import { useLocalStorage } from "@mantine/hooks"; -import { fileStorage } from "../services/fileStorage"; - -GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; - -// Lazy loading page image component -interface LazyPageImageProps { - pageIndex: number; - zoom: number; - theme: any; - isFirst: boolean; - renderPage: (pageIndex: number) => Promise; - pageImages: (string | null)[]; - setPageRef: (index: number, ref: HTMLImageElement | null) => void; -} - -const LazyPageImage: React.FC = ({ - pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef -}) => { - const [isVisible, setIsVisible] = useState(false); - const [imageUrl, setImageUrl] = useState(pageImages[pageIndex]); - const imgRef = useRef(null); - - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !imageUrl) { - setIsVisible(true); - } - }); - }, - { - rootMargin: '200px', // Start loading 200px before visible - threshold: 0.1 - } - ); - - if (imgRef.current) { - observer.observe(imgRef.current); - } - - return () => observer.disconnect(); - }, [imageUrl]); - - useEffect(() => { - if (isVisible && !imageUrl) { - renderPage(pageIndex).then((url) => { - if (url) setImageUrl(url); - }); - } - }, [isVisible, imageUrl, pageIndex, renderPage]); - - useEffect(() => { - if (imgRef.current) { - setPageRef(pageIndex, imgRef.current); - } - }, [pageIndex, setPageRef]); - - if (imageUrl) { - return ( - {`Page - ); - } - - // Placeholder while loading - return ( -
- {isVisible ? ( -
-
- Loading page {pageIndex + 1}... -
- ) : ( - Page {pageIndex + 1} - )} -
- ); -}; - -export interface ViewerProps { - pdfFile: { file: File; url: string } | null; - setPdfFile: (file: { file: File; url: string } | null) => void; - sidebarsVisible: boolean; - setSidebarsVisible: (v: boolean) => void; -} - -const Viewer: React.FC = ({ - pdfFile, - setPdfFile, - sidebarsVisible, - setSidebarsVisible, -}) => { - const { t } = useTranslation(); - const theme = useMantineTheme(); - const [numPages, setNumPages] = useState(0); - const [pageImages, setPageImages] = useState([]); - const [loading, setLoading] = useState(false); - const [currentPage, setCurrentPage] = useState(null); - const [dualPage, setDualPage] = useState(false); - const [zoom, setZoom] = useState(1); // 1 = 100% - const pageRefs = useRef<(HTMLImageElement | null)[]>([]); - const scrollAreaRef = useRef(null); - const userInitiatedRef = useRef(false); - const suppressScrollRef = useRef(false); - const pdfDocRef = useRef(null); - const renderingPagesRef = useRef>(new Set()); - const currentArrayBufferRef = useRef(null); - - // Function to render a specific page on-demand - const renderPage = async (pageIndex: number): Promise => { - if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) { - return null; - } - - const pageNum = pageIndex + 1; - if (pageImages[pageIndex]) { - return pageImages[pageIndex]; // Already rendered - } - - renderingPagesRef.current.add(pageIndex); - - try { - const page = await pdfDocRef.current.getPage(pageNum); - const viewport = page.getViewport({ scale: 1.2 }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext("2d"); - - if (ctx) { - await page.render({ canvasContext: ctx, viewport }).promise; - const dataUrl = canvas.toDataURL(); - - // Update the pageImages array - setPageImages(prev => { - const newImages = [...prev]; - newImages[pageIndex] = dataUrl; - return newImages; - }); - - renderingPagesRef.current.delete(pageIndex); - return dataUrl; - } - } catch (error) { - console.error(`Failed to render page ${pageNum}:`, error); - } - - renderingPagesRef.current.delete(pageIndex); - return null; - }; - - // Listen for hash changes and update currentPage - useEffect(() => { - function handleHashChange() { - if (window.location.hash.startsWith("#page=")) { - const page = parseInt(window.location.hash.replace("#page=", ""), 10); - if (!isNaN(page) && page >= 1 && page <= numPages) { - setCurrentPage(page); - } - } - userInitiatedRef.current = false; - } - window.addEventListener("hashchange", handleHashChange); - handleHashChange(); // Run on mount - return () => window.removeEventListener("hashchange", handleHashChange); - }, [numPages]); - - // Scroll to the current page when it changes - useEffect(() => { - if (currentPage && pageRefs.current[currentPage - 1]) { - suppressScrollRef.current = true; - const el = pageRefs.current[currentPage - 1]; - el?.scrollIntoView({ behavior: "smooth", block: "center" }); - - // Try to use scrollend if supported - const viewport = scrollAreaRef.current; - let timeout: NodeJS.Timeout | null = null; - let scrollEndHandler: (() => void) | null = null; - - if (viewport && "onscrollend" in viewport) { - scrollEndHandler = () => { - suppressScrollRef.current = false; - viewport.removeEventListener("scrollend", scrollEndHandler!); - }; - viewport.addEventListener("scrollend", scrollEndHandler); - } else { - // Fallback for non-Chromium browsers - timeout = setTimeout(() => { - suppressScrollRef.current = false; - }, 1000); - } - - return () => { - if (viewport && scrollEndHandler) { - viewport.removeEventListener("scrollend", scrollEndHandler); - } - if (timeout) clearTimeout(timeout); - }; - } - }, [currentPage, pageImages]); - - // Detect visible page on scroll and update hash - const handleScroll = () => { - if (suppressScrollRef.current) return; - const scrollArea = scrollAreaRef.current; - if (!scrollArea || !pageRefs.current.length) return; - - const areaRect = scrollArea.getBoundingClientRect(); - let closestIdx = 0; - let minDist = Infinity; - - pageRefs.current.forEach((img, idx) => { - if (img) { - const imgRect = img.getBoundingClientRect(); - const dist = Math.abs(imgRect.top - areaRect.top); - if (dist < minDist) { - minDist = dist; - closestIdx = idx; - } - } - }); - - if (currentPage !== closestIdx + 1) { - setCurrentPage(closestIdx + 1); - if (window.location.hash !== `#page=${closestIdx + 1}`) { - window.location.hash = `#page=${closestIdx + 1}`; - } - } - }; - - useEffect(() => { - let cancelled = false; - async function loadPdfInfo() { - if (!pdfFile || !pdfFile.url) { - setNumPages(0); - setPageImages([]); - return; - } - setLoading(true); - try { - let pdfUrl = pdfFile.url; - - // Handle special IndexedDB URLs for large files - if (pdfFile.url.startsWith('indexeddb:')) { - const fileId = pdfFile.url.replace('indexeddb:', ''); - console.log('Loading large file from IndexedDB:', fileId); - - // Get data directly from IndexedDB - const arrayBuffer = await fileStorage.getFileData(fileId); - if (!arrayBuffer) { - throw new Error('File not found in IndexedDB - may have been purged by browser'); - } - - // Store reference for cleanup - currentArrayBufferRef.current = arrayBuffer; - - // Use ArrayBuffer directly instead of creating blob URL - const pdf = await getDocument({ data: arrayBuffer }).promise; - pdfDocRef.current = pdf; - setNumPages(pdf.numPages); - if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null)); - } else { - // Standard blob URL or regular URL - const pdf = await getDocument(pdfUrl).promise; - pdfDocRef.current = pdf; - setNumPages(pdf.numPages); - if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null)); - } - } catch (error) { - console.error('Failed to load PDF:', error); - if (!cancelled) { - setPageImages([]); - setNumPages(0); - } - } - if (!cancelled) setLoading(false); - } - loadPdfInfo(); - return () => { - cancelled = true; - // Cleanup ArrayBuffer reference to help garbage collection - currentArrayBufferRef.current = null; - }; - }, [pdfFile]); - - useEffect(() => { - const viewport = scrollAreaRef.current; - if (!viewport) return; - const handler = () => { - handleScroll(); - }; - viewport.addEventListener("scroll", handler); - return () => viewport.removeEventListener("scroll", handler); - }, [pageImages]); - - return ( - - {!pdfFile ? ( -
- - {t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")} - - -
- ) : loading ? ( -
- -
- ) : ( - - - {numPages === 0 && ( - {t("viewer.noPagesToDisplay", "No pages to display.")} - )} - {dualPage - ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => ( - - { pageRefs.current[index] = ref; }} - /> - {i * 2 + 1 < numPages && ( - { pageRefs.current[index] = ref; }} - /> - )} - - )) - : Array.from({ length: numPages }).map((_, idx) => ( - { pageRefs.current[index] = ref; }} - /> - ))} - - {/* Navigation bar overlays the scroll area */} -
- - - - { - const page = Number(value); - if (!isNaN(page) && page >= 1 && page <= numPages) { - window.location.hash = `#page=${page}`; - } - }} - min={1} - max={numPages} - hideControls - styles={{ - input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16}, - }} - /> - - / {numPages} - - - - - - - - {Math.round(zoom * 100)}% - - - -
-
- )} - -
- ); -}; - -export default Viewer;