mirror of
				https://github.com/Frooodle/Stirling-PDF.git
				synced 2025-11-01 01:21:18 +01:00 
			
		
		
		
	IndexxedDb refactored
This commit is contained in:
		
							parent
							
								
									a3c4f1a305
								
							
						
					
					
						commit
						16f150a203
					
				
							
								
								
									
										1
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							@ -10,6 +10,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# production
 | 
					# production
 | 
				
			||||||
/build
 | 
					/build
 | 
				
			||||||
 | 
					/dist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# misc
 | 
					# misc
 | 
				
			||||||
.DS_Store
 | 
					.DS_Store
 | 
				
			||||||
 | 
				
			|||||||
@ -1576,7 +1576,13 @@
 | 
				
			|||||||
    "dragDrop": "Drag & Drop files here",
 | 
					    "dragDrop": "Drag & Drop files here",
 | 
				
			||||||
    "clickToUpload": "Click to upload files",
 | 
					    "clickToUpload": "Click to upload files",
 | 
				
			||||||
    "selectedFiles": "Selected 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": {
 | 
					  "pageEditor": {
 | 
				
			||||||
    "title": "Page Editor",
 | 
					    "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 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 { Dropzone, MIME_TYPES } from "@mantine/dropzone";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					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";
 | 
					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 {
 | 
					interface FileManagerProps {
 | 
				
			||||||
  files: FileWithUrl[];
 | 
					  files: FileWithUrl[];
 | 
				
			||||||
  setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
 | 
					  setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
 | 
				
			||||||
@ -145,21 +31,212 @@ const FileManager: React.FC<FileManagerProps> = ({
 | 
				
			|||||||
  setCurrentView,
 | 
					  setCurrentView,
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const handleDrop = (uploadedFiles: File[]) => {
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
    setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...uploadedFiles] : uploadedFiles));
 | 
					  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) => {
 | 
					  const handleLoadExistingFiles = async () => {
 | 
				
			||||||
    setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
 | 
					    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 (
 | 
					  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
 | 
					      <Dropzone
 | 
				
			||||||
        onDrop={handleDrop}
 | 
					        onDrop={handleDrop}
 | 
				
			||||||
        accept={[MIME_TYPES.pdf]}
 | 
					        accept={[MIME_TYPES.pdf]}
 | 
				
			||||||
        multiple={allowMultiple}
 | 
					        multiple={allowMultiple}
 | 
				
			||||||
        maxSize={20 * 1024 * 1024}
 | 
					        maxSize={2 * 1024 * 1024 * 1024} // 2GB limit
 | 
				
			||||||
 | 
					        loading={loading}
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
          marginTop: 16,
 | 
					          marginTop: 16,
 | 
				
			||||||
          marginBottom: 16,
 | 
					          marginBottom: 16,
 | 
				
			||||||
@ -169,15 +246,23 @@ const FileManager: React.FC<FileManagerProps> = ({
 | 
				
			|||||||
          display: "flex",
 | 
					          display: "flex",
 | 
				
			||||||
          alignItems: "center",
 | 
					          alignItems: "center",
 | 
				
			||||||
          justifyContent: "center",
 | 
					          justifyContent: "center",
 | 
				
			||||||
          width:"90%"
 | 
					          width: "90%"
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <Group justify="center" gap="xl" style={{ pointerEvents: "none" }}>
 | 
					        <Text size="md">
 | 
				
			||||||
          <Text size="md">
 | 
					          {t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
 | 
				
			||||||
            {t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
 | 
					        </Text>
 | 
				
			||||||
          </Text>
 | 
					 | 
				
			||||||
        </Group>
 | 
					 | 
				
			||||||
      </Dropzone>
 | 
					      </Dropzone>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Storage Stats Card */}
 | 
				
			||||||
 | 
					      <StorageStatsCard
 | 
				
			||||||
 | 
					        storageStats={storageStats}
 | 
				
			||||||
 | 
					        filesCount={files.length}
 | 
				
			||||||
 | 
					        onClearAll={handleClearAll}
 | 
				
			||||||
 | 
					        onReloadFiles={handleReloadFiles}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Files Display */}
 | 
				
			||||||
      {files.length === 0 ? (
 | 
					      {files.length === 0 ? (
 | 
				
			||||||
        <Text c="dimmed" ta="center">
 | 
					        <Text c="dimmed" ta="center">
 | 
				
			||||||
          {t("noFileSelected", "No files uploaded yet.")}
 | 
					          {t("noFileSelected", "No files uploaded yet.")}
 | 
				
			||||||
@ -192,23 +277,26 @@ const FileManager: React.FC<FileManagerProps> = ({
 | 
				
			|||||||
          >
 | 
					          >
 | 
				
			||||||
            {files.map((file, idx) => (
 | 
					            {files.map((file, idx) => (
 | 
				
			||||||
              <FileCard
 | 
					              <FileCard
 | 
				
			||||||
                key={file.name + idx}
 | 
					                key={file.id || file.name + idx}
 | 
				
			||||||
                file={file}
 | 
					                file={file}
 | 
				
			||||||
                onRemove={() => handleRemoveFile(idx)}
 | 
					                onRemove={() => handleRemoveFile(idx)}
 | 
				
			||||||
                onDoubleClick={() => {
 | 
					                onDoubleClick={() => handleFileDoubleClick(file)}
 | 
				
			||||||
                  const fileObj = (file as FileWithUrl).file || file;
 | 
					 as FileWithUrl              />
 | 
				
			||||||
                  setPdfFile &&
 | 
					 | 
				
			||||||
                    setPdfFile({
 | 
					 | 
				
			||||||
                      file: fileObj,
 | 
					 | 
				
			||||||
                      url: URL.createObjectURL(fileObj),
 | 
					 | 
				
			||||||
                    });
 | 
					 | 
				
			||||||
                  setCurrentView && setCurrentView("viewer");
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            ))}
 | 
					            ))}
 | 
				
			||||||
          </Flex>
 | 
					          </Flex>
 | 
				
			||||||
        </Box>
 | 
					        </Box>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Notifications */}
 | 
				
			||||||
 | 
					      {notification && (
 | 
				
			||||||
 | 
					        <Notification
 | 
				
			||||||
 | 
					          color="blue"
 | 
				
			||||||
 | 
					          onClose={() => setNotification(null)}
 | 
				
			||||||
 | 
					          style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {notification}
 | 
				
			||||||
 | 
					        </Notification>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </div>
 | 
					    </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 ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
 | 
				
			||||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
 | 
					import DescriptionIcon from "@mui/icons-material/Description"; // for single page
 | 
				
			||||||
import { useLocalStorage } from "@mantine/hooks";
 | 
					import { useLocalStorage } from "@mantine/hooks";
 | 
				
			||||||
 | 
					import { fileStorage } from "../services/fileStorage";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
 | 
					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 {
 | 
					export interface ViewerProps {
 | 
				
			||||||
  pdfFile: { file: File; url: string } | null;
 | 
					  pdfFile: { file: File; url: string } | null;
 | 
				
			||||||
  setPdfFile: (file: { file: File; url: string } | null) => void;
 | 
					  setPdfFile: (file: { file: File; url: string } | null) => void;
 | 
				
			||||||
@ -38,7 +147,52 @@ const Viewer: React.FC<ViewerProps> = ({
 | 
				
			|||||||
  const scrollAreaRef = useRef<HTMLDivElement>(null);
 | 
					  const scrollAreaRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
  const userInitiatedRef = useRef(false);
 | 
					  const userInitiatedRef = useRef(false);
 | 
				
			||||||
  const suppressScrollRef = 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
 | 
					  // Listen for hash changes and update currentPage
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
@ -121,7 +275,7 @@ const Viewer: React.FC<ViewerProps> = ({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    let cancelled = false;
 | 
					    let cancelled = false;
 | 
				
			||||||
    async function renderPages() {
 | 
					    async function loadPdfInfo() {
 | 
				
			||||||
      if (!pdfFile || !pdfFile.url) {
 | 
					      if (!pdfFile || !pdfFile.url) {
 | 
				
			||||||
        setNumPages(0);
 | 
					        setNumPages(0);
 | 
				
			||||||
        setPageImages([]);
 | 
					        setPageImages([]);
 | 
				
			||||||
@ -129,29 +283,49 @@ const Viewer: React.FC<ViewerProps> = ({
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const pdf = await getDocument(pdfFile.url).promise;
 | 
					        let pdfUrl = pdfFile.url;
 | 
				
			||||||
        setNumPages(pdf.numPages);
 | 
					        
 | 
				
			||||||
        const images: string[] = [];
 | 
					        // Handle special IndexedDB URLs for large files
 | 
				
			||||||
        for (let i = 1; i <= pdf.numPages; i++) {
 | 
					        if (pdfFile.url.startsWith('indexeddb:')) {
 | 
				
			||||||
          const page = await pdf.getPage(i);
 | 
					          const fileId = pdfFile.url.replace('indexeddb:', '');
 | 
				
			||||||
          const viewport = page.getViewport({ scale: 1.2 });
 | 
					          console.log('Loading large file from IndexedDB:', fileId);
 | 
				
			||||||
          const canvas = document.createElement("canvas");
 | 
					          
 | 
				
			||||||
          canvas.width = viewport.width;
 | 
					          // Get data directly from IndexedDB
 | 
				
			||||||
          canvas.height = viewport.height;
 | 
					          const arrayBuffer = await fileStorage.getFileData(fileId);
 | 
				
			||||||
          const ctx = canvas.getContext("2d");
 | 
					          if (!arrayBuffer) {
 | 
				
			||||||
          if (ctx) {
 | 
					            throw new Error('File not found in IndexedDB - may have been purged by browser');
 | 
				
			||||||
            await page.render({ canvasContext: ctx, viewport }).promise;
 | 
					 | 
				
			||||||
            images.push(canvas.toDataURL());
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          // 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);
 | 
					      if (!cancelled) setLoading(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    renderPages();
 | 
					    loadPdfInfo();
 | 
				
			||||||
    return () => { cancelled = true; };
 | 
					    return () => { 
 | 
				
			||||||
 | 
					      cancelled = true; 
 | 
				
			||||||
 | 
					      // Cleanup ArrayBuffer reference to help garbage collection
 | 
				
			||||||
 | 
					      currentArrayBufferRef.current = null;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
  }, [pdfFile]);
 | 
					  }, [pdfFile]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
@ -210,53 +384,44 @@ const Viewer: React.FC<ViewerProps> = ({
 | 
				
			|||||||
          viewportRef={scrollAreaRef}
 | 
					          viewportRef={scrollAreaRef}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Stack gap="xl" align="center" >
 | 
					          <Stack gap="xl" align="center" >
 | 
				
			||||||
            {pageImages.length === 0 && (
 | 
					            {numPages === 0 && (
 | 
				
			||||||
              <Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
 | 
					              <Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
            {dualPage
 | 
					            {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" }}>
 | 
					                  <Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
 | 
				
			||||||
                    <img
 | 
					                    <LazyPageImage
 | 
				
			||||||
                      ref={el => { pageRefs.current[i * 2] = el; }}
 | 
					                      pageIndex={i * 2}
 | 
				
			||||||
                      src={pageImages[i * 2]}
 | 
					                      zoom={zoom}
 | 
				
			||||||
                      alt={`Page ${i * 2 + 1}`}
 | 
					                      theme={theme}
 | 
				
			||||||
                      style={{
 | 
					                      isFirst={i === 0}
 | 
				
			||||||
                        width: `${100 * zoom}%`,
 | 
					                      renderPage={renderPage}
 | 
				
			||||||
                        maxWidth: 700 * zoom,
 | 
					                      pageImages={pageImages}
 | 
				
			||||||
                        boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
 | 
					                      setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
 | 
				
			||||||
                        borderRadius: 8,
 | 
					 | 
				
			||||||
                        marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
 | 
					 | 
				
			||||||
                      }}
 | 
					 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                    {pageImages[i * 2 + 1] && (
 | 
					                    {i * 2 + 1 < numPages && (
 | 
				
			||||||
                      <img
 | 
					                      <LazyPageImage
 | 
				
			||||||
                        ref={el => { pageRefs.current[i * 2 + 1] = el; }}
 | 
					                        pageIndex={i * 2 + 1}
 | 
				
			||||||
                        src={pageImages[i * 2 + 1]}
 | 
					                        zoom={zoom}
 | 
				
			||||||
                        alt={`Page ${i * 2 + 2}`}
 | 
					                        theme={theme}
 | 
				
			||||||
                        style={{
 | 
					                        isFirst={i === 0}
 | 
				
			||||||
                          width: `${100 * zoom}%`,
 | 
					                        renderPage={renderPage}
 | 
				
			||||||
                          maxWidth: 700 * zoom,
 | 
					                        pageImages={pageImages}
 | 
				
			||||||
                          boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
 | 
					                        setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
 | 
				
			||||||
                          borderRadius: 8,
 | 
					 | 
				
			||||||
                          marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
 | 
					 | 
				
			||||||
                        }}
 | 
					 | 
				
			||||||
                      />
 | 
					                      />
 | 
				
			||||||
                    )}
 | 
					                    )}
 | 
				
			||||||
                  </Group>
 | 
					                  </Group>
 | 
				
			||||||
                ))
 | 
					                ))
 | 
				
			||||||
              : pageImages.map((img, idx) => (
 | 
					              : Array.from({ length: numPages }).map((_, idx) => (
 | 
				
			||||||
                  <img
 | 
					                  <LazyPageImage
 | 
				
			||||||
                    key={idx}
 | 
					                    key={idx}
 | 
				
			||||||
                    ref={el => { pageRefs.current[idx] = el; }}
 | 
					                    pageIndex={idx}
 | 
				
			||||||
                    src={img}
 | 
					                    zoom={zoom}
 | 
				
			||||||
                    alt={`Page ${idx + 1}`}
 | 
					                    theme={theme}
 | 
				
			||||||
                    style={{
 | 
					                    isFirst={idx === 0}
 | 
				
			||||||
                      width: `${100 * zoom}%`,
 | 
					                    renderPage={renderPage}
 | 
				
			||||||
                      maxWidth: 700 * zoom,
 | 
					                    pageImages={pageImages}
 | 
				
			||||||
                      boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
 | 
					                    setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
 | 
				
			||||||
                      borderRadius: 8,
 | 
					 | 
				
			||||||
                      marginTop: idx === 0 ? theme.spacing.xl : 0, // <-- add gap to first page
 | 
					 | 
				
			||||||
                    }}
 | 
					 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                ))}
 | 
					                ))}
 | 
				
			||||||
          </Stack>
 | 
					          </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 };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										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,9 +2,11 @@ import React, { useState } from "react";
 | 
				
			|||||||
import { useSearchParams } from "react-router-dom";
 | 
					import { useSearchParams } from "react-router-dom";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
import { Stack, Slider, Group, Text, Button, Checkbox, TextInput, Paper } from "@mantine/core";
 | 
					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 {
 | 
					export interface CompressProps {
 | 
				
			||||||
  files?: File[];
 | 
					  files?: FileWithUrl[];
 | 
				
			||||||
  setDownloadUrl?: (url: string) => void;
 | 
					  setDownloadUrl?: (url: string) => void;
 | 
				
			||||||
  setLoading?: (loading: boolean) => void;
 | 
					  setLoading?: (loading: boolean) => void;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -41,21 +43,39 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
 | 
				
			|||||||
    setLocalLoading(true);
 | 
					    setLocalLoading(true);
 | 
				
			||||||
    setLoading?.(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 {
 | 
					    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", {
 | 
					      const res = await fetch("/api/v1/general/compress-pdf", {
 | 
				
			||||||
        method: "POST",
 | 
					        method: "POST",
 | 
				
			||||||
        body: formData,
 | 
					        body: formData,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      const blob = await res.blob();
 | 
					      const blob = await res.blob();
 | 
				
			||||||
      setDownloadUrl?.(URL.createObjectURL(blob));
 | 
					      setDownloadUrl?.(URL.createObjectURL(blob));
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Compression failed:', error);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setLocalLoading(false);
 | 
					      setLocalLoading(false);
 | 
				
			||||||
      setLoading?.(false);
 | 
					      setLoading?.(false);
 | 
				
			||||||
 | 
				
			|||||||
@ -2,9 +2,11 @@ import React, { useState, useEffect } from "react";
 | 
				
			|||||||
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
 | 
					import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
 | 
				
			||||||
import { useSearchParams } from "react-router-dom";
 | 
					import { useSearchParams } from "react-router-dom";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { FileWithUrl } from "../types/file";
 | 
				
			||||||
 | 
					import { fileStorage } from "../services/fileStorage";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface MergePdfPanelProps {
 | 
					export interface MergePdfPanelProps {
 | 
				
			||||||
  files: File[];
 | 
					  files: FileWithUrl[];
 | 
				
			||||||
  setDownloadUrl: (url: string) => void;
 | 
					  setDownloadUrl: (url: string) => void;
 | 
				
			||||||
  params: {
 | 
					  params: {
 | 
				
			||||||
    order: string;
 | 
					    order: string;
 | 
				
			||||||
@ -38,7 +40,22 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const formData = new FormData();
 | 
					    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);
 | 
					    setIsLoading(true);
 | 
				
			||||||
    setErrorMessage(null);
 | 
					    setErrorMessage(null);
 | 
				
			||||||
 | 
				
			|||||||
@ -12,9 +12,11 @@ import {
 | 
				
			|||||||
import { useSearchParams } from "react-router-dom";
 | 
					import { useSearchParams } from "react-router-dom";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
import DownloadIcon from "@mui/icons-material/Download";
 | 
					import DownloadIcon from "@mui/icons-material/Download";
 | 
				
			||||||
 | 
					import { FileWithUrl } from "../types/file";
 | 
				
			||||||
 | 
					import { fileStorage } from "../services/fileStorage";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface SplitPdfPanelProps {
 | 
					export interface SplitPdfPanelProps {
 | 
				
			||||||
  file: { file: File; url: string } | null;
 | 
					  file: { file: FileWithUrl; url: string } | null;
 | 
				
			||||||
  downloadUrl?: string | null;
 | 
					  downloadUrl?: string | null;
 | 
				
			||||||
  setDownloadUrl: (url: string | null) => void;
 | 
					  setDownloadUrl: (url: string | null) => void;
 | 
				
			||||||
  params: {
 | 
					  params: {
 | 
				
			||||||
@ -68,7 +70,21 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const formData = new FormData();
 | 
					    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 = "";
 | 
					    let endpoint = "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1453,6 +1453,12 @@ fileManager.dragDrop=Drag & Drop files here
 | 
				
			|||||||
fileManager.clickToUpload=Click to upload files
 | 
					fileManager.clickToUpload=Click to upload files
 | 
				
			||||||
fileManager.selectedFiles=Selected Files
 | 
					fileManager.selectedFiles=Selected Files
 | 
				
			||||||
fileManager.clearAll=Clear All
 | 
					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
 | 
					# Page Editor
 | 
				
			||||||
pageEditor.title=Page Editor
 | 
					pageEditor.title=Page Editor
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user