mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-12 10:35:03 +00:00
Stirling 2.0 (#3645)
# Description of Changes Please provide a summary of the changes, including: Vite fixes Indexxdb Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
parent
0650a6a877
commit
b5d84db3c8
3
.gitignore
vendored
3
.gitignore
vendored
@ -189,6 +189,9 @@ id_ed25519.pub
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Claude.ai assistant files
|
||||
CLAUDE.md
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
# node_modules
|
||||
|
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@ -10,6 +10,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
File diff suppressed because one or more lines are too long
99
frontend/dist/assets/index-C7ZkpjP3.js
vendored
99
frontend/dist/assets/index-C7ZkpjP3.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-CEZrmp6h.css
vendored
1
frontend/dist/assets/index-CEZrmp6h.css
vendored
File diff suppressed because one or more lines are too long
BIN
frontend/dist/favicon.ico
vendored
BIN
frontend/dist/favicon.ico
vendored
Binary file not shown.
Before Width: | Height: | Size: 3.8 KiB |
23
frontend/dist/index.html
vendored
23
frontend/dist/index.html
vendored
@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using Vite"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<title>Vite App</title>
|
||||
<script type="module" crossorigin src="/assets/index-C7ZkpjP3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CEZrmp6h.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
1561
frontend/dist/locales/ar-AR/translation.json
vendored
1561
frontend/dist/locales/ar-AR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/az-AZ/translation.json
vendored
1561
frontend/dist/locales/az-AZ/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/bg-BG/translation.json
vendored
1561
frontend/dist/locales/bg-BG/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ca-CA/translation.json
vendored
1561
frontend/dist/locales/ca-CA/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/cs-CZ/translation.json
vendored
1561
frontend/dist/locales/cs-CZ/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/da-DK/translation.json
vendored
1561
frontend/dist/locales/da-DK/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/de-DE/translation.json
vendored
1561
frontend/dist/locales/de-DE/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/el-GR/translation.json
vendored
1561
frontend/dist/locales/el-GR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1620
frontend/dist/locales/en-GB/translation.json
vendored
1620
frontend/dist/locales/en-GB/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/en-US/translation.json
vendored
1561
frontend/dist/locales/en-US/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1
frontend/dist/locales/en/translation.json
vendored
1
frontend/dist/locales/en/translation.json
vendored
@ -1 +0,0 @@
|
||||
{}
|
1561
frontend/dist/locales/es-ES/translation.json
vendored
1561
frontend/dist/locales/es-ES/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/eu-ES/translation.json
vendored
1561
frontend/dist/locales/eu-ES/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/fa-IR/translation.json
vendored
1561
frontend/dist/locales/fa-IR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/fr-FR/translation.json
vendored
1561
frontend/dist/locales/fr-FR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ga-IE/translation.json
vendored
1561
frontend/dist/locales/ga-IE/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/hi-IN/translation.json
vendored
1561
frontend/dist/locales/hi-IN/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/hr-HR/translation.json
vendored
1561
frontend/dist/locales/hr-HR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/hu-HU/translation.json
vendored
1561
frontend/dist/locales/hu-HU/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/id-ID/translation.json
vendored
1561
frontend/dist/locales/id-ID/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/it-IT/translation.json
vendored
1561
frontend/dist/locales/it-IT/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ja-JP/translation.json
vendored
1561
frontend/dist/locales/ja-JP/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ko-KR/translation.json
vendored
1561
frontend/dist/locales/ko-KR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ml-ML/translation.json
vendored
1561
frontend/dist/locales/ml-ML/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/nl-NL/translation.json
vendored
1561
frontend/dist/locales/nl-NL/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/no-NB/translation.json
vendored
1561
frontend/dist/locales/no-NB/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/pl-PL/translation.json
vendored
1561
frontend/dist/locales/pl-PL/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/pt-BR/translation.json
vendored
1561
frontend/dist/locales/pt-BR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/pt-PT/translation.json
vendored
1561
frontend/dist/locales/pt-PT/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ro-RO/translation.json
vendored
1561
frontend/dist/locales/ro-RO/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/ru-RU/translation.json
vendored
1561
frontend/dist/locales/ru-RU/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/sk-SK/translation.json
vendored
1561
frontend/dist/locales/sk-SK/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/sl-SI/translation.json
vendored
1561
frontend/dist/locales/sl-SI/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/sr-LATN-RS/translation.json
vendored
1561
frontend/dist/locales/sr-LATN-RS/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/sv-SE/translation.json
vendored
1561
frontend/dist/locales/sv-SE/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/th-TH/translation.json
vendored
1561
frontend/dist/locales/th-TH/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/tr-TR/translation.json
vendored
1561
frontend/dist/locales/tr-TR/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/uk-UA/translation.json
vendored
1561
frontend/dist/locales/uk-UA/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/vi-VN/translation.json
vendored
1561
frontend/dist/locales/vi-VN/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/zh-BO/translation.json
vendored
1561
frontend/dist/locales/zh-BO/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/zh-CN/translation.json
vendored
1561
frontend/dist/locales/zh-CN/translation.json
vendored
File diff suppressed because it is too large
Load Diff
1561
frontend/dist/locales/zh-TW/translation.json
vendored
1561
frontend/dist/locales/zh-TW/translation.json
vendored
File diff suppressed because it is too large
Load Diff
BIN
frontend/dist/logo192.png
vendored
BIN
frontend/dist/logo192.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 5.2 KiB |
BIN
frontend/dist/logo512.png
vendored
BIN
frontend/dist/logo512.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 9.4 KiB |
25
frontend/dist/manifest.json
vendored
25
frontend/dist/manifest.json
vendored
@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
58353
frontend/dist/pdf.worker.js
vendored
58353
frontend/dist/pdf.worker.js
vendored
File diff suppressed because one or more lines are too long
3
frontend/dist/robots.txt
vendored
3
frontend/dist/robots.txt
vendored
@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
@ -1576,7 +1576,13 @@
|
||||
"dragDrop": "Drag & Drop files here",
|
||||
"clickToUpload": "Click to upload files",
|
||||
"selectedFiles": "Selected Files",
|
||||
"clearAll": "Clear All"
|
||||
"clearAll": "Clear All",
|
||||
"storage": "Storage",
|
||||
"filesStored": "files stored",
|
||||
"storageError": "Storage error occurred",
|
||||
"storageLow": "Storage is running low. Consider removing old files.",
|
||||
"uploadError": "Failed to upload some files.",
|
||||
"supportMessage": "Powered by browser database storage for unlimited capacity"
|
||||
},
|
||||
"pageEditor": {
|
||||
"title": "Page Editor",
|
||||
|
136
frontend/src/components/FileCard.standalone.tsx
Normal file
136
frontend/src/components/FileCard.standalone.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { getFileSize, getFileDate } from "../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
}
|
||||
|
||||
const FileCard: React.FC<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
cursor: onDoubleClick ? "pointer" : undefined
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
background: "#fafbfc",
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #ddd',
|
||||
borderTop: '2px solid #666',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: 8
|
||||
}} />
|
||||
<Text size="xs" c="dimmed">Generating...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="gray" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{file.storedInIndexedDB && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={onRemove}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCard;
|
@ -1,134 +1,20 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Group, Text, Stack, Image, Badge, Button, Box, Flex, ThemeIcon } from "@mantine/core";
|
||||
import { Box, Flex, Text, Notification } from "@mantine/core";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
|
||||
import { GlobalWorkerOptions, getDocument } from "pdfjs-dist";
|
||||
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";
|
||||
|
||||
export interface FileWithUrl extends File {
|
||||
url?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
function getFileDate(file: File): string {
|
||||
if (file.lastModified) {
|
||||
return new Date(file.lastModified).toLocaleString();
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function getFileSize(file: File): string {
|
||||
if (!file.size) return "Unknown";
|
||||
if (file.size < 1024) return `${file.size} B`;
|
||||
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
||||
return `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function usePdfThumbnail(file: File | undefined | null): string | null {
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function generate() {
|
||||
if (!file) return;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
if (!cancelled) setThumb(canvas.toDataURL());
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setThumb(null);
|
||||
}
|
||||
}
|
||||
generate();
|
||||
return () => { cancelled = true; };
|
||||
}, [file]);
|
||||
|
||||
return thumb;
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
}
|
||||
|
||||
function FileCard({ file, onRemove, onDoubleClick }: FileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const thumb = usePdfThumbnail(file);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{ width: 225, minWidth: 180, maxWidth: 260, cursor: onDoubleClick ? "pointer" : undefined }}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
background: "#fafbfc",
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<Image src={thumb} alt="PDF thumbnail" height={110} width={80} fit="contain" radius="sm" />
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Box>
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="gray" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={onRemove}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileManagerProps {
|
||||
files: FileWithUrl[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
|
||||
@ -145,21 +31,212 @@ const FileManager: React.FC<FileManagerProps> = ({
|
||||
setCurrentView,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleDrop = (uploadedFiles: File[]) => {
|
||||
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...uploadedFiles] : uploadedFiles));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||
const [notification, setNotification] = useState<string | null>(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 handleRemoveFile = (index: number) => {
|
||||
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
|
||||
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 (
|
||||
<div style={{ width: "100%", margin: "0 auto", justifyContent: "center", display: "flex", flexDirection: "column", alignItems: "center", padding: "20px" }}>
|
||||
<div style={{
|
||||
width: "100%",
|
||||
margin: "0 auto",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "20px"
|
||||
}}>
|
||||
|
||||
{/* File Upload Dropzone */}
|
||||
<Dropzone
|
||||
onDrop={handleDrop}
|
||||
accept={[MIME_TYPES.pdf]}
|
||||
multiple={allowMultiple}
|
||||
maxSize={20 * 1024 * 1024}
|
||||
maxSize={2 * 1024 * 1024 * 1024} // 2GB limit
|
||||
loading={loading}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
@ -169,15 +246,23 @@ const FileManager: React.FC<FileManagerProps> = ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width:"90%"
|
||||
width: "90%"
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap="xl" style={{ pointerEvents: "none" }}>
|
||||
<Text size="md">
|
||||
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="md">
|
||||
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{/* Storage Stats Card */}
|
||||
<StorageStatsCard
|
||||
storageStats={storageStats}
|
||||
filesCount={files.length}
|
||||
onClearAll={handleClearAll}
|
||||
onReloadFiles={handleReloadFiles}
|
||||
/>
|
||||
|
||||
{/* Files Display */}
|
||||
{files.length === 0 ? (
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("noFileSelected", "No files uploaded yet.")}
|
||||
@ -192,23 +277,26 @@ const FileManager: React.FC<FileManagerProps> = ({
|
||||
>
|
||||
{files.map((file, idx) => (
|
||||
<FileCard
|
||||
key={file.name + idx}
|
||||
key={file.id || file.name + idx}
|
||||
file={file}
|
||||
onRemove={() => handleRemoveFile(idx)}
|
||||
onDoubleClick={() => {
|
||||
const fileObj = (file as FileWithUrl).file || file;
|
||||
setPdfFile &&
|
||||
setPdfFile({
|
||||
file: fileObj,
|
||||
url: URL.createObjectURL(fileObj),
|
||||
});
|
||||
setCurrentView && setCurrentView("viewer");
|
||||
}}
|
||||
/>
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
as FileWithUrl />
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
{notification && (
|
||||
<Notification
|
||||
color="blue"
|
||||
onClose={() => setNotification(null)}
|
||||
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{notification}
|
||||
</Notification>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
76
frontend/src/components/StorageStatsCard.tsx
Normal file
76
frontend/src/components/StorageStatsCard.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { StorageStats } from "../services/fileStorage";
|
||||
import { formatFileSize } from "../utils/fileUtils";
|
||||
import { getStorageUsagePercent } from "../utils/storageUtils";
|
||||
|
||||
interface StorageStatsCardProps {
|
||||
storageStats: StorageStats | null;
|
||||
filesCount: number;
|
||||
onClearAll: () => void;
|
||||
onReloadFiles: () => void;
|
||||
}
|
||||
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
|
||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
||||
|
||||
return (
|
||||
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}>
|
||||
<Group align="center" gap="md">
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
|
||||
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
||||
</Text>
|
||||
{storageStats.quota && (
|
||||
<Progress
|
||||
value={storageUsagePercent}
|
||||
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
Reload Files
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
@ -10,9 +10,118 @@ 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: React.FC<LazyPageImageProps> = ({
|
||||
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
|
||||
}) => {
|
||||
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;
|
||||
setPdfFile: (file: { file: File; url: string } | null) => void;
|
||||
@ -38,7 +147,52 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
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(() => {
|
||||
@ -121,7 +275,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function renderPages() {
|
||||
async function loadPdfInfo() {
|
||||
if (!pdfFile || !pdfFile.url) {
|
||||
setNumPages(0);
|
||||
setPageImages([]);
|
||||
@ -129,29 +283,49 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const pdf = await getDocument(pdfFile.url).promise;
|
||||
setNumPages(pdf.numPages);
|
||||
const images: string[] = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
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;
|
||||
images.push(canvas.toDataURL());
|
||||
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) setPageImages(images);
|
||||
} catch {
|
||||
if (!cancelled) setPageImages([]);
|
||||
}
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
renderPages();
|
||||
return () => { cancelled = true; };
|
||||
loadPdfInfo();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Cleanup ArrayBuffer reference to help garbage collection
|
||||
currentArrayBufferRef.current = null;
|
||||
};
|
||||
}, [pdfFile]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -210,53 +384,44 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
viewportRef={scrollAreaRef}
|
||||
>
|
||||
<Stack gap="xl" align="center" >
|
||||
{pageImages.length === 0 && (
|
||||
{numPages === 0 && (
|
||||
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
|
||||
)}
|
||||
{dualPage
|
||||
? Array.from({ length: Math.ceil(pageImages.length / 2) }).map((_, i) => (
|
||||
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
|
||||
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
|
||||
<img
|
||||
ref={el => { pageRefs.current[i * 2] = el; }}
|
||||
src={pageImages[i * 2]}
|
||||
alt={`Page ${i * 2 + 1}`}
|
||||
style={{
|
||||
width: `${100 * zoom}%`,
|
||||
maxWidth: 700 * zoom,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
|
||||
}}
|
||||
<LazyPageImage
|
||||
pageIndex={i * 2}
|
||||
zoom={zoom}
|
||||
theme={theme}
|
||||
isFirst={i === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
/>
|
||||
{pageImages[i * 2 + 1] && (
|
||||
<img
|
||||
ref={el => { pageRefs.current[i * 2 + 1] = el; }}
|
||||
src={pageImages[i * 2 + 1]}
|
||||
alt={`Page ${i * 2 + 2}`}
|
||||
style={{
|
||||
width: `${100 * zoom}%`,
|
||||
maxWidth: 700 * zoom,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
|
||||
}}
|
||||
{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>
|
||||
))
|
||||
: pageImages.map((img, idx) => (
|
||||
<img
|
||||
: Array.from({ length: numPages }).map((_, idx) => (
|
||||
<LazyPageImage
|
||||
key={idx}
|
||||
ref={el => { pageRefs.current[idx] = el; }}
|
||||
src={img}
|
||||
alt={`Page ${idx + 1}`}
|
||||
style={{
|
||||
width: `${100 * zoom}%`,
|
||||
maxWidth: 700 * zoom,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: idx === 0 ? theme.spacing.xl : 0, // <-- add gap to first page
|
||||
}}
|
||||
pageIndex={idx}
|
||||
zoom={zoom}
|
||||
theme={theme}
|
||||
isFirst={idx === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
72
frontend/src/hooks/useIndexedDBThumbnail.ts
Normal file
72
frontend/src/hooks/useIndexedDBThumbnail.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getDocument } from "pdfjs-dist";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
|
||||
/**
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
* Handles thumbnail generation for files not in IndexedDB
|
||||
*/
|
||||
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
thumbnail: string | null;
|
||||
isGenerating: boolean
|
||||
} {
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (!file) {
|
||||
setThumb(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// First priority: use stored thumbnail
|
||||
if (file.thumbnail) {
|
||||
setThumb(file.thumbnail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second priority: for IndexedDB files without stored thumbnails, just use placeholder
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
// Don't generate thumbnails for files loaded from IndexedDB - just use placeholder
|
||||
setThumb(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Third priority: generate from blob for regular files during upload (small files only)
|
||||
if (!file.storedInIndexedDB && file.size < 50 * 1024 * 1024 && !generating) {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.2 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context && !cancelled) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
if (!cancelled) setThumb(canvas.toDataURL());
|
||||
}
|
||||
pdf.destroy(); // Clean up memory
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for regular file', file.name, error);
|
||||
if (!cancelled) setThumb(null);
|
||||
} finally {
|
||||
if (!cancelled) setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
// Large files or files without proper conditions - show placeholder
|
||||
setThumb(null);
|
||||
}
|
||||
}
|
||||
|
||||
loadThumbnail();
|
||||
return () => { cancelled = true; };
|
||||
}, [file, file?.thumbnail, file?.id]);
|
||||
|
||||
return { thumbnail: thumb, isGenerating: generating };
|
||||
}
|
@ -72,8 +72,8 @@ function getToolParams(toolKey: string, searchParams: URLSearchParams) {
|
||||
return {
|
||||
mode: searchParams.get("splitMode") || "byPages",
|
||||
pages: searchParams.get("pages") || "",
|
||||
hDiv: searchParams.get("hDiv") || "0",
|
||||
vDiv: searchParams.get("vDiv") || "1",
|
||||
hDiv: searchParams.get("hDiv") || "",
|
||||
vDiv: searchParams.get("vDiv") || "",
|
||||
merge: searchParams.get("merge") === "true",
|
||||
splitType: searchParams.get("splitType") || "size",
|
||||
splitValue: searchParams.get("splitValue") || "",
|
||||
@ -83,8 +83,11 @@ function getToolParams(toolKey: string, searchParams: URLSearchParams) {
|
||||
};
|
||||
case "compress":
|
||||
return {
|
||||
level: searchParams.get("compressLevel") || "medium",
|
||||
keepQuality: searchParams.get("keepQuality") === "true",
|
||||
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 {
|
||||
@ -124,10 +127,13 @@ function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSea
|
||||
params.set("allowDuplicates", String(merged.allowDuplicates));
|
||||
}
|
||||
} else if (toolKey === "compress") {
|
||||
["compressLevel", "keepQuality"].forEach((k) => params.delete(k));
|
||||
["compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive"].forEach((k) => params.delete(k));
|
||||
const merged = { ...getToolParams("compress", searchParams), ...newParams };
|
||||
params.set("compressLevel", merged.level);
|
||||
params.set("keepQuality", String(merged.keepQuality));
|
||||
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 };
|
||||
@ -146,7 +152,7 @@ const TOOL_PARAMS = {
|
||||
"splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates"
|
||||
],
|
||||
compress: [
|
||||
"compressLevel", "keepQuality"
|
||||
"compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive"
|
||||
],
|
||||
merge: [
|
||||
"mergeOrder", "removeDuplicates"
|
||||
@ -222,13 +228,39 @@ export default function HomePage() {
|
||||
return <div>Tool not found</div>;
|
||||
}
|
||||
|
||||
// Pass only the necessary props
|
||||
return React.createElement(selectedTool.component, {
|
||||
files,
|
||||
setDownloadUrl,
|
||||
params: toolParams,
|
||||
updateParams,
|
||||
});
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
194
frontend/src/services/fileOperationsService.ts
Normal file
194
frontend/src/services/fileOperationsService.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage, StorageStats } from "./fileStorage";
|
||||
import { loadFilesFromIndexedDB, createEnhancedFileFromStored, cleanupFileUrls } from "../utils/fileUtils";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
import { updateStorageStatsIncremental } from "../utils/storageUtils";
|
||||
|
||||
/**
|
||||
* Service for file storage operations
|
||||
* Contains all IndexedDB operations and file management logic
|
||||
*/
|
||||
export const fileOperationsService = {
|
||||
|
||||
/**
|
||||
* Load storage statistics
|
||||
*/
|
||||
async loadStorageStats(): Promise<StorageStats | null> {
|
||||
try {
|
||||
return await fileStorage.getStorageStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to load storage stats:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Force reload files from IndexedDB
|
||||
*/
|
||||
async forceReloadFiles(): Promise<FileWithUrl[]> {
|
||||
try {
|
||||
return await loadFilesFromIndexedDB();
|
||||
} catch (error) {
|
||||
console.error('Failed to force reload files:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load existing files from IndexedDB if not already loaded
|
||||
*/
|
||||
async loadExistingFiles(
|
||||
filesLoaded: boolean,
|
||||
currentFiles: FileWithUrl[]
|
||||
): Promise<FileWithUrl[]> {
|
||||
if (filesLoaded && currentFiles.length > 0) {
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
try {
|
||||
await fileStorage.init();
|
||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
||||
|
||||
// Detect if IndexedDB was purged by comparing with current UI state
|
||||
if (currentFiles.length > 0 && storedFiles.length === 0) {
|
||||
console.warn('IndexedDB appears to have been purged - clearing UI state');
|
||||
return [];
|
||||
}
|
||||
|
||||
return await loadFilesFromIndexedDB();
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing files:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload files to IndexedDB with thumbnail generation
|
||||
*/
|
||||
async uploadFiles(
|
||||
uploadedFiles: File[],
|
||||
useIndexedDB: boolean
|
||||
): Promise<FileWithUrl[]> {
|
||||
const newFiles: FileWithUrl[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (useIndexedDB) {
|
||||
try {
|
||||
console.log('Storing file in IndexedDB:', file.name);
|
||||
|
||||
// Generate thumbnail only during upload
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||
console.log('File stored with ID:', storedFile.id);
|
||||
|
||||
const baseFile = fileStorage.createFileFromStored(storedFile);
|
||||
const enhancedFile = createEnhancedFileFromStored(storedFile, thumbnail);
|
||||
|
||||
// Copy File interface methods from baseFile
|
||||
enhancedFile.arrayBuffer = baseFile.arrayBuffer.bind(baseFile);
|
||||
enhancedFile.slice = baseFile.slice.bind(baseFile);
|
||||
enhancedFile.stream = baseFile.stream.bind(baseFile);
|
||||
enhancedFile.text = baseFile.text.bind(baseFile);
|
||||
|
||||
newFiles.push(enhancedFile);
|
||||
} catch (error) {
|
||||
console.error('Failed to store file in IndexedDB:', error);
|
||||
// Fallback to RAM storage
|
||||
const enhancedFile: FileWithUrl = Object.assign(file, {
|
||||
url: URL.createObjectURL(file),
|
||||
storedInIndexedDB: false
|
||||
});
|
||||
newFiles.push(enhancedFile);
|
||||
}
|
||||
} else {
|
||||
// IndexedDB disabled - use RAM
|
||||
const enhancedFile: FileWithUrl = Object.assign(file, {
|
||||
url: URL.createObjectURL(file),
|
||||
storedInIndexedDB: false
|
||||
});
|
||||
newFiles.push(enhancedFile);
|
||||
}
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a file from storage
|
||||
*/
|
||||
async removeFile(file: FileWithUrl): Promise<void> {
|
||||
// Clean up blob URL
|
||||
if (file.url && !file.url.startsWith('indexeddb:')) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
|
||||
// Remove from IndexedDB if stored there
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
try {
|
||||
await fileStorage.deleteFile(file.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file from IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all files from storage
|
||||
*/
|
||||
async clearAllFiles(files: FileWithUrl[]): Promise<void> {
|
||||
// Clean up all blob URLs
|
||||
cleanupFileUrls(files);
|
||||
|
||||
// Clear IndexedDB
|
||||
try {
|
||||
await fileStorage.clearAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear IndexedDB:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create blob URL for file viewing
|
||||
*/
|
||||
async createBlobUrlForFile(file: FileWithUrl): Promise<string> {
|
||||
// For large files, use IndexedDB direct access to avoid memory issues
|
||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
if (file.size > FILE_SIZE_LIMIT) {
|
||||
console.warn(`File ${file.name} is too large for blob URL. Use direct IndexedDB access.`);
|
||||
return `indexeddb:${file.id}`;
|
||||
}
|
||||
|
||||
// For all files, avoid persistent blob URLs
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
if (storedFile) {
|
||||
return fileStorage.createBlobUrl(storedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for files not in IndexedDB
|
||||
return URL.createObjectURL(file);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check for IndexedDB purge
|
||||
*/
|
||||
async checkForPurge(currentFiles: FileWithUrl[]): Promise<boolean> {
|
||||
if (currentFiles.length === 0) return false;
|
||||
|
||||
try {
|
||||
await fileStorage.init();
|
||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
||||
return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files
|
||||
} catch (error) {
|
||||
console.error('Error checking for purge:', error);
|
||||
return true; // Assume purged if can't access IndexedDB
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update storage stats incrementally (re-export utility for convenience)
|
||||
*/
|
||||
updateStorageStatsIncremental
|
||||
};
|
576
frontend/src/services/fileStorage.ts
Normal file
576
frontend/src/services/fileStorage.ts
Normal file
@ -0,0 +1,576 @@
|
||||
/**
|
||||
* IndexedDB File Storage Service
|
||||
* Provides high-capacity file storage for PDF processing
|
||||
*/
|
||||
|
||||
export interface StoredFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
data: ArrayBuffer;
|
||||
thumbnail?: string;
|
||||
url?: string; // For compatibility with existing components
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
used: number;
|
||||
available: number;
|
||||
fileCount: number;
|
||||
quota?: number;
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private dbName = 'stirling-pdf-files';
|
||||
private dbVersion = 2; // Increment version to force schema update
|
||||
private storeName = 'files';
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the IndexedDB database (singleton pattern)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.db) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
this.initPromise = null;
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
console.log('IndexedDB connection established');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const oldVersion = (event as any).oldVersion;
|
||||
|
||||
console.log('IndexedDB upgrade needed from version', oldVersion, 'to', this.dbVersion);
|
||||
|
||||
// Only recreate object store if it doesn't exist or if upgrading from version < 2
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||
store.createIndex('name', 'name', { unique: false });
|
||||
store.createIndex('lastModified', 'lastModified', { unique: false });
|
||||
console.log('IndexedDB object store created with keyPath: id');
|
||||
} else if (oldVersion < 2) {
|
||||
// Only delete and recreate if upgrading from version 1 to 2
|
||||
db.deleteObjectStore(this.storeName);
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||
store.createIndex('name', 'name', { unique: false });
|
||||
store.createIndex('lastModified', 'lastModified', { unique: false });
|
||||
console.log('IndexedDB object store recreated with keyPath: id (version upgrade)');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file in IndexedDB
|
||||
*/
|
||||
async storeFile(file: File, thumbnail?: string): Promise<StoredFile> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const storedFile: StoredFile = {
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
data: arrayBuffer,
|
||||
thumbnail
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
// Debug logging
|
||||
console.log('Object store keyPath:', store.keyPath);
|
||||
console.log('Storing file:', {
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
hasData: !!storedFile.data,
|
||||
dataSize: storedFile.data.byteLength
|
||||
});
|
||||
|
||||
const request = store.add(storedFile);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB add error:', request.error);
|
||||
console.error('Failed object:', storedFile);
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
console.log('File stored successfully with ID:', storedFile.id);
|
||||
resolve(storedFile);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file from IndexedDB
|
||||
*/
|
||||
async getFile(id: string): Promise<StoredFile | null> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored files (WARNING: loads all data into memory)
|
||||
*/
|
||||
async getAllFiles(): Promise<StoredFile[]> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
// Filter out null/corrupted entries
|
||||
const files = request.result.filter(file =>
|
||||
file &&
|
||||
file.data &&
|
||||
file.name &&
|
||||
typeof file.size === 'number'
|
||||
);
|
||||
resolve(files);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata of all stored files (without loading data into memory)
|
||||
*/
|
||||
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const files: Omit<StoredFile, 'data'>[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const storedFile = cursor.value;
|
||||
// Only extract metadata, skip the data field
|
||||
if (storedFile && storedFile.name && typeof storedFile.size === 'number') {
|
||||
files.push({
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
type: storedFile.type,
|
||||
size: storedFile.size,
|
||||
lastModified: storedFile.lastModified,
|
||||
thumbnail: storedFile.thumbnail
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
console.log('Loaded metadata for', files.length, 'files without loading data');
|
||||
resolve(files);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from IndexedDB
|
||||
*/
|
||||
async deleteFile(id: string): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored files
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics (only our IndexedDB usage)
|
||||
*/
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
let used = 0;
|
||||
let available = 0;
|
||||
let quota: number | undefined;
|
||||
let fileCount = 0;
|
||||
|
||||
try {
|
||||
// Get browser quota for context
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
quota = estimate.quota;
|
||||
available = estimate.quota || 0;
|
||||
}
|
||||
|
||||
// Calculate our actual IndexedDB usage from file metadata
|
||||
const files = await this.getAllFileMetadata();
|
||||
used = files.reduce((total, file) => total + (file?.size || 0), 0);
|
||||
fileCount = files.length;
|
||||
|
||||
// Adjust available space
|
||||
if (quota) {
|
||||
available = quota - used;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not get storage stats:', error);
|
||||
// If we can't read metadata, database might be purged
|
||||
used = 0;
|
||||
fileCount = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
available,
|
||||
fileCount,
|
||||
quota
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file count quickly without loading metadata
|
||||
*/
|
||||
async getFileCount(): Promise<number> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.count();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all IndexedDB databases to see if files are in another version
|
||||
*/
|
||||
async debugAllDatabases(): Promise<void> {
|
||||
console.log('=== Checking All IndexedDB Databases ===');
|
||||
|
||||
if ('databases' in indexedDB) {
|
||||
try {
|
||||
const databases = await indexedDB.databases();
|
||||
console.log('Found databases:', databases);
|
||||
|
||||
for (const dbInfo of databases) {
|
||||
if (dbInfo.name?.includes('stirling') || dbInfo.name?.includes('pdf')) {
|
||||
console.log(`Checking database: ${dbInfo.name} (version: ${dbInfo.version})`);
|
||||
try {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbInfo.name!, dbInfo.version);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
console.log(`Database ${dbInfo.name} object stores:`, Array.from(db.objectStoreNames));
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error(`Failed to open database ${dbInfo.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to list databases:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('indexedDB.databases() not supported');
|
||||
}
|
||||
|
||||
// Also check our specific database with different versions
|
||||
for (let version = 1; version <= 3; version++) {
|
||||
try {
|
||||
console.log(`Trying to open ${this.dbName} version ${version}...`);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, version);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onupgradeneeded = () => {
|
||||
// Don't actually upgrade, just check
|
||||
request.transaction?.abort();
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`Version ${version} object stores:`, Array.from(db.objectStoreNames));
|
||||
|
||||
if (db.objectStoreNames.contains('files')) {
|
||||
const transaction = db.transaction(['files'], 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const countRequest = store.count();
|
||||
countRequest.onsuccess = () => {
|
||||
console.log(`Version ${version} files store has ${countRequest.result} entries`);
|
||||
};
|
||||
}
|
||||
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.log(`Version ${version} not accessible:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the database
|
||||
*/
|
||||
async debugDatabaseContents(): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
// First try getAll to see if there's anything
|
||||
const getAllRequest = store.getAll();
|
||||
getAllRequest.onsuccess = () => {
|
||||
console.log('=== Raw getAll() result ===');
|
||||
console.log('Raw entries found:', getAllRequest.result.length);
|
||||
getAllRequest.result.forEach((item, index) => {
|
||||
console.log(`Raw entry ${index}:`, {
|
||||
keys: Object.keys(item || {}),
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
size: item?.size,
|
||||
type: item?.type,
|
||||
hasData: !!item?.data,
|
||||
dataSize: item?.data?.byteLength,
|
||||
fullObject: item
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Then try cursor
|
||||
const cursorRequest = store.openCursor();
|
||||
console.log('=== IndexedDB Cursor Debug ===');
|
||||
let count = 0;
|
||||
|
||||
cursorRequest.onerror = () => {
|
||||
console.error('Cursor error:', cursorRequest.error);
|
||||
reject(cursorRequest.error);
|
||||
};
|
||||
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
count++;
|
||||
const value = cursor.value;
|
||||
console.log(`Cursor File ${count}:`, {
|
||||
id: value?.id,
|
||||
name: value?.name,
|
||||
size: value?.size,
|
||||
type: value?.type,
|
||||
hasData: !!value?.data,
|
||||
dataSize: value?.data?.byteLength,
|
||||
hasThumbnail: !!value?.thumbnail,
|
||||
allKeys: Object.keys(value || {})
|
||||
});
|
||||
cursor.continue();
|
||||
} else {
|
||||
console.log(`=== End Cursor Debug - Found ${count} files ===`);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert StoredFile back to File object for compatibility
|
||||
*/
|
||||
createFileFromStored(storedFile: StoredFile): File {
|
||||
if (!storedFile || !storedFile.data) {
|
||||
throw new Error('Invalid stored file: missing data');
|
||||
}
|
||||
|
||||
if (!storedFile.name || typeof storedFile.size !== 'number') {
|
||||
throw new Error('Invalid stored file: missing metadata');
|
||||
}
|
||||
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const file = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
|
||||
// Add custom properties for compatibility
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false });
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create blob URL for stored file
|
||||
*/
|
||||
createBlobUrl(storedFile: StoredFile): string {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file data as ArrayBuffer for streaming/chunked processing
|
||||
*/
|
||||
async getFileData(id: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const storedFile = await this.getFile(id);
|
||||
return storedFile ? storedFile.data : null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get file data for ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary blob URL that gets revoked automatically
|
||||
*/
|
||||
async createTemporaryBlobUrl(id: string): Promise<string | null> {
|
||||
const data = await this.getFileData(id);
|
||||
if (!data) return null;
|
||||
|
||||
const blob = new Blob([data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Auto-revoke after a short delay to free memory
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update thumbnail for an existing file
|
||||
*/
|
||||
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
||||
if (!this.db) await this.init();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const storedFile = getRequest.result;
|
||||
if (storedFile) {
|
||||
storedFile.thumbnail = thumbnail;
|
||||
const updateRequest = store.put(storedFile);
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
console.log('Thumbnail updated for file:', id);
|
||||
resolve(true);
|
||||
};
|
||||
updateRequest.onerror = () => {
|
||||
console.error('Failed to update thumbnail:', updateRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
console.error('Failed to get file for thumbnail update:', getRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error during thumbnail update:', error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage quota is running low
|
||||
*/
|
||||
async isStorageLow(): Promise<boolean> {
|
||||
const stats = await this.getStorageStats();
|
||||
if (!stats.quota) return false;
|
||||
|
||||
const usagePercent = stats.used / stats.quota;
|
||||
return usagePercent > 0.8; // Consider low if over 80% used
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old files if storage is low
|
||||
*/
|
||||
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
|
||||
const files = await this.getAllFileMetadata();
|
||||
|
||||
if (files.length <= maxFiles) return;
|
||||
|
||||
// Sort by last modified (oldest first)
|
||||
files.sort((a, b) => a.lastModified - b.lastModified);
|
||||
|
||||
// Delete oldest files
|
||||
const filesToDelete = files.slice(0, files.length - maxFiles);
|
||||
for (const file of filesToDelete) {
|
||||
await this.deleteFile(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const fileStorage = new FileStorageService();
|
||||
|
||||
// Helper hook for React components
|
||||
export function useFileStorage() {
|
||||
return fileStorage;
|
||||
}
|
@ -2,30 +2,49 @@ import React, { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Slider, Group, Text, Button, Checkbox, TextInput, Paper } from "@mantine/core";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
|
||||
export interface CompressProps {
|
||||
files?: File[];
|
||||
files?: FileWithUrl[];
|
||||
setDownloadUrl?: (url: string) => void;
|
||||
setLoading?: (loading: boolean) => void;
|
||||
params?: {
|
||||
compressionLevel: number;
|
||||
grayscale: boolean;
|
||||
removeMetadata: boolean;
|
||||
expectedSize: string;
|
||||
aggressive: boolean;
|
||||
};
|
||||
updateParams?: (newParams: Partial<CompressProps["params"]>) => void;
|
||||
}
|
||||
|
||||
const CompressPdfPanel: React.FC<CompressProps> = ({
|
||||
files = [],
|
||||
setDownloadUrl,
|
||||
setLoading,
|
||||
params = {
|
||||
compressionLevel: 5,
|
||||
grayscale: false,
|
||||
removeMetadata: false,
|
||||
expectedSize: "",
|
||||
aggressive: false,
|
||||
},
|
||||
updateParams,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
|
||||
const [selected, setSelected] = useState<boolean[]>(files.map(() => false));
|
||||
const [compressionLevel, setCompressionLevel] = useState<number>(5);
|
||||
const [grayscale, setGrayscale] = useState<boolean>(false);
|
||||
const [removeMetadata, setRemoveMetadata] = useState<boolean>(false);
|
||||
const [expectedSize, setExpectedSize] = useState<string>("");
|
||||
const [aggressive, setAggressive] = useState<boolean>(false);
|
||||
const [localLoading, setLocalLoading] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
compressionLevel,
|
||||
grayscale,
|
||||
removeMetadata,
|
||||
expectedSize,
|
||||
aggressive,
|
||||
} = params;
|
||||
|
||||
// Update selection state if files prop changes
|
||||
React.useEffect(() => {
|
||||
setSelected(files.map(() => false));
|
||||
@ -41,21 +60,39 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
|
||||
setLocalLoading(true);
|
||||
setLoading?.(true);
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => formData.append("fileInput", file));
|
||||
formData.append("compressionLevel", compressionLevel.toString());
|
||||
formData.append("grayscale", grayscale.toString());
|
||||
formData.append("removeMetadata", removeMetadata.toString());
|
||||
formData.append("aggressive", aggressive.toString());
|
||||
if (expectedSize) formData.append("expectedSize", expectedSize);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Handle IndexedDB files
|
||||
for (const file of selectedFiles) {
|
||||
if (!file.id) {
|
||||
continue; // Skip files without an id
|
||||
}
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const actualFile = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
formData.append("fileInput", actualFile);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("compressionLevel", compressionLevel.toString());
|
||||
formData.append("grayscale", grayscale.toString());
|
||||
formData.append("removeMetadata", removeMetadata.toString());
|
||||
formData.append("aggressive", aggressive.toString());
|
||||
if (expectedSize) formData.append("expectedSize", expectedSize);
|
||||
|
||||
const res = await fetch("/api/v1/general/compress-pdf", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const blob = await res.blob();
|
||||
setDownloadUrl?.(URL.createObjectURL(blob));
|
||||
} catch (error) {
|
||||
console.error('Compression failed:', error);
|
||||
} finally {
|
||||
setLocalLoading(false);
|
||||
setLoading?.(false);
|
||||
@ -84,7 +121,7 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
|
||||
max={9}
|
||||
step={1}
|
||||
value={compressionLevel}
|
||||
onChange={setCompressionLevel}
|
||||
onChange={(value) => updateParams?.({ compressionLevel: value })}
|
||||
marks={[
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 5, label: "5" },
|
||||
@ -96,23 +133,23 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
|
||||
<Checkbox
|
||||
label={t("compress.grayscale.label", "Convert images to grayscale")}
|
||||
checked={grayscale}
|
||||
onChange={e => setGrayscale(e.currentTarget.checked)}
|
||||
onChange={e => updateParams?.({ grayscale: e.currentTarget.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("removeMetadata.submit", "Remove PDF metadata")}
|
||||
checked={removeMetadata}
|
||||
onChange={e => setRemoveMetadata(e.currentTarget.checked)}
|
||||
onChange={e => updateParams?.({ removeMetadata: e.currentTarget.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("compress.selectText.1.1", "Aggressive compression (may reduce quality)")}
|
||||
checked={aggressive}
|
||||
onChange={e => setAggressive(e.currentTarget.checked)}
|
||||
onChange={e => updateParams?.({ aggressive: e.currentTarget.checked })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("compress.selectText.5", "Expected output size")}
|
||||
placeholder={t("compress.selectText.5", "e.g. 25MB, 10.8MB, 25KB")}
|
||||
value={expectedSize}
|
||||
onChange={e => setExpectedSize(e.currentTarget.value)}
|
||||
onChange={e => updateParams?.({ expectedSize: e.currentTarget.value })}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCompress}
|
||||
|
@ -2,9 +2,11 @@ import React, { useState, useEffect } from "react";
|
||||
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
|
||||
export interface MergePdfPanelProps {
|
||||
files: File[];
|
||||
files: FileWithUrl[];
|
||||
setDownloadUrl: (url: string) => void;
|
||||
params: {
|
||||
order: string;
|
||||
@ -38,7 +40,22 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
filesToMerge.forEach((file) => formData.append("fileInput", file));
|
||||
|
||||
// Handle IndexedDB files
|
||||
for (const file of filesToMerge) {
|
||||
if (!file.id) {
|
||||
continue; // Skip files without an id
|
||||
}
|
||||
const storedFile = await fileStorage.getFile(file?.id);
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const actualFile = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
formData.append("fileInput", actualFile);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
@ -12,9 +12,11 @@ import {
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
|
||||
export interface SplitPdfPanelProps {
|
||||
file: { file: File; url: string } | null;
|
||||
file: { file: FileWithUrl; url: string } | null;
|
||||
downloadUrl?: string | null;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
params: {
|
||||
@ -68,7 +70,21 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file.file);
|
||||
|
||||
// Handle IndexedDB files
|
||||
if (!file.file.id) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
const storedFile = await fileStorage.getFile(file.file.id);
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const actualFile = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
formData.append("fileInput", actualFile);
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
|
||||
@ -113,9 +129,13 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
setStatus(t("downloadComplete"));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setErrorMessage(
|
||||
error.response?.data || t("error.pdfPassword", "An error occurred while splitting the PDF.")
|
||||
);
|
||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMsg = error.response.data;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
setErrorMessage(errorMsg);
|
||||
setStatus(t("error._value", "Split failed."));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
19
frontend/src/types/file.ts
Normal file
19
frontend/src/types/file.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Enhanced file types for IndexedDB storage
|
||||
*/
|
||||
|
||||
export interface FileWithUrl extends File {
|
||||
id?: string;
|
||||
url?: string;
|
||||
thumbnail?: string;
|
||||
storedInIndexedDB?: boolean;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
useIndexedDB: boolean;
|
||||
// Simplified - no thresholds needed, IndexedDB for everything
|
||||
}
|
||||
|
||||
export const defaultStorageConfig: StorageConfig = {
|
||||
useIndexedDB: true,
|
||||
};
|
124
frontend/src/utils/fileUtils.ts
Normal file
124
frontend/src/utils/fileUtils.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { StoredFile, fileStorage } from "../services/fileStorage";
|
||||
|
||||
/**
|
||||
* Consolidated file size formatting utility
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file date as string
|
||||
*/
|
||||
export function getFileDate(file: File): string {
|
||||
if (file.lastModified) {
|
||||
return new Date(file.lastModified).toLocaleString();
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size as string (legacy method for backward compatibility)
|
||||
*/
|
||||
export function getFileSize(file: File): string {
|
||||
if (!file.size) return "Unknown";
|
||||
return formatFileSize(file.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced file object from stored file metadata
|
||||
* This eliminates the repeated pattern in FileManager
|
||||
*/
|
||||
export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?: string): FileWithUrl {
|
||||
const enhancedFile: FileWithUrl = {
|
||||
id: storedFile.id,
|
||||
storedInIndexedDB: true,
|
||||
url: undefined, // Don't create blob URL immediately to save memory
|
||||
thumbnail: thumbnail || storedFile.thumbnail,
|
||||
// File metadata
|
||||
name: storedFile.name,
|
||||
size: storedFile.size,
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified,
|
||||
// Lazy-loading File interface methods
|
||||
arrayBuffer: async () => {
|
||||
const data = await fileStorage.getFileData(storedFile.id);
|
||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
||||
return data;
|
||||
},
|
||||
slice: (start?: number, end?: number, contentType?: string) => {
|
||||
// Return a promise-based slice that loads from IndexedDB
|
||||
return new Blob([], { type: contentType || storedFile.type });
|
||||
},
|
||||
stream: () => {
|
||||
throw new Error('Stream not implemented for IndexedDB files');
|
||||
},
|
||||
text: async () => {
|
||||
const data = await fileStorage.getFileData(storedFile.id);
|
||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
} as FileWithUrl;
|
||||
|
||||
return enhancedFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load files from IndexedDB and convert to enhanced file objects
|
||||
*/
|
||||
export async function loadFilesFromIndexedDB(): Promise<FileWithUrl[]> {
|
||||
try {
|
||||
await fileStorage.init();
|
||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
||||
|
||||
if (storedFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const restoredFiles: FileWithUrl[] = storedFiles
|
||||
.filter(storedFile => {
|
||||
// Filter out corrupted entries
|
||||
return storedFile &&
|
||||
storedFile.name &&
|
||||
typeof storedFile.size === 'number';
|
||||
})
|
||||
.map(storedFile => {
|
||||
try {
|
||||
return createEnhancedFileFromStored(storedFile);
|
||||
} catch (error) {
|
||||
console.error('Failed to restore file:', storedFile?.name || 'unknown', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((file): file is FileWithUrl => file !== null);
|
||||
|
||||
return restoredFiles;
|
||||
} catch (error) {
|
||||
console.error('Failed to load files from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URLs from file objects
|
||||
*/
|
||||
export function cleanupFileUrls(files: FileWithUrl[]): void {
|
||||
files.forEach(file => {
|
||||
if (file.url && !file.url.startsWith('indexeddb:')) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file should use blob URL or IndexedDB direct access
|
||||
*/
|
||||
export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean {
|
||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
return file.size > FILE_SIZE_LIMIT;
|
||||
}
|
71
frontend/src/utils/storageUtils.ts
Normal file
71
frontend/src/utils/storageUtils.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { StorageStats } from "../services/fileStorage";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
|
||||
/**
|
||||
* Storage operation types for incremental updates
|
||||
*/
|
||||
export type StorageOperation = 'add' | 'remove' | 'clear';
|
||||
|
||||
/**
|
||||
* Update storage stats incrementally based on operation
|
||||
*/
|
||||
export function updateStorageStatsIncremental(
|
||||
currentStats: StorageStats,
|
||||
operation: StorageOperation,
|
||||
files: FileWithUrl[] = []
|
||||
): StorageStats {
|
||||
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
|
||||
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
return {
|
||||
...currentStats,
|
||||
used: currentStats.used + filesSizeTotal,
|
||||
available: currentStats.available - filesSizeTotal,
|
||||
fileCount: currentStats.fileCount + files.length
|
||||
};
|
||||
|
||||
case 'remove':
|
||||
return {
|
||||
...currentStats,
|
||||
used: Math.max(0, currentStats.used - filesSizeTotal),
|
||||
available: currentStats.available + filesSizeTotal,
|
||||
fileCount: Math.max(0, currentStats.fileCount - files.length)
|
||||
};
|
||||
|
||||
case 'clear':
|
||||
return {
|
||||
...currentStats,
|
||||
used: 0,
|
||||
available: currentStats.quota || currentStats.available,
|
||||
fileCount: 0
|
||||
};
|
||||
|
||||
default:
|
||||
return currentStats;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check storage usage and return warning message if needed
|
||||
*/
|
||||
export function checkStorageWarnings(stats: StorageStats): string | null {
|
||||
if (!stats.quota || stats.used === 0) return null;
|
||||
|
||||
const usagePercent = (stats.used / stats.quota) * 100;
|
||||
|
||||
if (usagePercent > 90) {
|
||||
return 'Warning: Storage is nearly full (>90%). Browser may start clearing data.';
|
||||
} else if (usagePercent > 80) {
|
||||
return 'Storage is getting full (>80%). Consider removing old files.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate storage usage percentage
|
||||
*/
|
||||
export function getStorageUsagePercent(stats: StorageStats): number {
|
||||
return stats.quota ? (stats.used / stats.quota) * 100 : 0;
|
||||
}
|
51
frontend/src/utils/thumbnailUtils.ts
Normal file
51
frontend/src/utils/thumbnailUtils.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { getDocument } from "pdfjs-dist";
|
||||
|
||||
/**
|
||||
* Generate thumbnail for a PDF file during upload
|
||||
* Returns base64 data URL or undefined if generation fails
|
||||
*/
|
||||
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
||||
// Skip thumbnail generation for large files to avoid memory issues
|
||||
if (file.size >= 50 * 1024 * 1024) { // 50MB limit
|
||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
||||
// Only read first 2MB for thumbnail generation to save memory
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
|
||||
const pdf = await getDocument({
|
||||
data: arrayBuffer,
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
}).promise;
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.2 }); // Smaller scale for memory efficiency
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
// Immediately clean up memory after thumbnail generation
|
||||
pdf.destroy();
|
||||
console.log('Thumbnail generated and PDF destroyed for', file.name);
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for', file.name, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
@ -113,5 +113,5 @@
|
||||
"include": [
|
||||
"src",
|
||||
"src/global.d.ts"
|
||||
]
|
||||
, "vite.config.ts" ]
|
||||
}
|
||||
|
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
@ -1453,6 +1453,12 @@ fileManager.dragDrop=Drag & Drop files here
|
||||
fileManager.clickToUpload=Click to upload files
|
||||
fileManager.selectedFiles=Selected Files
|
||||
fileManager.clearAll=Clear All
|
||||
fileManager.storage=Storage
|
||||
fileManager.filesStored=files stored
|
||||
fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
|
Loading…
x
Reference in New Issue
Block a user