diff --git a/.gitignore b/.gitignore index 519c28f4a..f21e4db2d 100644 --- a/.gitignore +++ b/.gitignore @@ -189,8 +189,7 @@ id_ed25519.pub .pytest_cache .ipynb_checkpoints -# Claude.ai assistant files -CLAUDE.md + **/jcef-bundle/ diff --git a/frontend/src/components/fileManagement/FileManager.tsx b/frontend/src/components/fileManagement/FileManager.tsx new file mode 100644 index 000000000..370ea32ec --- /dev/null +++ b/frontend/src/components/fileManagement/FileManager.tsx @@ -0,0 +1,423 @@ +import React, { useState, useEffect } from "react"; +import { Box, Flex, Text, Notification, Button, Group } 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, initializeStorageConfig, StorageConfig } from "../../types/file"; + +// Refactored imports +import { fileOperationsService } from "../../services/fileOperationsService"; +import { checkStorageWarnings } from "../../utils/storageUtils"; +import StorageStatsCard from "./StorageStatsCard"; +import FileCard from "./FileCard"; +import FileUploadSelector from "../shared/FileUploadSelector"; + +GlobalWorkerOptions.workerSrc = "/pdf.worker.js"; + +interface FileManagerProps { + files: FileWithUrl[]; + setFiles: React.Dispatch>; + allowMultiple?: boolean; + setCurrentView?: (view: string) => void; + onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void; + onLoadFileToActive?: (file: File) => void; +} + +const FileManager = ({ + files = [], + setFiles, + allowMultiple = true, + setCurrentView, + onOpenFileEditor, + onLoadFileToActive, +}: FileManagerProps) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [storageStats, setStorageStats] = useState(null); + const [notification, setNotification] = useState(null); + const [filesLoaded, setFilesLoaded] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [storageConfig, setStorageConfig] = useState(defaultStorageConfig); + + // 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]); + + // Initialize storage configuration on mount + useEffect(() => { + const initStorage = async () => { + try { + const config = await initializeStorageConfig(); + setStorageConfig(config); + console.log('Initialized storage config:', config); + } catch (error) { + console.warn('Failed to initialize storage config, using defaults:', error); + } + }; + + initStorage(); + }, []); + + // 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(t("fileManager.storageCleared", "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 validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => { + // Check individual file sizes + for (const file of filesToUpload) { + if (file.size > storageConfig.maxFileSize) { + const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024)); + return { + valid: false, + error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB` + }; + } + } + + // Check total storage capacity + if (storageStats) { + const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0); + const projectedUsage = storageStats.totalSize + totalNewSize; + + if (projectedUsage > storageConfig.maxTotalStorage) { + return { + valid: false, + error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.") + }; + } + } + + return { valid: true }; + }; + + const handleDrop = async (uploadedFiles: File[]) => { + setLoading(true); + + try { + // Validate storage limits before uploading + const validation = validateStorageLimits(uploadedFiles); + if (!validation.valid) { + setNotification(validation.error); + setLoading(false); + return; + } + + const newFiles = await uploadFiles(uploadedFiles, storageConfig.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) => { + try { + // Reconstruct File object from storage and add to active files + if (onLoadFileToActive) { + const reconstructedFile = await reconstructFileFromStorage(file); + onLoadFileToActive(reconstructedFile); + setCurrentView && setCurrentView("viewer"); + } + } catch (error) { + console.error('Failed to load file to active set:', error); + setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage.")); + } + }; + + const handleFileView = async (file: FileWithUrl) => { + try { + // Reconstruct File object from storage and add to active files + if (onLoadFileToActive) { + const reconstructedFile = await reconstructFileFromStorage(file); + onLoadFileToActive(reconstructedFile); + setCurrentView && setCurrentView("viewer"); + } + } catch (error) { + console.error('Failed to load file to active set:', error); + setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage.")); + } + }; + + const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise => { + // If it's already a regular file, return it + if (fileWithUrl instanceof File) { + return fileWithUrl; + } + + // Reconstruct from IndexedDB + const arrayBuffer = await createBlobUrlForFile(fileWithUrl); + if (typeof arrayBuffer === 'string') { + // createBlobUrlForFile returned a blob URL, we need the actual data + const response = await fetch(arrayBuffer); + const data = await response.arrayBuffer(); + return new File([data], fileWithUrl.name, { + type: fileWithUrl.type || 'application/pdf', + lastModified: fileWithUrl.lastModified || Date.now() + }); + } else { + return new File([arrayBuffer], fileWithUrl.name, { + type: fileWithUrl.type || 'application/pdf', + lastModified: fileWithUrl.lastModified || Date.now() + }); + } + }; + + const handleFileEdit = (file: FileWithUrl) => { + if (onOpenFileEditor) { + onOpenFileEditor([file]); + } + }; + + const toggleFileSelection = (fileId: string) => { + setSelectedFiles(prev => + prev.includes(fileId) + ? prev.filter(id => id !== fileId) + : [...prev, fileId] + ); + }; + + const handleOpenSelectedInEditor = () => { + if (onOpenFileEditor && selectedFiles.length > 0) { + const selected = files.filter(f => selectedFiles.includes(f.id || f.name)); + onOpenFileEditor(selected); + } + }; + + return ( +
+ + {/* File upload is now handled by FileUploadSelector when no files exist */} + + {/* Storage Stats Card */} + + + {/* Multi-selection controls */} + {selectedFiles.length > 0 && ( + + + + {selectedFiles.length} {t("fileManager.filesSelected", "files selected")} + + + + + + + + )} + + + + {files.map((file, idx) => ( + handleRemoveFile(idx)} + onDoubleClick={() => handleFileDoubleClick(file)} + onView={() => handleFileView(file)} + onEdit={() => handleFileEdit(file)} + isSelected={selectedFiles.includes(file.id || file.name)} + onSelect={() => toggleFileSelection(file.id || file.name)} + /> + ))} + + + + {/* Notifications */} + {notification && ( + setNotification(null)} + style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }} + > + {notification} + + )} +
+ ); +}; + +export default FileManager; diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx new file mode 100644 index 000000000..8c2e89c78 --- /dev/null +++ b/frontend/src/components/viewer/Viewer.tsx @@ -0,0 +1,567 @@ +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 = ({ + pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef +}: LazyPageImageProps) => { + 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; // First file in the array + setPdfFile: (file: { file: File; url: string } | null) => void; + sidebarsVisible: boolean; + setSidebarsVisible: (v: boolean) => void; +} + +const Viewer = ({ + pdfFile, + setPdfFile, + sidebarsVisible, + setSidebarsVisible, +}: ViewerProps) => { + 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; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 427dd6645..acd8a85b6 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,24 +1,29 @@ import React, { useState, useCallback, useEffect } from "react"; import { useTranslation } from 'react-i18next'; import { useSearchParams } from "react-router-dom"; +import { useToolParams } from "../hooks/useToolParams"; +import { useFileWithUrl } from "../hooks/useFileWithUrl"; +import { fileStorage } from "../services/fileStorage"; import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; -import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; -import VisibilityIcon from "@mui/icons-material/Visibility"; -import EditNoteIcon from "@mui/icons-material/EditNote"; -import { Group, SegmentedControl, Paper, Center, Box, Button, useMantineTheme, useMantineColorScheme } from "@mantine/core"; +import { Group, Paper, Box, Button, useMantineTheme, Container } from "@mantine/core"; +import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; +import rainbowStyles from '../styles/rainbow.module.css'; -import ToolPicker from "../components/ToolPicker"; -import FileManager from "../components/FileManager"; +import ToolPicker from "../components/tools/ToolPicker"; +import TopControls from "../components/shared/TopControls"; +import FileManager from "../components/fileManagement/FileManager"; +import FileEditor from "../components/editor/FileEditor"; +import PageEditor from "../components/editor/PageEditor"; +import PageEditorControls from "../components/editor/PageEditorControls"; +import Viewer from "../components/viewer/Viewer"; +import FileUploadSelector from "../components/shared/FileUploadSelector"; import SplitPdfPanel from "../tools/Split"; import CompressPdfPanel from "../tools/Compress"; import MergePdfPanel from "../tools/Merge"; -import PageEditor from "../components/PageEditor"; -import Viewer from "../components/Viewer"; -import LanguageSelector from "../components/LanguageSelector"; -import DarkModeIcon from '@mui/icons-material/DarkMode'; -import LightModeIcon from '@mui/icons-material/LightMode'; +import ToolRenderer from "../components/tools/ToolRenderer"; +import QuickAccessBar from "../components/shared/QuickAccessBar"; type ToolRegistryEntry = { icon: React.ReactNode; @@ -38,412 +43,344 @@ const baseToolRegistry = { merge: { icon: , component: MergePdfPanel, view: "fileManager" }, }; -const VIEW_OPTIONS = [ - { - label: ( - - - - ), - value: "viewer", - }, - { - label: ( - - - - ), - value: "pageEditor", - }, - { - label: ( - - - - ), - value: "fileManager", - }, -]; - -// Utility to extract params for a tool from searchParams -function getToolParams(toolKey: string, searchParams: URLSearchParams) { - switch (toolKey) { - case "split": - return { - mode: searchParams.get("splitMode") || "byPages", - pages: searchParams.get("pages") || "", - hDiv: searchParams.get("hDiv") || "", - vDiv: searchParams.get("vDiv") || "", - merge: searchParams.get("merge") === "true", - splitType: searchParams.get("splitType") || "size", - splitValue: searchParams.get("splitValue") || "", - bookmarkLevel: searchParams.get("bookmarkLevel") || "0", - includeMetadata: searchParams.get("includeMetadata") === "true", - allowDuplicates: searchParams.get("allowDuplicates") === "true", - }; - case "compress": - return { - compressionLevel: parseInt(searchParams.get("compressionLevel") || "5"), - grayscale: searchParams.get("grayscale") === "true", - removeMetadata: searchParams.get("removeMetadata") === "true", - expectedSize: searchParams.get("expectedSize") || "", - aggressive: searchParams.get("aggressive") === "true", - }; - case "merge": - return { - order: searchParams.get("mergeOrder") || "default", - removeDuplicates: searchParams.get("removeDuplicates") === "true", - }; - // Add more tools here as needed - default: - return {}; - } -} - -// Utility to update params for a tool -function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) { - const params = new URLSearchParams(searchParams); - - // Clear tool-specific params - if (toolKey === "split") { - [ - "splitMode", "pages", "hDiv", "vDiv", "merge", - "splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates" - ].forEach((k) => params.delete(k)); - // Set new split params - const merged = { ...getToolParams("split", searchParams), ...newParams }; - params.set("splitMode", merged.mode); - if (merged.mode === "byPages") params.set("pages", merged.pages); - else if (merged.mode === "bySections") { - params.set("hDiv", merged.hDiv); - params.set("vDiv", merged.vDiv); - params.set("merge", String(merged.merge)); - } else if (merged.mode === "bySizeOrCount") { - params.set("splitType", merged.splitType); - params.set("splitValue", merged.splitValue); - } else if (merged.mode === "byChapters") { - params.set("bookmarkLevel", merged.bookmarkLevel); - params.set("includeMetadata", String(merged.includeMetadata)); - params.set("allowDuplicates", String(merged.allowDuplicates)); - } - } else if (toolKey === "compress") { - ["compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive"].forEach((k) => params.delete(k)); - const merged = { ...getToolParams("compress", searchParams), ...newParams }; - params.set("compressionLevel", String(merged.compressionLevel)); - params.set("grayscale", String(merged.grayscale)); - params.set("removeMetadata", String(merged.removeMetadata)); - if (merged.expectedSize) params.set("expectedSize", merged.expectedSize); - params.set("aggressive", String(merged.aggressive)); - } else if (toolKey === "merge") { - ["mergeOrder", "removeDuplicates"].forEach((k) => params.delete(k)); - const merged = { ...getToolParams("merge", searchParams), ...newParams }; - params.set("mergeOrder", merged.order); - params.set("removeDuplicates", String(merged.removeDuplicates)); - } - // Add more tools as needed - - setSearchParams(params, { replace: true }); -} - -// List of all tool-specific params -const TOOL_PARAMS = { - split: [ - "splitMode", "pages", "hDiv", "vDiv", "merge", - "splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates" - ], - compress: [ - "compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive" - ], - merge: [ - "mergeOrder", "removeDuplicates" - ] - // Add more tools as needed -}; - export default function HomePage() { const { t } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const theme = useMantineTheme(); - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); + const { isRainbowMode } = useRainbowThemeContext(); + + // Core app state + const [selectedToolKey, setSelectedToolKey] = useState(searchParams.get("t") || "split"); + const [currentView, setCurrentView] = useState(searchParams.get("v") || "viewer"); + + // File state separation + const [storedFiles, setStoredFiles] = useState([]); // IndexedDB files (FileManager) + const [activeFiles, setActiveFiles] = useState([]); // Active working set (persisted) + const [preSelectedFiles, setPreSelectedFiles] = useState([]); + + const [downloadUrl, setDownloadUrl] = useState(null); + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + + // Page editor functions + const [pageEditorFunctions, setPageEditorFunctions] = useState(null); + + // URL parameter management + const { toolParams, updateParams } = useToolParams(selectedToolKey, currentView); + + // Persist active files across reloads + useEffect(() => { + // Save active files to localStorage (just metadata) + const activeFileData = activeFiles.map(file => ({ + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + })); + localStorage.setItem('activeFiles', JSON.stringify(activeFileData)); + }, [activeFiles]); + + // Load stored files from IndexedDB on mount + useEffect(() => { + const loadStoredFiles = async () => { + try { + const files = await fileStorage.getAllFiles(); + setStoredFiles(files); + } catch (error) { + console.warn('Failed to load stored files:', error); + } + }; + loadStoredFiles(); + }, []); + + // Restore active files on load + useEffect(() => { + const restoreActiveFiles = async () => { + try { + const savedFileData = JSON.parse(localStorage.getItem('activeFiles') || '[]'); + if (savedFileData.length > 0) { + // TODO: Reconstruct files from IndexedDB when fileStorage is available + console.log('Would restore active files:', savedFileData); + } + } catch (error) { + console.warn('Failed to restore active files:', error); + } + }; + restoreActiveFiles(); + }, []); - // Create translated tool registry const toolRegistry: ToolRegistry = { split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") }, compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") }, merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") }, }; - // Core app state - const [selectedToolKey, setSelectedToolKey] = useState(searchParams.get("tool") || "split"); - const [currentView, setCurrentView] = useState(searchParams.get("view") || "viewer"); - const [pdfFile, setPdfFile] = useState(null); - const [files, setFiles] = useState([]); - const [downloadUrl, setDownloadUrl] = useState(null); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - - const toolParams = getToolParams(selectedToolKey, searchParams); - - const updateParams = (newParams: any) => - updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams); - - // Update URL when core state changes - useEffect(() => { - const params = new URLSearchParams(searchParams); - - // Remove all tool-specific params except for the current tool - Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => { - if (tool !== selectedToolKey) { - keys.forEach((k) => params.delete(k)); - } - }); - - // Collect all params except 'view' - const entries = Array.from(params.entries()).filter(([key]) => key !== "view"); - - // Rebuild params with 'view' first - const newParams = new URLSearchParams(); - newParams.set("view", currentView); - newParams.set("tool", selectedToolKey); - entries.forEach(([key, value]) => { - if (key !== "tool") newParams.set(key, value); - }); - - setSearchParams(newParams, { replace: true }); - }, [selectedToolKey, currentView, setSearchParams, searchParams]); - // Handle tool selection const handleToolSelect = useCallback( (id: string) => { setSelectedToolKey(id); if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); + setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected + setReaderMode(false); // Exit reader mode when selecting a tool }, [toolRegistry] ); + // Handle quick access actions + const handleQuickAccessTools = useCallback(() => { + setLeftPanelView('toolPicker'); + setReaderMode(false); + }, []); + + const handleReaderToggle = useCallback(() => { + setReaderMode(!readerMode); + }, [readerMode]); + + // Update URL when view changes + const handleViewChange = useCallback((view: string) => { + setCurrentView(view); + const params = new URLSearchParams(window.location.search); + params.set('view', view); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, '', newUrl); + }, []); + + // Active file management + const addToActiveFiles = useCallback((file: File) => { + setActiveFiles(prev => { + // Avoid duplicates based on name and size + const exists = prev.some(f => f.name === file.name && f.size === file.size); + if (exists) return prev; + return [file, ...prev]; + }); + }, []); + + const removeFromActiveFiles = useCallback((file: File) => { + setActiveFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size))); + }, []); + + const setCurrentActiveFile = useCallback((file: File) => { + setActiveFiles(prev => { + const filtered = prev.filter(f => !(f.name === file.name && f.size === file.size)); + return [file, ...filtered]; + }); + }, []); + + // Handle file selection from upload (adds to active files) + const handleFileSelect = useCallback((file: File) => { + addToActiveFiles(file); + }, [addToActiveFiles]); + + // Handle opening file editor with selected files + const handleOpenFileEditor = useCallback((selectedFiles) => { + setPreSelectedFiles(selectedFiles || []); + handleViewChange("fileEditor"); + }, [handleViewChange]); + const selectedTool = toolRegistry[selectedToolKey]; - // Tool component rendering - const renderTool = () => { - if (!selectedTool || !selectedTool.component) { - return
Tool not found
; - } - - // Pass tool-specific props - switch (selectedToolKey) { - case "split": - return React.createElement(selectedTool.component, { - file: pdfFile, - downloadUrl, - setDownloadUrl, - params: toolParams, - updateParams, - }); - case "compress": - return React.createElement(selectedTool.component, { - files, - setDownloadUrl, - setLoading: (loading: boolean) => {}, // TODO: Add loading state - params: toolParams, - updateParams, - }); - case "merge": - return React.createElement(selectedTool.component, { - files, - setDownloadUrl, - params: toolParams, - updateParams, - }); - default: - return React.createElement(selectedTool.component, { - files, - setDownloadUrl, - params: toolParams, - updateParams, - }); - } - }; + // Convert current active file to format expected by Viewer/PageEditor + const currentFileWithUrl = useFileWithUrl(activeFiles[0] || null); return ( - {/* Left: Tool Picker */} - {sidebarsVisible && ( - - - - )} + {/* Quick Access Bar */} + - {/* Middle: Main View */} - - {/* Overlayed View Switcher + Theme Toggle */} -
- - + {leftPanelView === 'toolPicker' ? ( + // Tool Picker View +
+ +
+ ) : ( + // Selected Tool Content View +
+ {/* Back button */} +
+ +
+ + {/* Tool title */} +
+

{selectedTool?.name}

+
+ + {/* Tool content */} +
+ +
+
+ )}
-
- -
-
+
+ + {/* Main View */} + + {/* Top Controls */} + {/* Main content area */} - - - {(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? ( + + {currentView === "fileManager" ? ( - ) : currentView === "viewer" ? ( + ) : (currentView != "fileManager") && !activeFiles[0] ? ( + + { + addToActiveFiles(file); + }} + allowMultiple={false} + accept={["application/pdf"]} + loading={false} + /> + + ) : currentView === "fileEditor" ? ( + setPreSelectedFiles([])} + onOpenPageEditor={(file) => { + setCurrentActiveFile(file); + handleViewChange("pageEditor"); + }} + onMergeFiles={(filesToMerge) => { + // Add merged files to active set + filesToMerge.forEach(addToActiveFiles); + handleViewChange("viewer"); + }} + /> + ) : currentView === "viewer" ? ( { + if (fileObj) { + setCurrentActiveFile(fileObj.file); + } else { + setActiveFiles([]); + } + }} sidebarsVisible={sidebarsVisible} setSidebarsVisible={setSidebarsVisible} /> ) : currentView === "pageEditor" ? ( - + <> + { + if (fileObj) { + setCurrentActiveFile(fileObj.file); + } else { + setActiveFiles([]); + } + }} + downloadUrl={downloadUrl} + setDownloadUrl={setDownloadUrl} + onFunctionsReady={setPageEditorFunctions} + sharedFiles={activeFiles} + /> + {activeFiles[0] && pageEditorFunctions && ( + pageEditorFunctions.showExportPreview(true)} + onExportAll={() => pageEditorFunctions.showExportPreview(false)} + exportLoading={pageEditorFunctions.exportLoading} + selectionMode={pageEditorFunctions.selectionMode} + selectedPages={pageEditorFunctions.selectedPages} + /> + )} + ) : ( )} - - - {/* Right: Tool Interaction */} - {sidebarsVisible && ( - - {selectedTool && selectedTool.component && renderTool()} - - )} - - {/* Sidebar toggle button */} - ); } diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index f2915e32e..c887c093b 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -11,9 +11,40 @@ export interface FileWithUrl extends File { export interface StorageConfig { useIndexedDB: boolean; - // Simplified - no thresholds needed, IndexedDB for everything + maxFileSize: number; // Maximum size per file in bytes + maxTotalStorage: number; // Maximum total storage in bytes + warningThreshold: number; // Warning threshold (percentage 0-1) } export const defaultStorageConfig: StorageConfig = { useIndexedDB: true, + maxFileSize: 100 * 1024 * 1024, // 100MB per file + maxTotalStorage: 1024 * 1024 * 1024, // 1GB default, will be updated dynamically + warningThreshold: 0.8, // Warn at 80% capacity +}; + +// Calculate and update storage limit: half of available storage or 10GB, whichever is smaller +export const initializeStorageConfig = async (): Promise => { + const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes + const oneGB = 1024 * 1024 * 1024; // 1GB fallback + + let maxTotalStorage = oneGB; // Default fallback + + // Try to estimate available storage + if ('storage' in navigator && 'estimate' in navigator.storage) { + try { + const estimate = await navigator.storage.estimate(); + if (estimate.quota) { + const halfQuota = estimate.quota / 2; + maxTotalStorage = Math.min(halfQuota, tenGB); + } + } catch (error) { + console.warn('Could not estimate storage quota, using 1GB default:', error); + } + } + + return { + ...defaultStorageConfig, + maxTotalStorage + }; }; \ No newline at end of file