IndexxedDb refactored

This commit is contained in:
Reece
2025-06-04 19:04:56 +01:00
parent a3c4f1a305
commit 16f150a203
17 changed files with 1856 additions and 218 deletions

View 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;

View File

@@ -1,134 +1,20 @@
import React, { useState, useEffect } from "react";
import { Card, Group, Text, Stack, Image, Badge, Button, Box, Flex, ThemeIcon } from "@mantine/core";
import { Box, Flex, Text, Notification } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { GlobalWorkerOptions, getDocument } from "pdfjs-dist";
import { GlobalWorkerOptions } from "pdfjs-dist";
import { StorageStats } from "../services/fileStorage";
import { FileWithUrl, defaultStorageConfig } from "../types/file";
// Refactored imports
import { fileOperationsService } from "../services/fileOperationsService";
import { checkStorageWarnings } from "../utils/storageUtils";
import StorageStatsCard from "./StorageStatsCard";
import FileCard from "./FileCard.standalone";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
export interface FileWithUrl extends File {
url?: string;
file?: File;
}
function getFileDate(file: File): string {
if (file.lastModified) {
return new Date(file.lastModified).toLocaleString();
}
return "Unknown";
}
function getFileSize(file: File): string {
if (!file.size) return "Unknown";
if (file.size < 1024) return `${file.size} B`;
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
return `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
}
function usePdfThumbnail(file: File | undefined | null): string | null {
const [thumb, setThumb] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function generate() {
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 0.5 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (context) {
await page.render({ canvasContext: context, viewport }).promise;
if (!cancelled) setThumb(canvas.toDataURL());
}
} catch {
if (!cancelled) setThumb(null);
}
}
generate();
return () => { cancelled = true; };
}, [file]);
return thumb;
}
interface FileCardProps {
file: File;
onRemove: () => void;
onDoubleClick?: () => void;
}
function FileCard({ file, onRemove, onDoubleClick }: FileCardProps) {
const { t } = useTranslation();
const thumb = usePdfThumbnail(file);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{ width: 225, minWidth: 180, maxWidth: 260, cursor: onDoubleClick ? "pointer" : undefined }}
onDoubleClick={onDoubleClick}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
}}
>
{thumb ? (
<Image src={thumb} alt="PDF thumbnail" height={110} width={80} fit="contain" radius="sm" />
) : (
<ThemeIcon
variant="light"
color="red"
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="gray" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={onRemove}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
}
interface FileManagerProps {
files: FileWithUrl[];
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
@@ -145,21 +31,212 @@ const FileManager: React.FC<FileManagerProps> = ({
setCurrentView,
}) => {
const { t } = useTranslation();
const handleDrop = (uploadedFiles: File[]) => {
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...uploadedFiles] : uploadedFiles));
const [loading, setLoading] = useState(false);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
// Extract operations from service for cleaner code
const {
loadStorageStats,
forceReloadFiles,
loadExistingFiles,
uploadFiles,
removeFile,
clearAllFiles,
createBlobUrlForFile,
checkForPurge,
updateStorageStatsIncremental
} = fileOperationsService;
// Add CSS for spinner animation
useEffect(() => {
if (!document.querySelector('#spinner-animation')) {
const style = document.createElement('style');
style.id = 'spinner-animation';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}, []);
// Load existing files from IndexedDB on mount
useEffect(() => {
if (!filesLoaded) {
handleLoadExistingFiles();
}
}, [filesLoaded]);
// Load storage stats and set up periodic updates
useEffect(() => {
handleLoadStorageStats();
const interval = setInterval(async () => {
await handleLoadStorageStats();
await handleCheckForPurge();
}, 10000); // Update every 10 seconds
return () => clearInterval(interval);
}, []);
// Sync UI with IndexedDB whenever storage stats change
useEffect(() => {
const syncWithStorage = async () => {
if (storageStats && filesLoaded) {
// If file counts don't match, force reload
if (storageStats.fileCount !== files.length) {
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
}
}
};
syncWithStorage();
}, [storageStats, filesLoaded, files.length]);
// Handlers using extracted operations
const handleLoadStorageStats = async () => {
const stats = await loadStorageStats();
if (stats) {
setStorageStats(stats);
// Check for storage warnings
const warning = checkStorageWarnings(stats);
if (warning) {
setNotification(warning);
}
}
};
const handleRemoveFile = (index: number) => {
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
const handleLoadExistingFiles = async () => {
try {
const loadedFiles = await loadExistingFiles(filesLoaded, files);
setFiles(loadedFiles);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load existing files:', error);
setFilesLoaded(true);
}
};
const handleCheckForPurge = async () => {
try {
const isPurged = await checkForPurge(files);
if (isPurged) {
console.warn('IndexedDB purge detected - forcing UI reload');
setNotification('Browser cleared storage. Files have been removed. Please re-upload.');
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
setFilesLoaded(true);
}
} catch (error) {
console.error('Error checking for purge:', error);
}
};
const handleDrop = async (uploadedFiles: File[]) => {
setLoading(true);
try {
const newFiles = await uploadFiles(uploadedFiles, defaultStorageConfig.useIndexedDB);
// Update files state
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
setStorageStats(updatedStats);
// Check for storage warnings
const warning = checkStorageWarnings(updatedStats);
if (warning) {
setNotification(warning);
}
}
} catch (error) {
console.error('Error handling file drop:', error);
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
} finally {
setLoading(false);
}
};
const handleRemoveFile = async (index: number) => {
const file = files[index];
try {
await removeFile(file);
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
setStorageStats(updatedStats);
}
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
}
};
const handleClearAll = async () => {
try {
await clearAllFiles(files);
// Reset storage stats
if (storageStats) {
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
setStorageStats(clearedStats);
}
setFiles([]);
} catch (error) {
console.error('Failed to clear all files:', error);
}
};
const handleReloadFiles = () => {
setFilesLoaded(false);
setFiles([]);
};
const handleFileDoubleClick = async (file: FileWithUrl) => {
if (setPdfFile) {
try {
const url = await createBlobUrlForFile(file);
setPdfFile({ file: file, url: url });
setCurrentView && setCurrentView("viewer");
} catch (error) {
console.error('Failed to create blob URL for file:', error);
setNotification('Failed to open file. It may have been removed from storage.');
}
}
};
return (
<div style={{ width: "100%", margin: "0 auto", justifyContent: "center", display: "flex", flexDirection: "column", alignItems: "center", padding: "20px" }}>
<div style={{
width: "100%",
margin: "0 auto",
justifyContent: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "20px"
}}>
{/* File Upload Dropzone */}
<Dropzone
onDrop={handleDrop}
accept={[MIME_TYPES.pdf]}
multiple={allowMultiple}
maxSize={20 * 1024 * 1024}
maxSize={2 * 1024 * 1024 * 1024} // 2GB limit
loading={loading}
style={{
marginTop: 16,
marginBottom: 16,
@@ -169,15 +246,23 @@ const FileManager: React.FC<FileManagerProps> = ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width:"90%"
width: "90%"
}}
>
<Group justify="center" gap="xl" style={{ pointerEvents: "none" }}>
<Text size="md">
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
</Text>
</Group>
<Text size="md">
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
</Text>
</Dropzone>
{/* Storage Stats Card */}
<StorageStatsCard
storageStats={storageStats}
filesCount={files.length}
onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles}
/>
{/* Files Display */}
{files.length === 0 ? (
<Text c="dimmed" ta="center">
{t("noFileSelected", "No files uploaded yet.")}
@@ -192,23 +277,26 @@ const FileManager: React.FC<FileManagerProps> = ({
>
{files.map((file, idx) => (
<FileCard
key={file.name + idx}
key={file.id || file.name + idx}
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => {
const fileObj = (file as FileWithUrl).file || file;
setPdfFile &&
setPdfFile({
file: fileObj,
url: URL.createObjectURL(fileObj),
});
setCurrentView && setCurrentView("viewer");
}}
/>
onDoubleClick={() => handleFileDoubleClick(file)}
as FileWithUrl />
))}
</Flex>
</Box>
)}
{/* Notifications */}
{notification && (
<Notification
color="blue"
onClose={() => setNotification(null)}
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
>
{notification}
</Notification>
)}
</div>
);
};

View 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;

View File

@@ -10,9 +10,118 @@ import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../services/fileStorage";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
// Lazy loading page image component
interface LazyPageImageProps {
pageIndex: number;
zoom: number;
theme: any;
isFirst: boolean;
renderPage: (pageIndex: number) => Promise<string | null>;
pageImages: (string | null)[];
setPageRef: (index: number, ref: HTMLImageElement | null) => void;
}
const LazyPageImage: React.FC<LazyPageImageProps> = ({
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
}) => {
const [isVisible, setIsVisible] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(pageImages[pageIndex]);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !imageUrl) {
setIsVisible(true);
}
});
},
{
rootMargin: '200px', // Start loading 200px before visible
threshold: 0.1
}
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [imageUrl]);
useEffect(() => {
if (isVisible && !imageUrl) {
renderPage(pageIndex).then((url) => {
if (url) setImageUrl(url);
});
}
}, [isVisible, imageUrl, pageIndex, renderPage]);
useEffect(() => {
if (imgRef.current) {
setPageRef(pageIndex, imgRef.current);
}
}, [pageIndex, setPageRef]);
if (imageUrl) {
return (
<img
ref={imgRef}
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
}}
/>
);
}
// Placeholder while loading
return (
<div
ref={imgRef}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
height: 800 * zoom, // Estimated height
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
border: '1px dashed #ccc'
}}
>
{isVisible ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 8px'
}} />
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
</div>
) : (
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
)}
</div>
);
};
export interface ViewerProps {
pdfFile: { file: File; url: string } | null;
setPdfFile: (file: { file: File; url: string } | null) => void;
@@ -38,7 +147,52 @@ const Viewer: React.FC<ViewerProps> = ({
const scrollAreaRef = useRef<HTMLDivElement>(null);
const userInitiatedRef = useRef(false);
const suppressScrollRef = useRef(false);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
const pageNum = pageIndex + 1;
if (pageImages[pageIndex]) {
return pageImages[pageIndex]; // Already rendered
}
renderingPagesRef.current.add(pageIndex);
try {
const page = await pdfDocRef.current.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx, viewport }).promise;
const dataUrl = canvas.toDataURL();
// Update the pageImages array
setPageImages(prev => {
const newImages = [...prev];
newImages[pageIndex] = dataUrl;
return newImages;
});
renderingPagesRef.current.delete(pageIndex);
return dataUrl;
}
} catch (error) {
console.error(`Failed to render page ${pageNum}:`, error);
}
renderingPagesRef.current.delete(pageIndex);
return null;
};
// Listen for hash changes and update currentPage
useEffect(() => {
@@ -121,7 +275,7 @@ const Viewer: React.FC<ViewerProps> = ({
useEffect(() => {
let cancelled = false;
async function renderPages() {
async function loadPdfInfo() {
if (!pdfFile || !pdfFile.url) {
setNumPages(0);
setPageImages([]);
@@ -129,29 +283,49 @@ const Viewer: React.FC<ViewerProps> = ({
}
setLoading(true);
try {
const pdf = await getDocument(pdfFile.url).promise;
setNumPages(pdf.numPages);
const images: string[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx, viewport }).promise;
images.push(canvas.toDataURL());
let pdfUrl = pdfFile.url;
// Handle special IndexedDB URLs for large files
if (pdfFile.url.startsWith('indexeddb:')) {
const fileId = pdfFile.url.replace('indexeddb:', '');
console.log('Loading large file from IndexedDB:', fileId);
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
// Use ArrayBuffer directly instead of creating blob URL
const pdf = await getDocument({ data: arrayBuffer }).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
} else {
// Standard blob URL or regular URL
const pdf = await getDocument(pdfUrl).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
}
} catch (error) {
console.error('Failed to load PDF:', error);
if (!cancelled) {
setPageImages([]);
setNumPages(0);
}
if (!cancelled) setPageImages(images);
} catch {
if (!cancelled) setPageImages([]);
}
if (!cancelled) setLoading(false);
}
renderPages();
return () => { cancelled = true; };
loadPdfInfo();
return () => {
cancelled = true;
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [pdfFile]);
useEffect(() => {
@@ -210,53 +384,44 @@ const Viewer: React.FC<ViewerProps> = ({
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{pageImages.length === 0 && (
{numPages === 0 && (
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(pageImages.length / 2) }).map((_, i) => (
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<img
ref={el => { pageRefs.current[i * 2] = el; }}
src={pageImages[i * 2]}
alt={`Page ${i * 2 + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
}}
<LazyPageImage
pageIndex={i * 2}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
{pageImages[i * 2 + 1] && (
<img
ref={el => { pageRefs.current[i * 2 + 1] = el; }}
src={pageImages[i * 2 + 1]}
alt={`Page ${i * 2 + 2}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: i === 0 ? theme.spacing.xl : 0, // <-- add gap to first row
}}
{i * 2 + 1 < numPages && (
<LazyPageImage
pageIndex={i * 2 + 1}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
)}
</Group>
))
: pageImages.map((img, idx) => (
<img
: Array.from({ length: numPages }).map((_, idx) => (
<LazyPageImage
key={idx}
ref={el => { pageRefs.current[idx] = el; }}
src={img}
alt={`Page ${idx + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: idx === 0 ? theme.spacing.xl : 0, // <-- add gap to first page
}}
pageIndex={idx}
zoom={zoom}
theme={theme}
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
/>
))}
</Stack>