Resolve merge conflicts by using versions from Stirling-2.0

This commit is contained in:
Reece 2025-06-24 12:20:31 +01:00
parent b5d84db3c8
commit 16e56e3208
5 changed files with 1327 additions and 370 deletions

3
.gitignore vendored
View File

@ -189,8 +189,7 @@ id_ed25519.pub
.pytest_cache
.ipynb_checkpoints
# Claude.ai assistant files
CLAUDE.md
**/jcef-bundle/

View File

@ -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<React.SetStateAction<FileWithUrl[]>>;
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<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [storageConfig, setStorageConfig] = useState<StorageConfig>(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<File> => {
// 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 (
<div style={{
width: "100%",
justifyContent: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: "3rem"
}}>
{/* File upload is now handled by FileUploadSelector when no files exist */}
{/* Storage Stats Card */}
<StorageStatsCard
storageStats={storageStats}
filesCount={files.length}
onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles}
storageConfig={storageConfig}
/>
{/* Multi-selection controls */}
{selectedFiles.length > 0 && (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={() => setSelectedFiles([])}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
<Button
size="xs"
color="orange"
onClick={handleOpenSelectedInEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
</Group>
</Group>
</Box>
)}
<Flex
wrap="wrap"
gap="lg"
justify="flex-start"
style={{ width: "90%", marginTop: "1rem"}}
>
{files.map((file, idx) => (
<FileCard
key={file.id || file.name + idx}
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => handleFileDoubleClick(file)}
onView={() => handleFileView(file)}
onEdit={() => handleFileEdit(file)}
isSelected={selectedFiles.includes(file.id || file.name)}
onSelect={() => toggleFileSelection(file.id || file.name)}
/>
))}
</Flex>
{/* Notifications */}
{notification && (
<Notification
color="blue"
onClose={() => setNotification(null)}
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
>
{notification}
</Notification>
)}
</div>
);
};
export default FileManager;

View File

@ -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<string | null>;
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<string | null>(pageImages[pageIndex]);
const imgRef = useRef<HTMLImageElement>(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 (
<img
ref={imgRef}
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
}}
/>
);
}
// Placeholder while loading
return (
<div
ref={imgRef}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
height: 800 * zoom, // Estimated height
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
border: '1px dashed #ccc'
}}
>
{isVisible ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 8px'
}} />
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
</div>
) : (
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
)}
</div>
);
};
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<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number | null>(null);
const [dualPage, setDualPage] = useState(false);
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const userInitiatedRef = useRef(false);
const suppressScrollRef = useRef(false);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
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 ? (
<Center style={{ flex: 1 }}>
<Stack align="center">
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
<Button
component="label"
variant="outline"
color="blue"
>
{t("viewer.choosePdf", "Choose PDF")}
<input
type="file"
accept="application/pdf"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type === "application/pdf") {
const fileUrl = URL.createObjectURL(file);
setPdfFile({ file, url: fileUrl });
}
}}
/>
</Button>
</Stack>
</Center>
) : loading ? (
<Center style={{ flex: 1 }}>
<Loader size="lg" />
</Center>
) : (
<ScrollArea
style={{ flex: 1, height: "100vh", position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{numPages === 0 && (
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<LazyPageImage
pageIndex={i * 2}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
pageIndex={i * 2 + 1}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
)}
</Group>
))
: Array.from({ length: numPages }).map((_, idx) => (
<LazyPageImage
key={idx}
pageIndex={idx}
zoom={zoom}
theme={theme}
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
))}
</Stack>
{/* Navigation bar overlays the scroll area */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
style={{
display: "flex",
alignItems: "center",
gap: 10,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: 420,
maxWidth: 700,
flexWrap: "wrap",
}}
>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=1`;
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<FirstPageIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<ArrowBackIosNewIcon fontSize="small" />
</Button>
<NumberInput
value={currentPage || 1}
onChange={value => {
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},
}}
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {numPages}
</span>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${numPages}`;
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<LastPageIcon fontSize="small" />
</Button>
<Button
variant={dualPage ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={() => setDualPage(v => !v)}
style={{ minWidth: 36 }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
></Button>
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>+</Button>
</Group>
</Paper>
</div>
</ScrollArea>
)}
</>
);
};
export default Viewer;

View File

@ -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: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
};
const VIEW_OPTIONS = [
{
label: (
<Group gap={4}>
<VisibilityIcon fontSize="small" />
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
<EditNoteIcon fontSize="small" />
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
<InsertDriveFileIcon fontSize="small" />
</Group>
),
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<string>(searchParams.get("t") || "split");
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
// File state separation
const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager)
const [activeFiles, setActiveFiles] = useState<File[]>([]); // Active working set (persisted)
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(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<any>(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<string>(searchParams.get("tool") || "split");
const [currentView, setCurrentView] = useState<string>(searchParams.get("view") || "viewer");
const [pdfFile, setPdfFile] = useState<any>(null);
const [files, setFiles] = useState<any[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(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 <div>Tool not found</div>;
}
// 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 (
<Group
align="flex-start"
gap={0}
style={{
minHeight: "100vh",
width: "100vw",
overflow: "hidden",
flexWrap: "nowrap",
display: "flex",
}}
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
>
{/* Left: Tool Picker */}
{sidebarsVisible && (
<Box
style={{
minWidth: 180,
maxWidth: 240,
width: "16vw",
height: "100vh",
borderRight: `1px solid ${colorScheme === "dark" ? theme.colors.dark[4] : "#e9ecef"}`,
background: colorScheme === "dark" ? theme.colors.dark[7] : "#fff",
zIndex: 101,
display: "flex",
flexDirection: "column",
}}
>
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleToolSelect}
toolRegistry={toolRegistry}
/>
</Box>
)}
{/* Quick Access Bar */}
<QuickAccessBar
onToolsClick={handleQuickAccessTools}
onReaderToggle={handleReaderToggle}
selectedToolKey={selectedToolKey}
toolRegistry={toolRegistry}
leftPanelView={leftPanelView}
readerMode={readerMode}
/>
{/* Middle: Main View */}
<Box
{/* Left: Tool Picker OR Selected Tool Panel */}
<div
className={`h-screen z-sticky flex flex-col ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
style={{
flex: 1,
height: "100vh",
minWidth: "20rem",
position: "relative",
display: "flex",
flexDirection: "column",
transition: "all 0.3s",
background: colorScheme === "dark" ? theme.colors.dark[6] : "#f8f9fa",
backgroundColor: 'var(--bg-surface)',
borderRight: '1px solid var(--border-subtle)',
width: sidebarsVisible && !readerMode ? '25vw' : '0px',
minWidth: sidebarsVisible && !readerMode ? '300px' : '0px',
maxWidth: sidebarsVisible && !readerMode ? '450px' : '0px',
transition: 'width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), min-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), max-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
}}
>
{/* Overlayed View Switcher + Theme Toggle */}
<div
style={{
position: "absolute",
left: 0,
width: "100%",
top: 0,
zIndex: 30,
pointerEvents: "none",
}}
>
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "auto",
display: "flex",
gap: 12,
alignItems: "center",
opacity: sidebarsVisible && !readerMode ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Button
onClick={toggleColorScheme}
variant="subtle"
size="md"
aria-label="Toggle theme"
>
{colorScheme === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
</Button>
<LanguageSelector />
{leftPanelView === 'toolPicker' ? (
// Tool Picker View
<div className="flex-1 flex flex-col">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleToolSelect}
toolRegistry={toolRegistry}
/>
</div>
) : (
// Selected Tool Content View
<div className="flex-1 flex flex-col">
{/* Back button */}
<div className="mb-4">
<Button
variant="subtle"
size="sm"
onClick={() => setLeftPanelView('toolPicker')}
className="text-sm"
>
{t("fileUpload.backToTools", "Back to Tools")}
</Button>
</div>
{/* Tool title */}
<div className="mb-4">
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
</div>
{/* Tool content */}
<div className="flex-1 min-h-0">
<ToolRenderer
selectedToolKey={selectedToolKey}
selectedTool={selectedTool}
pdfFile={activeFiles[0] || null}
files={activeFiles}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
toolParams={toolParams}
updateParams={updateParams}
/>
</div>
</div>
)}
</div>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
pointerEvents: "auto",
}}
>
<SegmentedControl
data={VIEW_OPTIONS}
value={currentView}
onChange={setCurrentView}
color="blue"
radius="xl"
size="md"
fullWidth
/>
</div>
</div>
</div>
{/* Main View */}
<Box
className="flex-1 h-screen min-w-80 relative flex flex-col"
style={{
backgroundColor: 'var(--bg-background)'
}}
>
{/* Top Controls */}
<TopControls
currentView={currentView}
setCurrentView={handleViewChange}
/>
{/* Main content area */}
<Paper
radius="0 0 xl xl"
shadow="sm"
p={0}
style={{
flex: 1,
minHeight: 0,
marginTop: 0,
boxSizing: "border-box",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<Box style={{ flex: 1, minHeight: 0 }}>
{(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
{currentView === "fileManager" ? (
<FileManager
files={files}
setFiles={setFiles}
setPdfFile={setPdfFile}
setCurrentView={setCurrentView}
files={storedFiles}
setFiles={setStoredFiles}
setCurrentView={handleViewChange}
onOpenFileEditor={handleOpenFileEditor}
onLoadFileToActive={addToActiveFiles}
/>
) : currentView === "viewer" ? (
) : (currentView != "fileManager") && !activeFiles[0] ? (
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FileUploadSelector
title={currentView === "viewer"
? t("fileUpload.selectPdfToView", "Select a PDF to view")
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
}
subtitle={t("fileUpload.chooseFromStorage", "Choose a file from storage or upload a new PDF")}
sharedFiles={storedFiles}
onFileSelect={(file) => {
addToActiveFiles(file);
}}
allowMultiple={false}
accept={["application/pdf"]}
loading={false}
/>
</Container>
) : currentView === "fileEditor" ? (
<FileEditor
sharedFiles={activeFiles}
setSharedFiles={setActiveFiles}
preSelectedFiles={preSelectedFiles}
onClearPreSelection={() => setPreSelectedFiles([])}
onOpenPageEditor={(file) => {
setCurrentActiveFile(file);
handleViewChange("pageEditor");
}}
onMergeFiles={(filesToMerge) => {
// Add merged files to active set
filesToMerge.forEach(addToActiveFiles);
handleViewChange("viewer");
}}
/>
) : currentView === "viewer" ? (
<Viewer
pdfFile={pdfFile}
setPdfFile={setPdfFile}
pdfFile={currentFileWithUrl}
setPdfFile={(fileObj) => {
if (fileObj) {
setCurrentActiveFile(fileObj.file);
} else {
setActiveFiles([]);
}
}}
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
/>
) : currentView === "pageEditor" ? (
<PageEditor
file={pdfFile}
setFile={setPdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
/>
<>
<PageEditor
file={currentFileWithUrl}
setFile={(fileObj) => {
if (fileObj) {
setCurrentActiveFile(fileObj.file);
} else {
setActiveFiles([]);
}
}}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
onFunctionsReady={setPageEditorFunctions}
sharedFiles={activeFiles}
/>
{activeFiles[0] && pageEditorFunctions && (
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onExportSelected={() => pageEditorFunctions.showExportPreview(true)}
onExportAll={() => pageEditorFunctions.showExportPreview(false)}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPages={pageEditorFunctions.selectedPages}
/>
)}
</>
) : (
<FileManager
files={files}
setFiles={setFiles}
setPdfFile={setPdfFile}
setCurrentView={setCurrentView}
files={storedFiles}
setFiles={setStoredFiles}
setCurrentView={handleViewChange}
onOpenFileEditor={handleOpenFileEditor}
onLoadFileToActive={addToActiveFiles}
/>
)}
</Box>
</Paper>
</Box>
{/* Right: Tool Interaction */}
{sidebarsVisible && (
<Box
style={{
minWidth: 260,
maxWidth: 400,
width: "22vw",
height: "100vh",
borderLeft: `1px solid ${colorScheme === "dark" ? theme.colors.dark[4] : "#e9ecef"}`,
background: colorScheme === "dark" ? theme.colors.dark[7] : "#fff",
padding: 24,
gap: 16,
zIndex: 100,
display: "flex",
flexDirection: "column",
}}
>
{selectedTool && selectedTool.component && renderTool()}
</Box>
)}
{/* Sidebar toggle button */}
<Button
variant="light"
color="blue"
size="xs"
style={{ position: "fixed", top: 16, right: 16, zIndex: 200 }}
onClick={() => setSidebarsVisible((v) => !v)}
>
{t("sidebar.toggle", sidebarsVisible ? "Hide Sidebars" : "Show Sidebars")}
</Button>
</Group>
);
}

View File

@ -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<StorageConfig> => {
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
};
};