feat: Enhance file management with draft support and improved loading mechanisms

This commit is contained in:
Reece Browne 2025-08-20 00:27:03 +01:00
parent b8cf5fda7e
commit 6aa8e42941
8 changed files with 161 additions and 55 deletions

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; import { Group, Box, Text, ActionIcon, Checkbox, Divider, Badge } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
@ -64,7 +64,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
</Box> </Box>
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text> <Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
{file.isDraft && (
<Badge size="xs" variant="light" color="orange">
DRAFT
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text> <Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box> </Box>

View File

@ -556,10 +556,34 @@ const PageEditor = ({
// Enhanced draft save using centralized IndexedDB manager // Enhanced draft save using centralized IndexedDB manager
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
const draftKey = `draft-${doc.id || 'merged'}`; const draftKey = `draft-${doc.id || 'merged'}`;
// Convert PDF document to bytes for storage
const pdfBytes = await doc.save();
const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
// Create a temporary file for thumbnail generation
const tempFile = new File([pdfBytes], `Draft - ${originalFileNames.join(', ') || 'Untitled'}.pdf`, {
type: 'application/pdf',
lastModified: Date.now()
});
// Generate thumbnail for the draft
let thumbnail: string | undefined;
try {
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
thumbnail = await generateThumbnailForFile(tempFile);
} catch (error) {
console.warn('Failed to generate thumbnail for draft:', error);
}
const draftData = { const draftData = {
document: doc, id: draftKey,
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`,
pdfData: pdfBytes,
size: pdfBytes.length,
timestamp: Date.now(), timestamp: Date.now(),
originalFiles: activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean) thumbnail,
originalFiles: originalFileNames
}; };
try { try {

View File

@ -33,7 +33,7 @@ const LandingPage = () => {
{/* White PDF Page Background */} {/* White PDF Page Background */}
<Dropzone <Dropzone
onDrop={handleFileDrop} onDrop={handleFileDrop}
accept={["*/*"] as any} accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true} multiple={true}
className="w-4/5 flex items-center justify-center h-[95vh]" className="w-4/5 flex items-center justify-center h-[95vh]"
style={{ style={{
@ -125,7 +125,7 @@ const LandingPage = () => {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="*/*" accept=".pdf,.zip"
onChange={handleFileSelect} onChange={handleFileSelect}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />

View File

@ -208,48 +208,12 @@ function FileContextInner({
dispatch dispatch
}), [actions]); }), [actions]);
// Load files from persistence on mount // Persistence loading disabled - files only loaded on explicit user action
useEffect(() => { // useEffect(() => {
if (!enablePersistence || !indexedDB) return; // if (!enablePersistence || !indexedDB) return;
// const loadFromPersistence = async () => { /* loading logic removed */ };
const loadFromPersistence = async () => { // loadFromPersistence();
try { // }, [enablePersistence, indexedDB]);
// Load metadata to populate file list (actual File objects loaded on-demand)
const metadata = await indexedDB.loadAllMetadata();
if (metadata.length === 0) {
if (DEBUG) console.log('📄 No files found in persistence');
return;
}
if (DEBUG) {
console.log(`📄 Loading ${metadata.length} files from persistence`);
}
// Create FileRecords from metadata - File objects loaded when needed
const fileRecords = metadata.map(meta => ({
id: meta.id,
name: meta.name,
size: meta.size,
type: meta.type,
lastModified: meta.lastModified,
thumbnailUrl: meta.thumbnail,
isPinned: false,
createdAt: Date.now()
}));
// Add to state so file manager can show them
dispatch({
type: 'ADD_FILES',
payload: { fileRecords }
});
} catch (error) {
console.error('Failed to load files from persistence:', error);
}
};
loadFromPersistence();
}, [enablePersistence, indexedDB]); // Only run when these change
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {

View File

@ -25,6 +25,9 @@ interface IndexedDBContextValue {
// Utilities // Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>; getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
// Draft operations
loadAllDraftMetadata: () => Promise<FileMetadata[]>;
} }
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null); const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
@ -56,6 +59,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
}, []); }, []);
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => { const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
// DEBUG: Check original file before saving
if (DEBUG && file.type === 'application/pdf') {
try {
const { getDocument } = await import('pdfjs-dist');
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
console.log(`🔍 Saving file to IndexedDB:`, {
name: file.name,
size: file.size,
pages: pdf.numPages
});
} catch (error) {
console.error(`🔍 Error validating file before save:`, error);
}
}
// Use existing thumbnail or generate new one // Use existing thumbnail or generate new one
const thumbnail = existingThumbnail || await generateThumbnailForFile(file); const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
@ -99,6 +118,27 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
lastModified: storedFile.lastModified lastModified: storedFile.lastModified
}); });
// DEBUG: Check if file reconstruction is working
if (DEBUG && file.type === 'application/pdf') {
console.log(`🔍 File loaded from IndexedDB:`, {
name: file.name,
originalSize: storedFile.size,
reconstructedSize: file.size,
dataLength: storedFile.data.byteLength,
sizesMatch: storedFile.size === file.size
});
// Quick PDF validation
try {
const { getDocument } = await import('pdfjs-dist');
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
console.log(`🔍 PDF validation: ${pdf.numPages} pages in reconstructed file`);
} catch (error) {
console.error(`🔍 PDF reconstruction error:`, error);
}
}
// Cache for future use with LRU eviction // Cache for future use with LRU eviction
fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries(); evictLRUEntries();
@ -177,6 +217,38 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
return await fileStorage.getStorageStats(); return await fileStorage.getStorageStats();
}, []); }, []);
const loadAllDraftMetadata = useCallback(async (): Promise<FileMetadata[]> => {
try {
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
return new Promise((resolve, reject) => {
const transaction = db.transaction(['drafts'], 'readonly');
const store = transaction.objectStore('drafts');
const request = store.getAll();
request.onsuccess = () => {
const drafts = request.result || [];
const draftMetadata: FileMetadata[] = drafts.map((draft: any) => ({
id: draft.id,
name: draft.name || `Draft ${draft.id}`,
type: 'application/pdf',
size: draft.size || 0,
lastModified: draft.timestamp || Date.now(),
thumbnail: draft.thumbnail,
isDraft: true
}));
resolve(draftMetadata);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.warn('Failed to load draft metadata:', error);
return [];
}
}, []);
// No periodic cleanup needed - LRU eviction happens on-demand when cache fills // No periodic cleanup needed - LRU eviction happens on-demand when cache fills
const value: IndexedDBContextValue = { const value: IndexedDBContextValue = {
@ -187,7 +259,8 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
loadAllMetadata, loadAllMetadata,
deleteMultiple, deleteMultiple,
clearAll, clearAll,
getStorageStats getStorageStats,
loadAllDraftMetadata
}; };
return ( return (

View File

@ -12,7 +12,39 @@ export const useFileManager = () => {
throw new Error('IndexedDB context not available'); throw new Error('IndexedDB context not available');
} }
// Try ID first (preferred) // Handle drafts differently from regular files
if (fileMetadata.isDraft) {
// Load draft from the drafts database
try {
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
return new Promise((resolve, reject) => {
const transaction = db.transaction(['drafts'], 'readonly');
const store = transaction.objectStore('drafts');
const request = store.get(fileMetadata.id);
request.onsuccess = () => {
const draft = request.result;
if (draft && draft.pdfData) {
const file = new File([draft.pdfData], fileMetadata.name, {
type: 'application/pdf',
lastModified: fileMetadata.lastModified
});
resolve(file);
} else {
reject(new Error('Draft data not found'));
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
}
}
// Regular file loading
if (fileMetadata.id) { if (fileMetadata.id) {
const file = await indexedDB.loadFile(fileMetadata.id); const file = await indexedDB.loadFile(fileMetadata.id);
if (file) { if (file) {
@ -29,11 +61,16 @@ export const useFileManager = () => {
return []; return [];
} }
// Get metadata only (no file data) for performance // Load both regular files and drafts
const storedFileMetadata = await indexedDB.loadAllMetadata(); const [storedFileMetadata, draftMetadata] = await Promise.all([
const sortedFiles = storedFileMetadata.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); indexedDB.loadAllMetadata(),
indexedDB.loadAllDraftMetadata()
]);
// Combine and sort by last modified
const allFiles = [...storedFileMetadata, ...draftMetadata];
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
// Already in correct FileMetadata format
return sortedFiles; return sortedFiles;
} catch (error) { } catch (error) {
console.error('Failed to load recent files:', error); console.error('Failed to load recent files:', error);

View File

@ -156,7 +156,7 @@ class FileStorageService {
} }
cursor.continue(); cursor.continue();
} else { } else {
console.log('Loaded metadata for', files.length, 'files without loading data'); // Metadata loaded efficiently without file data
resolve(files); resolve(files);
} }
}; };

View File

@ -25,6 +25,7 @@ export interface FileMetadata {
size: number; size: number;
lastModified: number; lastModified: number;
thumbnail?: string; thumbnail?: string;
isDraft?: boolean; // Marks files as draft versions
/** @deprecated Legacy compatibility - will be removed */ /** @deprecated Legacy compatibility - will be removed */
storedInIndexedDB?: boolean; storedInIndexedDB?: boolean;
} }