mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-24 13:19:23 +00:00
Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/AllToolsSidebar
This commit is contained in:
commit
3360669fbb
@ -24,7 +24,7 @@ indent_size = 2
|
|||||||
insert_final_newline = false
|
insert_final_newline = false
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
[*.js]
|
[{*.js,*.jsx,*.ts,*.tsx}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[*.css]
|
[*.css]
|
||||||
|
80
CLAUDE.md
80
CLAUDE.md
@ -59,12 +59,73 @@ Frontend designed for **stateful document processing**:
|
|||||||
Without cleanup: browser crashes with memory leaks.
|
Without cleanup: browser crashes with memory leaks.
|
||||||
|
|
||||||
#### Tool Development
|
#### Tool Development
|
||||||
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
|
|
||||||
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
|
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||||
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
|
|
||||||
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
|
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||||
- **Parameters**: Tool parameter handling patterns still being standardized
|
- Coordinates all tool operations with consistent interface
|
||||||
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
|
- Integrates with FileContext for operation tracking
|
||||||
|
- Handles validation, error handling, and UI state management
|
||||||
|
|
||||||
|
- **Supporting Hooks**:
|
||||||
|
- **useToolState**: UI state management (loading, progress, error, files)
|
||||||
|
- **useToolApiCalls**: HTTP requests and file processing
|
||||||
|
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||||
|
|
||||||
|
- **Utilities**:
|
||||||
|
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||||
|
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||||
|
- **toolOperationTracker**: FileContext integration utilities
|
||||||
|
|
||||||
|
**Three Tool Patterns**:
|
||||||
|
|
||||||
|
**Pattern 1: Single-File Tools** (Individual processing)
|
||||||
|
- Backend processes one file per API call
|
||||||
|
- Set `multiFileEndpoint: false`
|
||||||
|
- Examples: Compress, Rotate
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'compress',
|
||||||
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
|
buildFormData: (params, file: File) => { /* single file */ },
|
||||||
|
multiFileEndpoint: false,
|
||||||
|
filePrefix: 'compressed_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||||
|
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||||
|
- Set `multiFileEndpoint: true`
|
||||||
|
- Examples: Split, Merge, Overlay
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'split',
|
||||||
|
endpoint: '/api/v1/general/split-pages',
|
||||||
|
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||||
|
multiFileEndpoint: true,
|
||||||
|
filePrefix: 'split_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 3: Complex Tools** (Custom processing)
|
||||||
|
- Tools with complex routing logic or non-standard processing
|
||||||
|
- Provide `customProcessor` for full control
|
||||||
|
- Examples: Convert, OCR
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'convert',
|
||||||
|
customProcessor: async (params, files) => { /* custom logic */ },
|
||||||
|
filePrefix: 'converted_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||||
|
- **Consistent**: All tools follow same pattern and interface
|
||||||
|
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||||
|
- **i18n Ready**: Built-in internationalization support
|
||||||
|
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||||
|
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@ -126,7 +187,10 @@ Without cleanup: browser crashes with memory leaks.
|
|||||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||||
- **State Management**: FileContext handles all file operations and tool navigation
|
- **State Management**: FileContext handles all file operations and tool navigation
|
||||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||||
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||||
|
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||||
|
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||||
|
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||||
|
|
||||||
@ -141,7 +205,7 @@ Without cleanup: browser crashes with memory leaks.
|
|||||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||||
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
|
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||||
|
|
||||||
|
@ -1774,7 +1774,25 @@
|
|||||||
"storageError": "Storage error occurred",
|
"storageError": "Storage error occurred",
|
||||||
"storageLow": "Storage is running low. Consider removing old files.",
|
"storageLow": "Storage is running low. Consider removing old files.",
|
||||||
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
||||||
"noFileSelected": "No files selected"
|
"noFileSelected": "No files selected",
|
||||||
|
"searchFiles": "Search files...",
|
||||||
|
"recent": "Recent",
|
||||||
|
"localFiles": "Local Files",
|
||||||
|
"googleDrive": "Google Drive",
|
||||||
|
"googleDriveShort": "Drive",
|
||||||
|
"myFiles": "My Files",
|
||||||
|
"noRecentFiles": "No recent files found",
|
||||||
|
"dropFilesHint": "Drop files here to upload",
|
||||||
|
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||||
|
"openFiles": "Open Files",
|
||||||
|
"openFile": "Open File",
|
||||||
|
"details": "File Details",
|
||||||
|
"fileName": "Name",
|
||||||
|
"fileFormat": "Format",
|
||||||
|
"fileSize": "Size",
|
||||||
|
"fileVersion": "Version",
|
||||||
|
"totalSelected": "Total Selected",
|
||||||
|
"dropFilesHere": "Drop files here"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||||
|
168
frontend/src/components/FileManager.tsx
Normal file
168
frontend/src/components/FileManager.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Modal } from '@mantine/core';
|
||||||
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { useFileManager } from '../hooks/useFileManager';
|
||||||
|
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||||
|
import { Tool } from '../types/tool';
|
||||||
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
|
import DragOverlay from './fileManager/DragOverlay';
|
||||||
|
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
selectedTool?: Tool | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
|
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
|
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||||
|
|
||||||
|
// File management handlers
|
||||||
|
const isFileSupported = useCallback((fileName: string) => {
|
||||||
|
if (!selectedTool?.supportedFormats) return true;
|
||||||
|
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
return selectedTool.supportedFormats.includes(extension || '');
|
||||||
|
}, [selectedTool?.supportedFormats]);
|
||||||
|
|
||||||
|
const refreshRecentFiles = useCallback(async () => {
|
||||||
|
const files = await loadRecentFiles();
|
||||||
|
setRecentFiles(files);
|
||||||
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
|
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
|
||||||
|
try {
|
||||||
|
const fileObjects = await Promise.all(
|
||||||
|
files.map(async (fileWithUrl) => {
|
||||||
|
return await convertToFile(fileWithUrl);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onFilesSelect(fileObjects);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process selected files:', error);
|
||||||
|
}
|
||||||
|
}, [convertToFile, onFilesSelect]);
|
||||||
|
|
||||||
|
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||||
|
onFilesSelect(files);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process dropped files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onFilesSelect, refreshRecentFiles]);
|
||||||
|
|
||||||
|
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||||
|
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||||
|
}, [handleRemoveFile, recentFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFilesModalOpen) {
|
||||||
|
refreshRecentFiles();
|
||||||
|
} else {
|
||||||
|
// Reset state when modal is closed
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}, [isFilesModalOpen, refreshRecentFiles]);
|
||||||
|
|
||||||
|
// Cleanup any blob URLs when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clean up blob URLs from recent files
|
||||||
|
recentFiles.forEach(file => {
|
||||||
|
if (file.url && file.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [recentFiles]);
|
||||||
|
|
||||||
|
// Modal size constants for consistent scaling
|
||||||
|
const modalHeight = '80vh';
|
||||||
|
const modalWidth = isMobile ? '100%' : '80vw';
|
||||||
|
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
||||||
|
const modalMaxHeight = '1200px';
|
||||||
|
const modalMinWidth = isMobile ? '320px' : '800px';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isFilesModalOpen}
|
||||||
|
onClose={closeFilesModal}
|
||||||
|
size={isMobile ? "100%" : "auto"}
|
||||||
|
centered
|
||||||
|
radius={30}
|
||||||
|
className="overflow-hidden p-0"
|
||||||
|
withCloseButton={false}
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
position: 'relative',
|
||||||
|
margin: isMobile ? '1rem' : '2rem'
|
||||||
|
},
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: modalHeight,
|
||||||
|
width: modalWidth,
|
||||||
|
maxWidth: modalMaxWidth,
|
||||||
|
maxHeight: modalMaxHeight,
|
||||||
|
minWidth: modalMinWidth,
|
||||||
|
margin: '0 auto',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleNewFileUpload}
|
||||||
|
onDragEnter={() => setIsDragging(true)}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
accept={["*/*"]}
|
||||||
|
multiple={true}
|
||||||
|
activateOnClick={false}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '30px',
|
||||||
|
backgroundColor: 'var(--bg-file-manager)'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
inner: { pointerEvents: 'all' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileManagerProvider
|
||||||
|
recentFiles={recentFiles}
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
onClose={closeFilesModal}
|
||||||
|
isFileSupported={isFileSupported}
|
||||||
|
isOpen={isFilesModalOpen}
|
||||||
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
storeFile={storeFile}
|
||||||
|
refreshRecentFiles={refreshRecentFiles}
|
||||||
|
>
|
||||||
|
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||||
|
</FileManagerProvider>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
<DragOverlay isVisible={isDragging} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileManager;
|
@ -1,92 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import WarningIcon from "@mui/icons-material/Warning";
|
|
||||||
import { StorageStats } from "../../services/fileStorage";
|
|
||||||
import { formatFileSize } from "../../utils/fileUtils";
|
|
||||||
import { getStorageUsagePercent } from "../../utils/storageUtils";
|
|
||||||
import { StorageConfig } from "../../types/file";
|
|
||||||
|
|
||||||
interface StorageStatsCardProps {
|
|
||||||
storageStats: StorageStats | null;
|
|
||||||
filesCount: number;
|
|
||||||
onClearAll: () => void;
|
|
||||||
onReloadFiles: () => void;
|
|
||||||
storageConfig: StorageConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageStatsCard = ({
|
|
||||||
storageStats,
|
|
||||||
filesCount,
|
|
||||||
onClearAll,
|
|
||||||
onReloadFiles,
|
|
||||||
storageConfig,
|
|
||||||
}: StorageStatsCardProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!storageStats) return null;
|
|
||||||
|
|
||||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
|
||||||
const totalUsed = storageStats.totalSize || storageStats.used;
|
|
||||||
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
|
|
||||||
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
|
|
||||||
<Card withBorder p="sm">
|
|
||||||
<Group align="center" gap="md">
|
|
||||||
<StorageIcon />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
value={hardLimitPercent}
|
|
||||||
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
|
|
||||||
size="sm"
|
|
||||||
mt={4}
|
|
||||||
/>
|
|
||||||
<Group justify="space-between" mt={2}>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
|
|
||||||
{Math.round(hardLimitPercent)}% used
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{isNearLimit && (
|
|
||||||
<Text size="xs" c="red" mt={4}>
|
|
||||||
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
|
|
||||||
</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}
|
|
||||||
>
|
|
||||||
{t("fileManager.reloadFiles", "Reload Files")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StorageStatsCard;
|
|
126
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal file
126
frontend/src/components/fileManager/CompactFileDetails.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getFileSize } from '../../utils/fileUtils';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface CompactFileDetailsProps {
|
||||||
|
currentFile: FileWithUrl | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
currentFileIndex: number;
|
||||||
|
numberOfFiles: number;
|
||||||
|
isAnimating: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||||
|
currentFile,
|
||||||
|
thumbnail,
|
||||||
|
selectedFiles,
|
||||||
|
currentFileIndex,
|
||||||
|
numberOfFiles,
|
||||||
|
isAnimating,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onOpenFiles
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasSelection = selectedFiles.length > 0;
|
||||||
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
{/* Compact mobile layout */}
|
||||||
|
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
{/* Small preview */}
|
||||||
|
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{currentFile && thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={currentFile.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentFile ? (
|
||||||
|
<Center style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
borderRadius: 4
|
||||||
|
}}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{currentFile ? currentFile.name : 'No file selected'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentFile ? getFileSize(currentFile) : ''}
|
||||||
|
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||||
|
</Text>
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<Text size="xs" c="blue">
|
||||||
|
{currentFileIndex + 1} of {selectedFiles.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation arrows for multiple files */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<Box style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompactFileDetails;
|
89
frontend/src/components/fileManager/DesktopLayout.tsx
Normal file
89
frontend/src/components/fileManager/DesktopLayout.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid } from '@mantine/core';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const DesktopLayout: React.FC = () => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
recentFiles,
|
||||||
|
modalHeight,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
||||||
|
{/* Column 1: File Sources */}
|
||||||
|
<Grid.Col span="content" p="lg" style={{
|
||||||
|
minWidth: '13.625rem',
|
||||||
|
width: '13.625rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
<FileSourceButtons />
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 2: File List */}
|
||||||
|
<Grid.Col span="auto" style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: '1 1 0px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'var(--bg-file-list)',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||||
|
}}>
|
||||||
|
<SearchInput />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
scrollAreaHeight={`calc(${modalHeight} )`}
|
||||||
|
scrollAreaStyle={{
|
||||||
|
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Column 3: File Details */}
|
||||||
|
<Grid.Col p="xl" span="content" style={{
|
||||||
|
minWidth: '25rem',
|
||||||
|
width: '25rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '18rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||||
|
<FileDetails />
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput />
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopLayout;
|
44
frontend/src/components/fileManager/DragOverlay.tsx
Normal file
44
frontend/src/components/fileManager/DragOverlay.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface DragOverlayProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
||||||
|
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||||
|
borderRadius: '1.875rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
|
||||||
|
<Text size="xl" fw={500} c="blue.6">
|
||||||
|
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DragOverlay;
|
116
frontend/src/components/fileManager/FileDetails.tsx
Normal file
116
frontend/src/components/fileManager/FileDetails.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Stack, Button } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
import FilePreview from './FilePreview';
|
||||||
|
import FileInfoCard from './FileInfoCard';
|
||||||
|
import CompactFileDetails from './CompactFileDetails';
|
||||||
|
|
||||||
|
interface FileDetailsProps {
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||||
|
compact = false
|
||||||
|
}) => {
|
||||||
|
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
// Get the currently displayed file
|
||||||
|
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||||
|
const hasSelection = selectedFiles.length > 0;
|
||||||
|
const hasMultipleFiles = selectedFiles.length > 1;
|
||||||
|
|
||||||
|
// Use IndexedDB hook for the current file
|
||||||
|
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
|
||||||
|
|
||||||
|
// Get thumbnail for current file
|
||||||
|
const getCurrentThumbnail = () => {
|
||||||
|
return currentThumbnail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
setIsAnimating(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
setIsAnimating(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset index when selection changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentFileIndex >= selectedFiles.length) {
|
||||||
|
setCurrentFileIndex(0);
|
||||||
|
}
|
||||||
|
}, [selectedFiles.length, currentFileIndex]);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<CompactFileDetails
|
||||||
|
currentFile={currentFile}
|
||||||
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
currentFileIndex={currentFileIndex}
|
||||||
|
numberOfFiles={selectedFiles.length}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
onOpenFiles={onOpenFiles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
|
{/* Section 1: Thumbnail Preview */}
|
||||||
|
<FilePreview
|
||||||
|
currentFile={currentFile}
|
||||||
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
numberOfFiles={selectedFiles.length}
|
||||||
|
isAnimating={isAnimating}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section 2: File Details */}
|
||||||
|
<FileInfoCard
|
||||||
|
currentFile={currentFile}
|
||||||
|
modalHeight={modalHeight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
mb="xl"
|
||||||
|
onClick={onOpenFiles}
|
||||||
|
disabled={!hasSelection}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedFiles.length > 1
|
||||||
|
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||||
|
: t('fileManager.openFile', 'Open File')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDetails;
|
67
frontend/src/components/fileManager/FileInfoCard.tsx
Normal file
67
frontend/src/components/fileManager/FileInfoCard.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface FileInfoCardProps {
|
||||||
|
currentFile: FileWithUrl | null;
|
||||||
|
modalHeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||||
|
currentFile,
|
||||||
|
modalHeight
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||||
|
<Text size="sm" fw={500} ta="center" c="white">
|
||||||
|
{t('fileManager.details', 'File Details')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<ScrollArea style={{ flex: 1 }} p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||||
|
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||||
|
{currentFile ? currentFile.name : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
|
||||||
|
{currentFile ? (
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{detectFileExtension(currentFile.name).toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" fw={500}></Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{currentFile ? getFileSize(currentFile) : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{currentFile ? '1.0' : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileInfoCard;
|
80
frontend/src/components/fileManager/FileListArea.tsx
Normal file
80
frontend/src/components/fileManager/FileListArea.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import FileListItem from './FileListItem';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileListAreaProps {
|
||||||
|
scrollAreaHeight: string;
|
||||||
|
scrollAreaStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||||
|
scrollAreaHeight,
|
||||||
|
scrollAreaStyle = {},
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
recentFiles,
|
||||||
|
filteredFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
onFileSelect,
|
||||||
|
onFileRemove,
|
||||||
|
onFileDoubleClick,
|
||||||
|
isFileSupported,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (activeSource === 'recent') {
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
h={scrollAreaHeight}
|
||||||
|
style={{
|
||||||
|
...scrollAreaStyle
|
||||||
|
}}
|
||||||
|
type="always"
|
||||||
|
scrollbarSize={8}
|
||||||
|
>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{recentFiles.length === 0 ? (
|
||||||
|
<Center style={{ height: '12.5rem' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||||
|
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||||
|
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
filteredFiles.map((file, index) => (
|
||||||
|
<FileListItem
|
||||||
|
key={file.id || file.name}
|
||||||
|
file={file}
|
||||||
|
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||||
|
isSupported={isFileSupported(file.name)}
|
||||||
|
onSelect={() => onFileSelect(file)}
|
||||||
|
onRemove={() => onFileRemove(index)}
|
||||||
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Drive placeholder
|
||||||
|
return (
|
||||||
|
<Center style={{ height: '12.5rem' }}>
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<CloudIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||||
|
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileListArea;
|
84
frontend/src/components/fileManager/FileListItem.tsx
Normal file
84
frontend/src/components/fileManager/FileListItem.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface FileListItemProps {
|
||||||
|
file: FileWithUrl;
|
||||||
|
isSelected: boolean;
|
||||||
|
isSupported: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileListItem: React.FC<FileListItemProps> = ({
|
||||||
|
file,
|
||||||
|
isSelected,
|
||||||
|
isSupported,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onDoubleClick
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
|
||||||
|
opacity: isSupported ? 1 : 0.5,
|
||||||
|
transition: 'background-color 0.15s ease'
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Box>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}} // Handled by parent onClick
|
||||||
|
size="sm"
|
||||||
|
pl="sm"
|
||||||
|
pr="xs"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>{file.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||||
|
</Box>
|
||||||
|
{/* Delete button - fades in/out on hover */}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
c="dimmed"
|
||||||
|
size="md"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
|
style={{
|
||||||
|
opacity: isHovered ? 1 : 0,
|
||||||
|
transform: isHovered ? 'scale(1)' : 'scale(0.8)',
|
||||||
|
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||||
|
pointerEvents: isHovered ? 'auto' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 20 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileListItem;
|
156
frontend/src/components/fileManager/FilePreview.tsx
Normal file
156
frontend/src/components/fileManager/FilePreview.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Center, ActionIcon, Image } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
currentFile: FileWithUrl | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
numberOfFiles: number;
|
||||||
|
isAnimating: boolean;
|
||||||
|
modalHeight: string;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
|
currentFile,
|
||||||
|
thumbnail,
|
||||||
|
numberOfFiles,
|
||||||
|
isAnimating,
|
||||||
|
modalHeight,
|
||||||
|
onPrevious,
|
||||||
|
onNext
|
||||||
|
}) => {
|
||||||
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
|
// Common style objects
|
||||||
|
const navigationArrowStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackDocumentBaseStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationStyle = {
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)',
|
||||||
|
opacity: isAnimating ? 0.7 : 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)';
|
||||||
|
const stackDocumentShadows = {
|
||||||
|
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* Left Navigation Arrow */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
left: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Stack Container */}
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* Background documents (stack effect) */}
|
||||||
|
{/* Show 2 shadow pages for 3+ files */}
|
||||||
|
{numberOfFiles >= 3 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||||
|
boxShadow: stackDocumentShadows.back,
|
||||||
|
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show 1 shadow page for 2+ files */}
|
||||||
|
{numberOfFiles >= 2 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
|
boxShadow: stackDocumentShadows.middle,
|
||||||
|
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main document */}
|
||||||
|
{currentFile && thumbnail ? (
|
||||||
|
<Image
|
||||||
|
src={thumbnail}
|
||||||
|
alt={currentFile.name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
boxShadow: mainDocumentShadow,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
...animationStyle
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentFile ? (
|
||||||
|
<Center style={{
|
||||||
|
width: '80%',
|
||||||
|
height: '80%',
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||||
|
boxShadow: mainDocumentShadow,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 3,
|
||||||
|
...animationStyle
|
||||||
|
}}>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Navigation Arrow */}
|
||||||
|
{hasMultipleFiles && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
color="blue"
|
||||||
|
disabled={isAnimating}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
right: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
103
frontend/src/components/fileManager/FileSourceButtons.tsx
Normal file
103
frontend/src/components/fileManager/FileSourceButtons.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import CloudIcon from '@mui/icons-material/Cloud';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface FileSourceButtonsProps {
|
||||||
|
horizontal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
|
horizontal = false
|
||||||
|
}) => {
|
||||||
|
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||||
|
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
||||||
|
getStyles: (source: string) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'transparent',
|
||||||
|
color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
|
||||||
|
border: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
leftSection={<HistoryIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={() => onSourceChange('recent')}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
color={buttonProps.getColor('recent')}
|
||||||
|
styles={buttonProps.getStyles('recent')}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color='var(--mantine-color-gray-6)'
|
||||||
|
leftSection={<FolderIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={onLocalFileClick}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={buttonProps.variant('drive')}
|
||||||
|
leftSection={<CloudIcon />}
|
||||||
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
|
onClick={() => onSourceChange('drive')}
|
||||||
|
fullWidth={!horizontal}
|
||||||
|
size={horizontal ? "xs" : "sm"}
|
||||||
|
disabled
|
||||||
|
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||||
|
styles={buttonProps.getStyles('drive')}
|
||||||
|
>
|
||||||
|
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||||
|
{buttons}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
|
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||||
|
{t('fileManager.myFiles', 'My Files')}
|
||||||
|
</Text>
|
||||||
|
{buttons}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileSourceButtons;
|
20
frontend/src/components/fileManager/HiddenFileInput.tsx
Normal file
20
frontend/src/components/fileManager/HiddenFileInput.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const HiddenFileInput: React.FC = () => {
|
||||||
|
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple={true}
|
||||||
|
accept="*/*"
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
data-testid="file-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HiddenFileInput;
|
83
frontend/src/components/fileManager/MobileLayout.tsx
Normal file
83
frontend/src/components/fileManager/MobileLayout.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Box } from '@mantine/core';
|
||||||
|
import FileSourceButtons from './FileSourceButtons';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import FileListArea from './FileListArea';
|
||||||
|
import HiddenFileInput from './HiddenFileInput';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
const MobileLayout: React.FC = () => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
selectedFiles,
|
||||||
|
modalHeight,
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
|
// Calculate the height more accurately based on actual content
|
||||||
|
const calculateFileListHeight = () => {
|
||||||
|
// Base modal height minus padding and gaps
|
||||||
|
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||||
|
|
||||||
|
// Estimate heights of fixed components
|
||||||
|
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
||||||
|
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
||||||
|
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
||||||
|
const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps
|
||||||
|
|
||||||
|
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* Section 1: File Sources - Fixed at top */}
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileSourceButtons horizontal={true} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ flexShrink: 0 }}>
|
||||||
|
<FileDetails compact={true} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||||
|
<Box style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'var(--bg-file-list)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 0
|
||||||
|
}}>
|
||||||
|
{activeSource === 'recent' && (
|
||||||
|
<Box style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
||||||
|
}}>
|
||||||
|
<SearchInput />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<FileListArea
|
||||||
|
scrollAreaHeight={calculateFileListHeight()}
|
||||||
|
scrollAreaStyle={{
|
||||||
|
height: calculateFileListHeight(),
|
||||||
|
maxHeight: '60vh',
|
||||||
|
minHeight: '9.375rem',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hidden file input for local file selection */}
|
||||||
|
<HiddenFileInput />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileLayout;
|
33
frontend/src/components/fileManager/SearchInput.tsx
Normal file
33
frontend/src/components/fileManager/SearchInput.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { searchTerm, onSearchChange } = useFileManagerContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
||||||
|
leftSection={<SearchIcon />}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
|
||||||
|
style={{ padding: '0.5rem', ...style }}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchInput;
|
160
frontend/src/components/layout/Workbench.tsx
Normal file
160
frontend/src/components/layout/Workbench.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
|
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||||
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
|
||||||
|
import TopControls from '../shared/TopControls';
|
||||||
|
import FileEditor from '../fileEditor/FileEditor';
|
||||||
|
import PageEditor from '../pageEditor/PageEditor';
|
||||||
|
import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||||
|
import Viewer from '../viewer/Viewer';
|
||||||
|
import ToolRenderer from '../tools/ToolRenderer';
|
||||||
|
import LandingPage from '../shared/LandingPage';
|
||||||
|
|
||||||
|
// No props needed - component uses contexts directly
|
||||||
|
export default function Workbench() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
|
||||||
|
// Use context-based hooks to eliminate all prop drilling
|
||||||
|
const { activeFiles, currentView, setCurrentView } = useFileContext();
|
||||||
|
const {
|
||||||
|
previewFile,
|
||||||
|
pageEditorFunctions,
|
||||||
|
sidebarsVisible,
|
||||||
|
setPreviewFile,
|
||||||
|
setPageEditorFunctions,
|
||||||
|
setSidebarsVisible
|
||||||
|
} = useWorkbenchState();
|
||||||
|
|
||||||
|
const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection();
|
||||||
|
const { addToActiveFiles } = useFileHandler();
|
||||||
|
|
||||||
|
const handlePreviewClose = () => {
|
||||||
|
setPreviewFile(null);
|
||||||
|
const previousMode = sessionStorage.getItem('previousMode');
|
||||||
|
if (previousMode === 'split') {
|
||||||
|
// Use context's handleToolSelect which coordinates tool selection and view changes
|
||||||
|
handleToolSelect('split');
|
||||||
|
sessionStorage.removeItem('previousMode');
|
||||||
|
} else if (previousMode === 'compress') {
|
||||||
|
handleToolSelect('compress');
|
||||||
|
sessionStorage.removeItem('previousMode');
|
||||||
|
} else if (previousMode === 'convert') {
|
||||||
|
handleToolSelect('convert');
|
||||||
|
sessionStorage.removeItem('previousMode');
|
||||||
|
} else {
|
||||||
|
setCurrentView('fileEditor' as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMainContent = () => {
|
||||||
|
if (!activeFiles[0]) {
|
||||||
|
return (
|
||||||
|
<LandingPage
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentView) {
|
||||||
|
case "fileEditor":
|
||||||
|
return (
|
||||||
|
<FileEditor
|
||||||
|
toolMode={!!selectedToolKey}
|
||||||
|
showUpload={true}
|
||||||
|
showBulkActions={!selectedToolKey}
|
||||||
|
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||||
|
{...(!selectedToolKey && {
|
||||||
|
onOpenPageEditor: (file) => {
|
||||||
|
setCurrentView("pageEditor" as any);
|
||||||
|
},
|
||||||
|
onMergeFiles: (filesToMerge) => {
|
||||||
|
filesToMerge.forEach(addToActiveFiles);
|
||||||
|
setCurrentView("viewer" as any);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "viewer":
|
||||||
|
return (
|
||||||
|
<Viewer
|
||||||
|
sidebarsVisible={sidebarsVisible}
|
||||||
|
setSidebarsVisible={setSidebarsVisible}
|
||||||
|
previewFile={previewFile}
|
||||||
|
onClose={handlePreviewClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pageEditor":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageEditor
|
||||||
|
onFunctionsReady={setPageEditorFunctions}
|
||||||
|
/>
|
||||||
|
{pageEditorFunctions && (
|
||||||
|
<PageEditorControls
|
||||||
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
|
onRedo={pageEditorFunctions.handleRedo}
|
||||||
|
canUndo={pageEditorFunctions.canUndo}
|
||||||
|
canRedo={pageEditorFunctions.canRedo}
|
||||||
|
onRotate={pageEditorFunctions.handleRotate}
|
||||||
|
onDelete={pageEditorFunctions.handleDelete}
|
||||||
|
onSplit={pageEditorFunctions.handleSplit}
|
||||||
|
onExportSelected={pageEditorFunctions.onExportSelected}
|
||||||
|
onExportAll={pageEditorFunctions.onExportAll}
|
||||||
|
exportLoading={pageEditorFunctions.exportLoading}
|
||||||
|
selectionMode={pageEditorFunctions.selectionMode}
|
||||||
|
selectedPages={pageEditorFunctions.selectedPages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Check if it's a tool view
|
||||||
|
if (selectedToolKey && selectedTool) {
|
||||||
|
return (
|
||||||
|
<ToolRenderer
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LandingPage/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
||||||
|
style={
|
||||||
|
isRainbowMode
|
||||||
|
? {} // No background color in rainbow mode
|
||||||
|
: { backgroundColor: 'var(--bg-background)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Top Controls */}
|
||||||
|
<TopControls
|
||||||
|
currentView={currentView}
|
||||||
|
setCurrentView={setCurrentView}
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<Box
|
||||||
|
className="flex-1 min-h-0 relative z-10"
|
||||||
|
style={{
|
||||||
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderMainContent()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage";
|
|||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
|
import { FileWithUrl } from "../../types/file";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
import SortIcon from "@mui/icons-material/Sort";
|
||||||
import FileCard from "../fileManagement/FileCard";
|
import FileCard from "./FileCard";
|
||||||
import { FileWithUrl } from "../../types/file";
|
import { FileWithUrl } from "../../types/file";
|
||||||
|
|
||||||
interface FileGridProps {
|
interface FileGridProps {
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal } from '@mantine/core';
|
|
||||||
import FileUploadSelector from './FileUploadSelector';
|
|
||||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
|
||||||
import { Tool } from '../../types/tool';
|
|
||||||
|
|
||||||
interface FileUploadModalProps {
|
|
||||||
selectedTool?: Tool | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileUploadModal: React.FC<FileUploadModalProps> = ({ selectedTool }) => {
|
|
||||||
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={isFilesModalOpen}
|
|
||||||
onClose={closeFilesModal}
|
|
||||||
title="Upload Files"
|
|
||||||
size="xl"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<FileUploadSelector
|
|
||||||
title="Upload Files"
|
|
||||||
subtitle="Choose files from storage or upload new files"
|
|
||||||
onFileSelect={onFileSelect}
|
|
||||||
onFilesSelect={onFilesSelect}
|
|
||||||
accept={["*/*"]}
|
|
||||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
|
||||||
data-testid="file-upload-modal"
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileUploadModal;
|
|
@ -1,255 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
|
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { fileStorage } from '../../services/fileStorage';
|
|
||||||
import { FileWithUrl } from '../../types/file';
|
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
|
||||||
import FileGrid from './FileGrid';
|
|
||||||
import MultiSelectControls from './MultiSelectControls';
|
|
||||||
import { useFileManager } from '../../hooks/useFileManager';
|
|
||||||
|
|
||||||
interface FileUploadSelectorProps {
|
|
||||||
// Appearance
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
showDropzone?: boolean;
|
|
||||||
|
|
||||||
// File handling
|
|
||||||
sharedFiles?: any[];
|
|
||||||
onFileSelect?: (file: File) => void;
|
|
||||||
onFilesSelect: (files: File[]) => void;
|
|
||||||
accept?: string[];
|
|
||||||
supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
loading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
|
|
||||||
// Recent files
|
|
||||||
showRecentFiles?: boolean;
|
|
||||||
maxRecentFiles?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileUploadSelector = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
showDropzone = true,
|
|
||||||
sharedFiles = [],
|
|
||||||
onFileSelect,
|
|
||||||
onFilesSelect,
|
|
||||||
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
|
|
||||||
supportedExtensions = ["pdf"], // Default to PDF only for most tools
|
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
|
||||||
showRecentFiles = true,
|
|
||||||
maxRecentFiles = 8,
|
|
||||||
}: FileUploadSelectorProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
|
|
||||||
|
|
||||||
// Utility function to check if a file extension is supported
|
|
||||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
|
||||||
const extension = detectFileExtension(fileName);
|
|
||||||
return extension ? supportedExtensions.includes(extension) : false;
|
|
||||||
}, [supportedExtensions]);
|
|
||||||
|
|
||||||
const refreshRecentFiles = useCallback(async () => {
|
|
||||||
const files = await loadRecentFiles();
|
|
||||||
setRecentFiles(files);
|
|
||||||
}, [loadRecentFiles]);
|
|
||||||
|
|
||||||
const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
|
||||||
if (uploadedFiles.length === 0) return;
|
|
||||||
|
|
||||||
if (showRecentFiles) {
|
|
||||||
try {
|
|
||||||
for (const file of uploadedFiles) {
|
|
||||||
await storeFile(file);
|
|
||||||
}
|
|
||||||
refreshRecentFiles();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save files to recent:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onFilesSelect) {
|
|
||||||
onFilesSelect(uploadedFiles);
|
|
||||||
} else if (onFileSelect) {
|
|
||||||
onFileSelect(uploadedFiles[0]);
|
|
||||||
}
|
|
||||||
}, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
|
|
||||||
|
|
||||||
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const fileArray = Array.from(files);
|
|
||||||
console.log('File input change:', fileArray.length, 'files');
|
|
||||||
handleNewFileUpload(fileArray);
|
|
||||||
}
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}, [handleNewFileUpload]);
|
|
||||||
|
|
||||||
const openFileDialog = useCallback(() => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
|
|
||||||
try {
|
|
||||||
const fileObj = await convertToFile(file);
|
|
||||||
if (onFilesSelect) {
|
|
||||||
onFilesSelect([fileObj]);
|
|
||||||
} else if (onFileSelect) {
|
|
||||||
onFileSelect(fileObj);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load file from recent:', error);
|
|
||||||
}
|
|
||||||
}, [onFileSelect, onFilesSelect, convertToFile]);
|
|
||||||
|
|
||||||
const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
|
|
||||||
|
|
||||||
const handleSelectedRecentFiles = useCallback(async () => {
|
|
||||||
if (onFilesSelect) {
|
|
||||||
await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
|
|
||||||
}
|
|
||||||
}, [recentFiles, onFilesSelect, selectionHandlers]);
|
|
||||||
|
|
||||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
|
||||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
|
||||||
const file = recentFiles[index];
|
|
||||||
setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
|
|
||||||
}, [handleRemoveFile, recentFiles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showRecentFiles) {
|
|
||||||
refreshRecentFiles();
|
|
||||||
}
|
|
||||||
}, [showRecentFiles, refreshRecentFiles]);
|
|
||||||
|
|
||||||
// Get default title and subtitle from translations if not provided
|
|
||||||
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
|
|
||||||
const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
{/* Title and description */}
|
|
||||||
<Stack align="center" gap="md">
|
|
||||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
|
||||||
<Text size="xl" fw={500}>
|
|
||||||
{displayTitle}
|
|
||||||
</Text>
|
|
||||||
<Text size="md" c="dimmed">
|
|
||||||
{displaySubtitle}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<Stack align="center" gap="md" w="100%">
|
|
||||||
|
|
||||||
{showDropzone ? (
|
|
||||||
<Dropzone
|
|
||||||
onDrop={handleNewFileUpload}
|
|
||||||
accept={accept}
|
|
||||||
multiple={true}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
style={{ width: '100%', height: "5rem" }}
|
|
||||||
activateOnClick={true}
|
|
||||||
data-testid="file-dropzone"
|
|
||||||
>
|
|
||||||
<Center>
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{accept.includes('application/pdf') && accept.includes('application/zip')
|
|
||||||
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
|
|
||||||
: accept.includes('application/pdf')
|
|
||||||
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
|
||||||
: t("fileUpload.supportedFileTypes", "Supported file types")
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Dropzone>
|
|
||||||
) : (
|
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
disabled={disabled}
|
|
||||||
loading={loading}
|
|
||||||
onClick={openFileDialog}
|
|
||||||
>
|
|
||||||
{t("fileUpload.uploadFiles", "Upload Files")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Manual file input as backup */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple={true}
|
|
||||||
accept={accept.join(',')}
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
data-testid="file-input"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Recent Files Section */}
|
|
||||||
{showRecentFiles && recentFiles.length > 0 && (
|
|
||||||
<Box w="100%" >
|
|
||||||
<Divider my="md" />
|
|
||||||
<Text size="lg" fw={500} mb="md">
|
|
||||||
{t("fileUpload.recentFiles", "Recent Files")}
|
|
||||||
</Text>
|
|
||||||
<MultiSelectControls
|
|
||||||
selectedCount={selectedFiles.length}
|
|
||||||
onClearSelection={selectionHandlers.clearSelection}
|
|
||||||
onAddToUpload={handleSelectedRecentFiles}
|
|
||||||
onDeleteAll={async () => {
|
|
||||||
await Promise.all(recentFiles.map(async (file) => {
|
|
||||||
await fileStorage.deleteFile(file.id || file.name);
|
|
||||||
}));
|
|
||||||
setRecentFiles([]);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FileGrid
|
|
||||||
files={recentFiles}
|
|
||||||
onDoubleClick={handleRecentFileSelection}
|
|
||||||
onSelect={selectionHandlers.toggleSelection}
|
|
||||||
onRemove={handleRemoveFileByIndex}
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
showSearch={true}
|
|
||||||
showSort={true}
|
|
||||||
isFileSupported={isFileSupported}
|
|
||||||
onDeleteAll={async () => {
|
|
||||||
await Promise.all(recentFiles.map(async (file) => {
|
|
||||||
await fileStorage.deleteFile(file.id || file.name);
|
|
||||||
}));
|
|
||||||
setRecentFiles([]);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileUploadSelector;
|
|
@ -11,20 +11,18 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
|||||||
import AppConfigModal from './AppConfigModal';
|
import AppConfigModal from './AppConfigModal';
|
||||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import { ButtonConfig } from '../../types/sidebar';
|
||||||
import './QuickAccessBar.css';
|
import './QuickAccessBar.css';
|
||||||
|
|
||||||
function NavHeader({
|
function NavHeader({
|
||||||
activeButton,
|
activeButton,
|
||||||
setActiveButton,
|
setActiveButton
|
||||||
onReaderToggle,
|
|
||||||
onToolsClick
|
|
||||||
}: {
|
}: {
|
||||||
activeButton: string;
|
activeButton: string;
|
||||||
setActiveButton: (id: string) => void;
|
setActiveButton: (id: string) => void;
|
||||||
onReaderToggle: () => void;
|
|
||||||
onToolsClick: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
const { handleReaderToggle, handleBackToTools } = useToolWorkflow();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* All Tools button below divider */}
|
{/* All Tools button below divider */}
|
||||||
@ -35,8 +33,8 @@ function NavHeader({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveButton('tools');
|
setActiveButton('tools');
|
||||||
onReaderToggle();
|
handleReaderToggle();
|
||||||
onToolsClick();
|
handleBackToTools();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||||
@ -59,12 +57,11 @@ function NavHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
|
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||||
onToolsClick,
|
|
||||||
onReaderToggle,
|
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||||
|
const { handleReaderToggle } = useToolWorkflow();
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
@ -85,7 +82,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
|
|||||||
type: 'navigation',
|
type: 'navigation',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setActiveButton('read');
|
setActiveButton('read');
|
||||||
onReaderToggle();
|
handleReaderToggle();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -199,9 +196,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
|
|||||||
<div className="quick-access-header">
|
<div className="quick-access-header">
|
||||||
<NavHeader
|
<NavHeader
|
||||||
activeButton={activeButton}
|
activeButton={activeButton}
|
||||||
setActiveButton={setActiveButton}
|
setActiveButton={setActiveButton}
|
||||||
onReaderToggle={onReaderToggle}
|
|
||||||
onToolsClick={onToolsClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
89
frontend/src/components/tools/ToolPanel.tsx
Normal file
89
frontend/src/components/tools/ToolPanel.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
|
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import ToolPicker from './ToolPicker';
|
||||||
|
import ToolRenderer from './ToolRenderer';
|
||||||
|
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||||
|
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||||
|
|
||||||
|
// No props needed - component uses context
|
||||||
|
|
||||||
|
export default function ToolPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { toolPanelRef } = sidebarRefs;
|
||||||
|
|
||||||
|
|
||||||
|
// Use context-based hooks to eliminate prop drilling
|
||||||
|
const {
|
||||||
|
leftPanelView,
|
||||||
|
isPanelVisible,
|
||||||
|
searchQuery,
|
||||||
|
filteredTools,
|
||||||
|
setSearchQuery,
|
||||||
|
handleBackToTools
|
||||||
|
} = useToolPanelState();
|
||||||
|
|
||||||
|
const { selectedToolKey, handleToolSelect } = useToolSelection();
|
||||||
|
const { setPreviewFile } = useWorkbenchState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={toolPanelRef}
|
||||||
|
data-sidebar="tool-panel"
|
||||||
|
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||||
|
isRainbowMode ? rainbowStyles.rainbowPaper : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: isPanelVisible ? '20rem' : '0',
|
||||||
|
padding: isPanelVisible ? '0.5rem' : '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isPanelVisible ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search Bar - Always visible at the top */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leftPanelView === 'toolPicker' ? (
|
||||||
|
// Tool Picker View
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<ToolPicker
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
onSelect={handleToolSelect}
|
||||||
|
filteredTools={filteredTools}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Selected Tool Content View
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Tool content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ToolRenderer
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
onPreviewFile={setPreviewFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||||
|
|
||||||
export interface SplitParameters {
|
export interface SplitParameters {
|
||||||
|
mode: SplitMode | '';
|
||||||
pages: string;
|
pages: string;
|
||||||
hDiv: string;
|
hDiv: string;
|
||||||
vDiv: string;
|
vDiv: string;
|
||||||
@ -15,16 +16,12 @@ export interface SplitParameters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitSettingsProps {
|
export interface SplitSettingsProps {
|
||||||
mode: SplitMode | '';
|
|
||||||
onModeChange: (mode: SplitMode | '') => void;
|
|
||||||
parameters: SplitParameters;
|
parameters: SplitParameters;
|
||||||
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitSettings = ({
|
const SplitSettings = ({
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
parameters,
|
parameters,
|
||||||
onParameterChange,
|
onParameterChange,
|
||||||
disabled = false
|
disabled = false
|
||||||
@ -125,8 +122,8 @@ const SplitSettings = ({
|
|||||||
<Select
|
<Select
|
||||||
label="Choose split method"
|
label="Choose split method"
|
||||||
placeholder="Select how to split the PDF"
|
placeholder="Select how to split the PDF"
|
||||||
value={mode}
|
value={parameters.mode}
|
||||||
onChange={(v) => v && onModeChange(v)}
|
onChange={(v) => v && onParameterChange('mode', v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data={[
|
data={[
|
||||||
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||||
@ -137,10 +134,10 @@ const SplitSettings = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Parameter Form */}
|
{/* Parameter Form */}
|
||||||
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
{parameters.mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
||||||
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
{parameters.mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
||||||
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
{parameters.mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
||||||
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
{parameters.mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
case 'REMOVE_FILES':
|
case 'REMOVE_FILES':
|
||||||
const remainingFiles = state.activeFiles.filter(file => {
|
const remainingFiles = state.activeFiles.filter(file => {
|
||||||
const fileId = getFileId(file);
|
const fileId = getFileId(file);
|
||||||
return !action.payload.includes(fileId);
|
return !fileId || !action.payload.includes(fileId);
|
||||||
});
|
});
|
||||||
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
||||||
return {
|
return {
|
||||||
@ -491,26 +491,38 @@ export function FileContextProvider({
|
|||||||
}, [cleanupFile]);
|
}, [cleanupFile]);
|
||||||
|
|
||||||
// Action implementations
|
// Action implementations
|
||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
||||||
dispatch({ type: 'ADD_FILES', payload: files });
|
dispatch({ type: 'ADD_FILES', payload: files });
|
||||||
|
|
||||||
// Auto-save to IndexedDB if persistence enabled
|
// Auto-save to IndexedDB if persistence enabled
|
||||||
if (enablePersistence) {
|
if (enablePersistence) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
// Check if file already has an ID (already in IndexedDB)
|
// Check if file already has an explicit ID property (already in IndexedDB)
|
||||||
const fileId = getFileId(file);
|
const fileId = getFileId(file);
|
||||||
if (!fileId) {
|
if (!fileId) {
|
||||||
// File doesn't have ID, store it and get the ID
|
// File doesn't have explicit ID, store it with thumbnail
|
||||||
const storedFile = await fileStorage.storeFile(file);
|
try {
|
||||||
// Add the ID to the file object
|
// Generate thumbnail for better recent files experience
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
const thumbnail = await thumbnailGenerationService.generateThumbnail(file);
|
||||||
|
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||||
|
// Add the ID to the file object
|
||||||
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
|
} catch (thumbnailError) {
|
||||||
|
// If thumbnail generation fails, store without thumbnail
|
||||||
|
console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
|
||||||
|
const storedFile = await fileStorage.storeFile(file);
|
||||||
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file:', error);
|
console.error('Failed to store file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return files with their IDs assigned
|
||||||
|
return files;
|
||||||
}, [enablePersistence]);
|
}, [enablePersistence]);
|
||||||
|
|
||||||
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
||||||
@ -682,7 +694,7 @@ export function FileContextProvider({
|
|||||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||||
return state.activeFiles.find(file => {
|
return state.activeFiles.find(file => {
|
||||||
const actualFileId = getFileId(file);
|
const actualFileId = getFileId(file);
|
||||||
return actualFileId === fileId;
|
return actualFileId && actualFileId === fileId;
|
||||||
});
|
});
|
||||||
}, [state.activeFiles]);
|
}, [state.activeFiles]);
|
||||||
|
|
||||||
|
218
frontend/src/contexts/FileManagerContext.tsx
Normal file
218
frontend/src/contexts/FileManagerContext.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { StoredFile } from '../services/fileStorage';
|
||||||
|
|
||||||
|
// Type for the context value - now contains everything directly
|
||||||
|
interface FileManagerContextValue {
|
||||||
|
// State
|
||||||
|
activeSource: 'recent' | 'local' | 'drive';
|
||||||
|
selectedFileIds: string[];
|
||||||
|
searchTerm: string;
|
||||||
|
selectedFiles: FileWithUrl[];
|
||||||
|
filteredFiles: FileWithUrl[];
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||||
|
onLocalFileClick: () => void;
|
||||||
|
onFileSelect: (file: FileWithUrl) => void;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
|
||||||
|
// External props
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
modalHeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context
|
||||||
|
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||||
|
|
||||||
|
// Provider component props
|
||||||
|
interface FileManagerProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
recentFiles: FileWithUrl[];
|
||||||
|
onFilesSelected: (files: FileWithUrl[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isFileSupported: (fileName: string) => boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
onFileRemove: (index: number) => void;
|
||||||
|
modalHeight: string;
|
||||||
|
storeFile: (file: File) => Promise<StoredFile>;
|
||||||
|
refreshRecentFiles: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
recentFiles,
|
||||||
|
onFilesSelected,
|
||||||
|
onClose,
|
||||||
|
isFileSupported,
|
||||||
|
isOpen,
|
||||||
|
onFileRemove,
|
||||||
|
modalHeight,
|
||||||
|
storeFile,
|
||||||
|
refreshRecentFiles,
|
||||||
|
}) => {
|
||||||
|
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||||
|
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Track blob URLs for cleanup
|
||||||
|
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Computed values (with null safety)
|
||||||
|
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
|
||||||
|
const filteredFiles = (recentFiles || []).filter(file =>
|
||||||
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
||||||
|
setActiveSource(source);
|
||||||
|
if (source !== 'recent') {
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLocalFileClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||||
|
setSelectedFileIds(prev => {
|
||||||
|
if (prev.includes(file.id)) {
|
||||||
|
return prev.filter(id => id !== file.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, file.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileRemove = useCallback((index: number) => {
|
||||||
|
const fileToRemove = filteredFiles[index];
|
||||||
|
if (fileToRemove) {
|
||||||
|
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
|
||||||
|
}
|
||||||
|
onFileRemove(index);
|
||||||
|
}, [filteredFiles, onFileRemove]);
|
||||||
|
|
||||||
|
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
||||||
|
if (isFileSupported(file.name)) {
|
||||||
|
onFilesSelected([file]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [isFileSupported, onFilesSelected, onClose]);
|
||||||
|
|
||||||
|
const handleOpenFiles = useCallback(() => {
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
onFilesSelected(selectedFiles);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [selectedFiles, onFilesSelected, onClose]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
||||||
|
const fileWithUrls = files.map(file => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
createdBlobUrls.current.add(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
url,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onFilesSelected(fileWithUrls);
|
||||||
|
await refreshRecentFiles();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process selected files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
||||||
|
|
||||||
|
// Cleanup blob URLs when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clean up all created blob URLs
|
||||||
|
createdBlobUrls.current.forEach(url => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
createdBlobUrls.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset state when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setActiveSource('recent');
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const contextValue: FileManagerContextValue = {
|
||||||
|
// State
|
||||||
|
activeSource,
|
||||||
|
selectedFileIds,
|
||||||
|
searchTerm,
|
||||||
|
selectedFiles,
|
||||||
|
filteredFiles,
|
||||||
|
fileInputRef,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onSourceChange: handleSourceChange,
|
||||||
|
onLocalFileClick: handleLocalFileClick,
|
||||||
|
onFileSelect: handleFileSelect,
|
||||||
|
onFileRemove: handleFileRemove,
|
||||||
|
onFileDoubleClick: handleFileDoubleClick,
|
||||||
|
onOpenFiles: handleOpenFiles,
|
||||||
|
onSearchChange: handleSearchChange,
|
||||||
|
onFileInputChange: handleFileInputChange,
|
||||||
|
|
||||||
|
// External props
|
||||||
|
recentFiles,
|
||||||
|
isFileSupported,
|
||||||
|
modalHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileManagerContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</FileManagerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook to use the context
|
||||||
|
export const useFileManagerContext = (): FileManagerContextValue => {
|
||||||
|
const context = useContext(FileManagerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
||||||
|
'Make sure you wrap your component with <FileManagerProvider>.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the context for advanced use cases
|
||||||
|
export { FileManagerContext };
|
@ -1,21 +1,58 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal';
|
|
||||||
import { useFileHandler } from '../hooks/useFileHandler';
|
import { useFileHandler } from '../hooks/useFileHandler';
|
||||||
|
|
||||||
interface FilesModalContextType extends UseFilesModalReturn {}
|
interface FilesModalContextType {
|
||||||
|
isFilesModalOpen: boolean;
|
||||||
|
openFilesModal: () => void;
|
||||||
|
closeFilesModal: () => void;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
onFilesSelect: (files: File[]) => void;
|
||||||
|
onModalClose: () => void;
|
||||||
|
setOnModalClose: (callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||||
|
|
||||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
||||||
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||||
const filesModal = useFilesModal({
|
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||||
onFileSelect: addToActiveFiles,
|
|
||||||
onFilesSelect: addMultipleFiles,
|
const openFilesModal = useCallback(() => {
|
||||||
});
|
setIsFilesModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeFilesModal = useCallback(() => {
|
||||||
|
setIsFilesModalOpen(false);
|
||||||
|
onModalClose?.();
|
||||||
|
}, [onModalClose]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
|
addToActiveFiles(file);
|
||||||
|
closeFilesModal();
|
||||||
|
}, [addToActiveFiles, closeFilesModal]);
|
||||||
|
|
||||||
|
const handleFilesSelect = useCallback((files: File[]) => {
|
||||||
|
addMultipleFiles(files);
|
||||||
|
closeFilesModal();
|
||||||
|
}, [addMultipleFiles, closeFilesModal]);
|
||||||
|
|
||||||
|
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||||
|
setOnModalClose(() => callback);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: FilesModalContextType = {
|
||||||
|
isFilesModalOpen,
|
||||||
|
openFilesModal,
|
||||||
|
closeFilesModal,
|
||||||
|
onFileSelect: handleFileSelect,
|
||||||
|
onFilesSelect: handleFilesSelect,
|
||||||
|
onModalClose,
|
||||||
|
setOnModalClose: setModalCloseCallback,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilesModalContext.Provider value={filesModal}>
|
<FilesModalContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</FilesModalContext.Provider>
|
</FilesModalContext.Provider>
|
||||||
);
|
);
|
||||||
|
221
frontend/src/contexts/ToolWorkflowContext.tsx
Normal file
221
frontend/src/contexts/ToolWorkflowContext.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* ToolWorkflowContext - Manages tool selection, UI state, and workflow coordination
|
||||||
|
* Eliminates prop drilling with a single, simple context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
|
||||||
|
import { useToolManagement } from '../hooks/useToolManagement';
|
||||||
|
import { ToolConfiguration } from '../types/tool';
|
||||||
|
import { PageEditorFunctions } from '../types/pageEditor';
|
||||||
|
|
||||||
|
// State interface
|
||||||
|
interface ToolWorkflowState {
|
||||||
|
// UI State
|
||||||
|
sidebarsVisible: boolean;
|
||||||
|
leftPanelView: 'toolPicker' | 'toolContent';
|
||||||
|
readerMode: boolean;
|
||||||
|
|
||||||
|
// File/Preview State
|
||||||
|
previewFile: File | null;
|
||||||
|
pageEditorFunctions: PageEditorFunctions | null;
|
||||||
|
|
||||||
|
// Search State
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
type ToolWorkflowAction =
|
||||||
|
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
|
||||||
|
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' }
|
||||||
|
| { type: 'SET_READER_MODE'; payload: boolean }
|
||||||
|
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
|
||||||
|
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
|
||||||
|
| { type: 'SET_SEARCH_QUERY'; payload: string }
|
||||||
|
| { type: 'RESET_UI_STATE' };
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: ToolWorkflowState = {
|
||||||
|
sidebarsVisible: true,
|
||||||
|
leftPanelView: 'toolPicker',
|
||||||
|
readerMode: false,
|
||||||
|
previewFile: null,
|
||||||
|
pageEditorFunctions: null,
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_SIDEBARS_VISIBLE':
|
||||||
|
return { ...state, sidebarsVisible: action.payload };
|
||||||
|
case 'SET_LEFT_PANEL_VIEW':
|
||||||
|
return { ...state, leftPanelView: action.payload };
|
||||||
|
case 'SET_READER_MODE':
|
||||||
|
return { ...state, readerMode: action.payload };
|
||||||
|
case 'SET_PREVIEW_FILE':
|
||||||
|
return { ...state, previewFile: action.payload };
|
||||||
|
case 'SET_PAGE_EDITOR_FUNCTIONS':
|
||||||
|
return { ...state, pageEditorFunctions: action.payload };
|
||||||
|
case 'SET_SEARCH_QUERY':
|
||||||
|
return { ...state, searchQuery: action.payload };
|
||||||
|
case 'RESET_UI_STATE':
|
||||||
|
return { ...initialState, searchQuery: state.searchQuery }; // Preserve search
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context value interface
|
||||||
|
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||||
|
// Tool management (from hook)
|
||||||
|
selectedToolKey: string | null;
|
||||||
|
selectedTool: ToolConfiguration | null;
|
||||||
|
toolRegistry: any; // From useToolManagement
|
||||||
|
|
||||||
|
// UI Actions
|
||||||
|
setSidebarsVisible: (visible: boolean) => void;
|
||||||
|
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
|
||||||
|
setReaderMode: (mode: boolean) => void;
|
||||||
|
setPreviewFile: (file: File | null) => void;
|
||||||
|
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
|
||||||
|
// Tool Actions
|
||||||
|
selectTool: (toolId: string) => void;
|
||||||
|
clearToolSelection: () => void;
|
||||||
|
|
||||||
|
// Workflow Actions (compound actions)
|
||||||
|
handleToolSelect: (toolId: string) => void;
|
||||||
|
handleBackToTools: () => void;
|
||||||
|
handleReaderToggle: () => void;
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
filteredTools: [string, any][]; // Filtered by search
|
||||||
|
isPanelVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
interface ToolWorkflowProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Handler for view changes (passed from parent) */
|
||||||
|
onViewChange?: (view: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||||
|
|
||||||
|
// Tool management hook
|
||||||
|
const {
|
||||||
|
selectedToolKey,
|
||||||
|
selectedTool,
|
||||||
|
toolRegistry,
|
||||||
|
selectTool,
|
||||||
|
clearToolSelection,
|
||||||
|
} = useToolManagement();
|
||||||
|
|
||||||
|
// UI Action creators
|
||||||
|
const setSidebarsVisible = useCallback((visible: boolean) => {
|
||||||
|
dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent') => {
|
||||||
|
dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setReaderMode = useCallback((mode: boolean) => {
|
||||||
|
dispatch({ type: 'SET_READER_MODE', payload: mode });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPreviewFile = useCallback((file: File | null) => {
|
||||||
|
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => {
|
||||||
|
dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSearchQuery = useCallback((query: string) => {
|
||||||
|
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||||
|
const handleToolSelect = useCallback((toolId: string) => {
|
||||||
|
selectTool(toolId);
|
||||||
|
onViewChange?.('fileEditor');
|
||||||
|
setLeftPanelView('toolContent');
|
||||||
|
setReaderMode(false);
|
||||||
|
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode]);
|
||||||
|
|
||||||
|
const handleBackToTools = useCallback(() => {
|
||||||
|
setLeftPanelView('toolPicker');
|
||||||
|
setReaderMode(false);
|
||||||
|
clearToolSelection();
|
||||||
|
}, [setLeftPanelView, setReaderMode, clearToolSelection]);
|
||||||
|
|
||||||
|
const handleReaderToggle = useCallback(() => {
|
||||||
|
setReaderMode(true);
|
||||||
|
}, [setReaderMode]);
|
||||||
|
|
||||||
|
// Filter tools based on search query
|
||||||
|
const filteredTools = useMemo(() => {
|
||||||
|
if (!toolRegistry) return [];
|
||||||
|
return Object.entries(toolRegistry).filter(([_, { name }]) =>
|
||||||
|
name.toLowerCase().includes(state.searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [toolRegistry, state.searchQuery]);
|
||||||
|
|
||||||
|
const isPanelVisible = useMemo(() =>
|
||||||
|
state.sidebarsVisible && !state.readerMode,
|
||||||
|
[state.sidebarsVisible, state.readerMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simple context value with basic memoization
|
||||||
|
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||||
|
// State
|
||||||
|
...state,
|
||||||
|
selectedToolKey,
|
||||||
|
selectedTool,
|
||||||
|
toolRegistry,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSidebarsVisible,
|
||||||
|
setLeftPanelView,
|
||||||
|
setReaderMode,
|
||||||
|
setPreviewFile,
|
||||||
|
setPageEditorFunctions,
|
||||||
|
setSearchQuery,
|
||||||
|
selectTool,
|
||||||
|
clearToolSelection,
|
||||||
|
|
||||||
|
// Workflow Actions
|
||||||
|
handleToolSelect,
|
||||||
|
handleBackToTools,
|
||||||
|
handleReaderToggle,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
filteredTools,
|
||||||
|
isPanelVisible,
|
||||||
|
}), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolWorkflowContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</ToolWorkflowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom hook to use the context
|
||||||
|
export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||||
|
const context = useContext(ToolWorkflowContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
|
||||||
|
export const useToolSelection = useToolWorkflow;
|
||||||
|
export const useToolPanelState = useToolWorkflow;
|
||||||
|
export const useWorkbenchState = useToolWorkflow;
|
@ -1,10 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
|
|
||||||
export interface CompressParameters {
|
export interface CompressParameters {
|
||||||
compressionLevel: number;
|
compressionLevel: number;
|
||||||
@ -15,254 +11,39 @@ export interface CompressParameters {
|
|||||||
fileSizeUnit: 'KB' | 'MB';
|
fileSizeUnit: 'KB' | 'MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompressOperationHook {
|
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||||
executeOperation: (
|
const formData = new FormData();
|
||||||
parameters: CompressParameters,
|
formData.append("fileInput", file);
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
if (parameters.compressionMethod === 'quality') {
|
||||||
files: File[];
|
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||||
thumbnails: string[];
|
} else {
|
||||||
isGeneratingThumbnails: boolean;
|
// File size method
|
||||||
downloadUrl: string | null;
|
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
|
||||||
downloadFilename: string;
|
if (fileSize) {
|
||||||
status: string;
|
formData.append("expectedOutputSize", fileSize);
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCompressOperation = (): CompressOperationHook => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState<string>('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Track blob URLs for cleanup
|
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
|
||||||
blobUrls.forEach(url => {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to revoke blob URL:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setBlobUrls([]);
|
|
||||||
}, [blobUrls]);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: CompressParameters,
|
|
||||||
file: File
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("fileInput", file);
|
|
||||||
|
|
||||||
if (parameters.compressionMethod === 'quality') {
|
|
||||||
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
|
||||||
} else {
|
|
||||||
// File size method
|
|
||||||
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
|
|
||||||
if (fileSize) {
|
|
||||||
formData.append("expectedOutputSize", fileSize);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
formData.append("grayscale", parameters.grayscale.toString());
|
formData.append("grayscale", parameters.grayscale.toString());
|
||||||
|
return formData;
|
||||||
const endpoint = "/api/v1/misc/compress-pdf";
|
};
|
||||||
|
|
||||||
return { formData, endpoint };
|
export const useCompressOperation = () => {
|
||||||
}, []);
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const createOperation = useCallback((
|
return useToolOperation<CompressParameters>({
|
||||||
parameters: CompressParameters,
|
operationType: 'compress',
|
||||||
selectedFiles: File[]
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
buildFormData,
|
||||||
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
filePrefix: 'compressed_',
|
||||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
multiFileEndpoint: false, // Individual API calls per file
|
||||||
|
validateParams: (params) => {
|
||||||
const operation: FileOperation = {
|
if (params.compressionMethod === 'filesize' && !params.fileSizeValue) {
|
||||||
id: operationId,
|
return { valid: false, errors: [t('compress.validation.fileSizeRequired', 'File size value is required when using filesize method')] };
|
||||||
type: 'compress',
|
}
|
||||||
timestamp: Date.now(),
|
return { valid: true };
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
},
|
||||||
status: 'pending',
|
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
|
||||||
metadata: {
|
});
|
||||||
originalFileNames: selectedFiles.map(f => f.name),
|
|
||||||
parameters: {
|
|
||||||
compressionLevel: parameters.compressionLevel,
|
|
||||||
grayscale: parameters.grayscale,
|
|
||||||
expectedSize: parameters.expectedSize,
|
|
||||||
},
|
|
||||||
totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
||||||
fileCount: selectedFiles.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: CompressParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
|
||||||
if (validFiles.length === 0) {
|
|
||||||
setErrorMessage('No valid files to compress. All selected files are empty.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validFiles.length < selectedFiles.length) {
|
|
||||||
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const compressedFiles: File[] = [];
|
|
||||||
|
|
||||||
const failedFiles: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < validFiles.length; i++) {
|
|
||||||
const file = validFiles[i];
|
|
||||||
setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { formData, endpoint } = buildFormData(parameters, file);
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
|
||||||
const blob = new Blob([response.data], { type: contentType });
|
|
||||||
const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType });
|
|
||||||
|
|
||||||
compressedFiles.push(compressedFile);
|
|
||||||
} catch (fileError) {
|
|
||||||
console.error(`Failed to compress ${file.name}:`, fileError);
|
|
||||||
failedFiles.push(file.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0 && compressedFiles.length === 0) {
|
|
||||||
throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles(compressedFiles);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
await addFiles(compressedFiles);
|
|
||||||
|
|
||||||
cleanupBlobUrls();
|
|
||||||
|
|
||||||
if (compressedFiles.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(compressedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`compressed_${selectedFiles[0].name}`);
|
|
||||||
} else {
|
|
||||||
const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip');
|
|
||||||
const url = window.URL.createObjectURL(zipFile);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`compressed_${validFiles.length}_files.zip`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnails = await Promise.all(
|
|
||||||
compressedFiles.map(async (file) => {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
return thumbnail || '';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setThumbnails(thumbnails);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("error.pdfPassword", "An error occurred while compressing the PDF.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
|
||||||
errorMsg = error.response.data;
|
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
setStatus(t("error._value", "Compression failed."));
|
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
cleanupBlobUrls();
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [cleanupBlobUrls]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
downloadFilename,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@ -1,36 +1,12 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
import { ConvertParameters } from './useConvertParameters';
|
import { ConvertParameters } from './useConvertParameters';
|
||||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||||
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||||
|
|
||||||
export interface ConvertOperationHook {
|
|
||||||
executeOperation: (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
downloadFilename: string;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldProcessFilesSeparately = (
|
const shouldProcessFilesSeparately = (
|
||||||
selectedFiles: File[],
|
selectedFiles: File[],
|
||||||
parameters: ConvertParameters
|
parameters: ConvertParameters
|
||||||
@ -53,6 +29,46 @@ const shouldProcessFilesSeparately = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
formData.append("fileInput", file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
|
||||||
|
|
||||||
|
if (isImageFormat(toExtension)) {
|
||||||
|
formData.append("imageFormat", toExtension);
|
||||||
|
formData.append("colorType", imageOptions.colorType);
|
||||||
|
formData.append("dpi", imageOptions.dpi.toString());
|
||||||
|
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
|
||||||
|
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
|
||||||
|
formData.append("outputFormat", toExtension);
|
||||||
|
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
|
||||||
|
formData.append("outputFormat", toExtension);
|
||||||
|
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
|
||||||
|
formData.append("outputFormat", toExtension);
|
||||||
|
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
|
||||||
|
formData.append("fitOption", imageOptions.fitOption);
|
||||||
|
formData.append("colorType", imageOptions.colorType);
|
||||||
|
formData.append("autoRotate", imageOptions.autoRotate.toString());
|
||||||
|
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
|
||||||
|
formData.append("zoom", htmlOptions.zoomLevel.toString());
|
||||||
|
} else if (fromExtension === 'eml' && toExtension === 'pdf') {
|
||||||
|
formData.append("includeAttachments", emailOptions.includeAttachments.toString());
|
||||||
|
formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString());
|
||||||
|
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
|
||||||
|
formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString());
|
||||||
|
} else if (fromExtension === 'pdf' && toExtension === 'pdfa') {
|
||||||
|
formData.append("outputFormat", pdfaOptions.outputFormat);
|
||||||
|
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
||||||
|
formData.append("pageNumbers", "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
const createFileFromResponse = (
|
const createFileFromResponse = (
|
||||||
responseData: any,
|
responseData: any,
|
||||||
headers: any,
|
headers: any,
|
||||||
@ -65,361 +81,70 @@ const createFileFromResponse = (
|
|||||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateThumbnailsForFiles = async (files: File[]): Promise<string[]> => {
|
export const useConvertOperation = () => {
|
||||||
const thumbnails: string[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
thumbnails.push(thumbnail);
|
|
||||||
} catch (error) {
|
|
||||||
thumbnails.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnails;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => {
|
|
||||||
if (files.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(files[0]);
|
|
||||||
return { url, filename: files[0].name };
|
|
||||||
} else {
|
|
||||||
const JSZip = (await import('jszip')).default;
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
zip.file(file.name, file);
|
|
||||||
});
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const zipUrl = window.URL.createObjectURL(zipBlob);
|
|
||||||
|
|
||||||
return { url: zipUrl, filename: 'converted_files.zip' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConvertOperation = (): ConvertOperationHook => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const customConvertProcessor = useCallback(async (
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: ConvertParameters,
|
parameters: ConvertParameters,
|
||||||
selectedFiles: File[]
|
selectedFiles: File[]
|
||||||
) => {
|
): Promise<File[]> => {
|
||||||
const formData = new FormData();
|
|
||||||
|
const processedFiles: File[] = [];
|
||||||
|
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||||
|
|
||||||
selectedFiles.forEach(file => {
|
if (!endpoint) {
|
||||||
formData.append("fileInput", file);
|
throw new Error(t('errorNotSupported', 'Unsupported conversion format'));
|
||||||
});
|
|
||||||
|
|
||||||
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
|
|
||||||
|
|
||||||
if (isImageFormat(toExtension)) {
|
|
||||||
formData.append("imageFormat", toExtension);
|
|
||||||
formData.append("colorType", imageOptions.colorType);
|
|
||||||
formData.append("dpi", imageOptions.dpi.toString());
|
|
||||||
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
|
|
||||||
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
|
|
||||||
formData.append("outputFormat", toExtension);
|
|
||||||
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
|
|
||||||
formData.append("outputFormat", toExtension);
|
|
||||||
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
|
|
||||||
formData.append("outputFormat", toExtension);
|
|
||||||
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
|
|
||||||
formData.append("fitOption", imageOptions.fitOption);
|
|
||||||
formData.append("colorType", imageOptions.colorType);
|
|
||||||
formData.append("autoRotate", imageOptions.autoRotate.toString());
|
|
||||||
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
|
|
||||||
formData.append("zoom", htmlOptions.zoomLevel.toString());
|
|
||||||
} else if (fromExtension === 'eml' && toExtension === 'pdf') {
|
|
||||||
formData.append("includeAttachments", emailOptions.includeAttachments.toString());
|
|
||||||
formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString());
|
|
||||||
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
|
|
||||||
formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString());
|
|
||||||
} else if (fromExtension === 'pdf' && toExtension === 'pdfa') {
|
|
||||||
formData.append("outputFormat", pdfaOptions.outputFormat);
|
|
||||||
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
|
||||||
formData.append("pageNumbers", "all");
|
|
||||||
}
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createOperation = useCallback((
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
|
||||||
const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles[0].name;
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'convert',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0].name,
|
|
||||||
parameters: {
|
|
||||||
fromExtension: parameters.fromExtension,
|
|
||||||
toExtension: parameters.toExtension,
|
|
||||||
imageOptions: parameters.imageOptions,
|
|
||||||
htmlOptions: parameters.htmlOptions,
|
|
||||||
emailOptions: parameters.emailOptions,
|
|
||||||
pdfaOptions: parameters.pdfaOptions,
|
|
||||||
},
|
|
||||||
fileSize: selectedFiles[0].size
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const processResults = useCallback(async (blob: Blob, filename: string) => {
|
|
||||||
try {
|
|
||||||
// For single file conversions, create a file directly
|
|
||||||
const convertedFile = new File([blob], filename, { type: blob.type });
|
|
||||||
|
|
||||||
// Set local state for preview
|
|
||||||
setFiles([convertedFile]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
// Add converted file to FileContext for future use
|
|
||||||
await addFiles([convertedFile]);
|
|
||||||
|
|
||||||
// Generate thumbnail for preview
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(convertedFile);
|
|
||||||
setThumbnails([thumbnail]);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
|
|
||||||
setThumbnails(['']);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to process conversion result:', error);
|
|
||||||
}
|
|
||||||
}, [addFiles]);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert-specific routing logic: decide batch vs individual processing
|
||||||
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||||
await executeMultipleSeparateFiles(parameters, selectedFiles);
|
// Individual processing for complex cases (PDF→image, smart detection, etc.)
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
try {
|
||||||
|
const formData = buildFormData(parameters, [file]);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||||
|
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to convert file ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await executeSingleCombinedOperation(parameters, selectedFiles);
|
// Batch processing for simple cases (image→PDF combine)
|
||||||
|
const formData = buildFormData(parameters, selectedFiles);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const baseFilename = selectedFiles.length === 1
|
||||||
|
? selectedFiles[0].name
|
||||||
|
: 'converted_files';
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return processedFiles;
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const executeMultipleSeparateFiles = async (
|
return useToolOperation<ConvertParameters>({
|
||||||
parameters: ConvertParameters,
|
operationType: 'convert',
|
||||||
selectedFiles: File[]
|
endpoint: '', // Not used with customProcessor but required
|
||||||
) => {
|
buildFormData, // Not used with customProcessor but required
|
||||||
setStatus(t("loading"));
|
filePrefix: 'converted_',
|
||||||
setIsLoading(true);
|
customProcessor: customConvertProcessor, // Convert handles its own routing
|
||||||
setErrorMessage(null);
|
validateParams: (params) => {
|
||||||
|
return { valid: true };
|
||||||
const results: File[] = [];
|
},
|
||||||
|
getErrorMessage: (error) => {
|
||||||
try {
|
|
||||||
// Process each file separately
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
|
||||||
const file = selectedFiles[i];
|
|
||||||
setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`));
|
|
||||||
|
|
||||||
const fileExtension = detectFileExtension(file.name);
|
|
||||||
let endpoint = getEndpointUrl(fileExtension, parameters.toExtension);
|
|
||||||
let fileSpecificParams = { ...parameters, fromExtension: fileExtension };
|
|
||||||
if (!endpoint && parameters.toExtension === 'pdf') {
|
|
||||||
endpoint = '/api/v1/convert/file/pdf';
|
|
||||||
console.log(`Using file-to-pdf fallback for ${fileExtension} file: ${file.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
console.error(`No endpoint available for ${fileExtension} to ${parameters.toExtension}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(fileSpecificParams, [file]);
|
|
||||||
const formData = buildFormData(fileSpecificParams, [file]);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
// Use utility function to create file from response
|
|
||||||
const convertedFile = createFileFromResponse(
|
|
||||||
response.data,
|
|
||||||
response.headers,
|
|
||||||
file.name,
|
|
||||||
parameters.toExtension
|
|
||||||
);
|
|
||||||
results.push(convertedFile);
|
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error converting file ${file.name}:`, error);
|
|
||||||
markOperationFailed(fileId, operationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
|
|
||||||
const generatedThumbnails = await generateThumbnailsForFiles(results);
|
|
||||||
|
|
||||||
setFiles(results);
|
|
||||||
setThumbnails(generatedThumbnails);
|
|
||||||
|
|
||||||
await addFiles(results);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { url, filename } = await createDownloadInfo(results);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(filename);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create download info:', error);
|
|
||||||
const url = window.URL.createObjectURL(results[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(results[0].name);
|
|
||||||
}
|
|
||||||
setStatus(t("convert.multipleFilesComplete", `Converted ${results.length} files successfully`));
|
|
||||||
} else {
|
|
||||||
setErrorMessage(t("convert.errorAllFilesFailed", "All files failed to convert"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in multiple operations:', error);
|
|
||||||
setErrorMessage(t("convert.errorMultipleConversion", "An error occurred while converting multiple files"));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeSingleCombinedOperation = async (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
const formData = buildFormData(parameters, selectedFiles);
|
|
||||||
|
|
||||||
// Get endpoint using utility function
|
|
||||||
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
|
||||||
if (!endpoint) {
|
|
||||||
setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
// Use utility function to create file from response
|
|
||||||
const originalFileName = selectedFiles.length === 1
|
|
||||||
? selectedFiles[0].name
|
|
||||||
: 'combined_files.pdf'; // Default extension for combined files
|
|
||||||
|
|
||||||
const convertedFile = createFileFromResponse(
|
|
||||||
response.data,
|
|
||||||
response.headers,
|
|
||||||
originalFileName,
|
|
||||||
parameters.toExtension
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(convertedFile);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(convertedFile.name);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
|
|
||||||
await processResults(new Blob([convertedFile]), convertedFile.name);
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("convert.errorConversion", "An error occurred while converting the file.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
errorMsg = error.response.data;
|
return error.response.data;
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
}
|
||||||
setErrorMessage(errorMsg);
|
if (error.message) {
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
return error.message;
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
// Clean up blob URLs to prevent memory leaks
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setDownloadFilename('');
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [downloadUrl]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
}
|
||||||
};
|
return t("convert.errorConversion", "An error occurred while converting the file.");
|
||||||
}, [downloadUrl]);
|
}
|
||||||
|
});
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
downloadFilename,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
};
|
@ -1,372 +1,117 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
|
||||||
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||||
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
import { useToolResources } from '../shared/useToolResources';
|
||||||
|
|
||||||
//Extract files from a ZIP blob
|
// Helper: get MIME type based on file extension
|
||||||
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
|
|
||||||
const JSZip = await import('jszip');
|
|
||||||
const zip = new JSZip.default();
|
|
||||||
|
|
||||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
|
||||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
|
||||||
|
|
||||||
const extractedFiles: File[] = [];
|
|
||||||
|
|
||||||
for (const [filename, file] of Object.entries(zipContent.files)) {
|
|
||||||
if (!file.dir) {
|
|
||||||
const content = await file.async('blob');
|
|
||||||
const extractedFile = new File([content], filename, { type: getMimeType(filename) });
|
|
||||||
extractedFiles.push(extractedFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Get MIME type based on file extension
|
|
||||||
function getMimeType(filename: string): string {
|
function getMimeType(filename: string): string {
|
||||||
const ext = filename.toLowerCase().split('.').pop();
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'pdf':
|
case 'pdf': return 'application/pdf';
|
||||||
return 'application/pdf';
|
case 'txt': return 'text/plain';
|
||||||
case 'txt':
|
case 'zip': return 'application/zip';
|
||||||
return 'text/plain';
|
default: return 'application/octet-stream';
|
||||||
case 'zip':
|
|
||||||
return 'application/zip';
|
|
||||||
default:
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OCROperationHook {
|
// Lightweight ZIP extractor (keep or replace with a shared util if you have one)
|
||||||
files: File[];
|
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
|
||||||
thumbnails: string[];
|
const JSZip = await import('jszip');
|
||||||
downloadUrl: string | null;
|
const zip = new JSZip.default();
|
||||||
downloadFilename: string | null;
|
const zipContent = await zip.loadAsync(await zipBlob.arrayBuffer());
|
||||||
isLoading: boolean;
|
const out: File[] = [];
|
||||||
isGeneratingThumbnails: boolean;
|
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||||
status: string;
|
if (!file.dir) {
|
||||||
errorMessage: string | null;
|
const content = await file.async('blob');
|
||||||
executeOperation: (parameters: OCRParameters, selectedFiles: File[]) => Promise<void>;
|
out.push(new File([content], filename, { type: getMimeType(filename) }));
|
||||||
resetResults: () => void;
|
}
|
||||||
clearError: () => void;
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOCROperation = (): OCROperationHook => {
|
// Helper: strip extension
|
||||||
|
function stripExt(name: string): string {
|
||||||
|
const i = name.lastIndexOf('.');
|
||||||
|
return i > 0 ? name.slice(0, i) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature must be (file, params)
|
||||||
|
const buildFormData = (file: File, parameters: OCRParameters): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
parameters.languages.forEach((lang) => formData.append('languages', lang));
|
||||||
|
formData.append('ocrType', parameters.ocrType);
|
||||||
|
formData.append('ocrRenderType', parameters.ocrRenderType);
|
||||||
|
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
|
||||||
|
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
|
||||||
|
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
|
||||||
|
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
|
||||||
|
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOCROperation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { extractZipFiles } = useToolResources();
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management
|
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
const headBuf = await blob.slice(0, 8).arrayBuffer();
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
const head = new TextDecoder().decode(new Uint8Array(headBuf));
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState<string>('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Track blob URLs for cleanup
|
// ZIP: sidecar or multi-asset output
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
if (head.startsWith('PK')) {
|
||||||
|
const base = stripExt(originalFiles[0].name);
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
|
||||||
blobUrls.forEach(url => {
|
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(url);
|
const extracted = await extractZipFiles(blob);
|
||||||
} catch (error) {
|
if (extracted.length > 0) return extracted;
|
||||||
console.warn('Failed to revoke blob URL:', error);
|
} catch { /* ignore and try local extractor */ }
|
||||||
}
|
try {
|
||||||
});
|
const local = await extractZipFile(blob); // local fallback
|
||||||
setBlobUrls([]);
|
if (local.length > 0) return local;
|
||||||
}, [blobUrls]);
|
} catch { /* fall through */ }
|
||||||
|
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: OCRParameters,
|
|
||||||
file: File
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
// Add the file
|
|
||||||
formData.append('fileInput', file);
|
|
||||||
|
|
||||||
// Add languages as multiple parameters with same name (like checkboxes)
|
|
||||||
parameters.languages.forEach(lang => {
|
|
||||||
formData.append('languages', lang);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add other parameters
|
|
||||||
formData.append('ocrType', parameters.ocrType);
|
|
||||||
formData.append('ocrRenderType', parameters.ocrRenderType);
|
|
||||||
|
|
||||||
// Handle additional options - convert array to individual boolean parameters
|
|
||||||
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
|
|
||||||
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
|
|
||||||
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
|
|
||||||
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
|
|
||||||
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
|
|
||||||
|
|
||||||
const endpoint = '/api/v1/misc/ocr-pdf';
|
|
||||||
|
|
||||||
return { formData, endpoint };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createOperation = useCallback((
|
|
||||||
parameters: OCRParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
|
||||||
const operationId = `ocr-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'ocr',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0]?.name,
|
|
||||||
parameters: {
|
|
||||||
languages: parameters.languages,
|
|
||||||
ocrType: parameters.ocrType,
|
|
||||||
ocrRenderType: parameters.ocrRenderType,
|
|
||||||
additionalOptions: parameters.additionalOptions,
|
|
||||||
},
|
|
||||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: OCRParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected") || "No file selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parameters.languages.length === 0) {
|
|
||||||
setErrorMessage('Please select at least one language for OCR processing.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
// Not a PDF: surface error details if present
|
||||||
if (validFiles.length === 0) {
|
if (!head.startsWith('%PDF')) {
|
||||||
setErrorMessage('No valid files to process. All selected files are empty.');
|
const textBuf = await blob.slice(0, 1024).arrayBuffer();
|
||||||
return;
|
const text = new TextDecoder().decode(new Uint8Array(textBuf));
|
||||||
}
|
if (/error|exception|html/i.test(text)) {
|
||||||
|
if (text.includes('OCR tools') && text.includes('not installed')) {
|
||||||
if (validFiles.length < selectedFiles.length) {
|
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
||||||
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading") || "Loading...");
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processedFiles: File[] = [];
|
|
||||||
const failedFiles: string[] = [];
|
|
||||||
|
|
||||||
// OCR typically processes one file at a time
|
|
||||||
for (let i = 0; i < validFiles.length; i++) {
|
|
||||||
const file = validFiles[i];
|
|
||||||
setStatus(`Processing OCR for ${file.name} (${i + 1}/${validFiles.length})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { formData, endpoint } = buildFormData(parameters, file);
|
|
||||||
const response = await axios.post(endpoint, formData, {
|
|
||||||
responseType: "blob",
|
|
||||||
timeout: 300000 // 5 minute timeout for OCR
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for HTTP errors
|
|
||||||
if (response.status >= 400) {
|
|
||||||
// Try to read error response as text
|
|
||||||
const errorText = await response.data.text();
|
|
||||||
throw new Error(`OCR service HTTP error ${response.status}: ${errorText.substring(0, 300)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate response
|
|
||||||
if (!response.data || response.data.size === 0) {
|
|
||||||
throw new Error('Empty response from OCR service');
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
|
||||||
|
|
||||||
// Check if response is actually a PDF by examining the first few bytes
|
|
||||||
const arrayBuffer = await response.data.arrayBuffer();
|
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
|
||||||
const header = new TextDecoder().decode(uint8Array.slice(0, 4));
|
|
||||||
|
|
||||||
// Check if it's a ZIP file (OCR service returns ZIP when sidecar is enabled or for multi-file results)
|
|
||||||
if (header.startsWith('PK')) {
|
|
||||||
try {
|
|
||||||
// Extract ZIP file contents
|
|
||||||
const zipFiles = await extractZipFile(response.data);
|
|
||||||
|
|
||||||
// Add extracted files to processed files
|
|
||||||
processedFiles.push(...zipFiles);
|
|
||||||
} catch (extractError) {
|
|
||||||
// Fallback to treating as single ZIP file
|
|
||||||
const blob = new Blob([response.data], { type: 'application/zip' });
|
|
||||||
const processedFile = new File([blob], `ocr_${file.name}.zip`, { type: 'application/zip' });
|
|
||||||
processedFiles.push(processedFile);
|
|
||||||
}
|
|
||||||
continue; // Skip the PDF validation for ZIP files
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!header.startsWith('%PDF')) {
|
|
||||||
// Check if it's an error response
|
|
||||||
const text = new TextDecoder().decode(uint8Array.slice(0, 500));
|
|
||||||
|
|
||||||
if (text.includes('error') || text.includes('Error') || text.includes('exception') || text.includes('html')) {
|
|
||||||
// Check for specific OCR tool unavailable error
|
|
||||||
if (text.includes('OCR tools') && text.includes('not installed')) {
|
|
||||||
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
|
||||||
}
|
|
||||||
throw new Error(`OCR service error: ${text.substring(0, 300)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an HTML error page
|
|
||||||
if (text.includes('<html') || text.includes('<!DOCTYPE')) {
|
|
||||||
// Try to extract error message from HTML
|
|
||||||
const errorMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i) ||
|
|
||||||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
|
|
||||||
text.match(/<body[^>]*>([^<]+)<\/body>/i);
|
|
||||||
const errorMessage = errorMatch ? errorMatch[1].trim() : 'Unknown error';
|
|
||||||
throw new Error(`OCR service error: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Response is not a valid PDF file. Header: "${header}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([response.data], { type: contentType });
|
|
||||||
const processedFile = new File([blob], `ocr_${file.name}`, { type: contentType });
|
|
||||||
|
|
||||||
processedFiles.push(processedFile);
|
|
||||||
} catch (fileError) {
|
|
||||||
const errorMessage = fileError instanceof Error ? fileError.message : 'Unknown error';
|
|
||||||
failedFiles.push(`${file.name} (${errorMessage})`);
|
|
||||||
}
|
}
|
||||||
|
const title =
|
||||||
|
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
|
||||||
|
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
|
||||||
|
t('ocr.error.unknown', 'Unknown error');
|
||||||
|
throw new Error(`OCR service error: ${title}`);
|
||||||
}
|
}
|
||||||
|
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
|
||||||
if (failedFiles.length > 0 && processedFiles.length === 0) {
|
|
||||||
throw new Error(`Failed to process OCR for all files: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
|
||||||
} else {
|
|
||||||
const hasPdfFiles = processedFiles.some(file => file.name.endsWith('.pdf'));
|
|
||||||
const hasTxtFiles = processedFiles.some(file => file.name.endsWith('.txt'));
|
|
||||||
let statusMessage = `OCR completed successfully for ${processedFiles.length} file(s)`;
|
|
||||||
|
|
||||||
if (hasPdfFiles && hasTxtFiles) {
|
|
||||||
statusMessage += ' (Extracted PDF and text files)';
|
|
||||||
} else if (hasPdfFiles) {
|
|
||||||
statusMessage += ' (Extracted PDF files)';
|
|
||||||
} else if (hasTxtFiles) {
|
|
||||||
statusMessage += ' (Extracted text files)';
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(statusMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles(processedFiles);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
await addFiles(processedFiles);
|
|
||||||
|
|
||||||
// Cleanup old blob URLs
|
|
||||||
cleanupBlobUrls();
|
|
||||||
|
|
||||||
// Create download URL - for multiple files, we'll create a new ZIP
|
|
||||||
if (processedFiles.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(processedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(processedFiles[0].name);
|
|
||||||
} else {
|
|
||||||
// For multiple files, create a new ZIP containing all extracted files
|
|
||||||
try {
|
|
||||||
const JSZip = await import('jszip');
|
|
||||||
const zip = new JSZip.default();
|
|
||||||
|
|
||||||
for (const file of processedFiles) {
|
|
||||||
zip.file(file.name, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const url = window.URL.createObjectURL(zipBlob);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`ocr_extracted_files.zip`);
|
|
||||||
} catch (zipError) {
|
|
||||||
// Fallback to first file
|
|
||||||
const url = window.URL.createObjectURL(processedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(processedFiles[0].name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OCR operation error:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'OCR operation failed';
|
|
||||||
setErrorMessage(errorMessage);
|
|
||||||
setStatus('');
|
|
||||||
markOperationFailed(fileId, operationId, errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [buildFormData, createOperation, recordOperation, addFiles, cleanupBlobUrls, markOperationApplied, markOperationFailed, t]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
const base = stripExt(originalFiles[0].name);
|
||||||
setFiles([]);
|
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
|
||||||
setThumbnails([]);
|
}, [t, extractZipFiles]);
|
||||||
setDownloadUrl(null);
|
|
||||||
setDownloadFilename('');
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
cleanupBlobUrls();
|
|
||||||
}, [cleanupBlobUrls]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||||
setErrorMessage(null);
|
operationType: 'ocr',
|
||||||
}, []);
|
endpoint: '/api/v1/misc/ocr-pdf',
|
||||||
|
buildFormData,
|
||||||
return {
|
filePrefix: 'ocr_',
|
||||||
files,
|
multiFileEndpoint: false, // Process files individually
|
||||||
thumbnails,
|
responseHandler, // use shared flow
|
||||||
downloadUrl,
|
validateParams: (params) =>
|
||||||
downloadFilename,
|
params.languages.length === 0
|
||||||
isLoading,
|
? { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] }
|
||||||
isGeneratingThumbnails,
|
: { valid: true },
|
||||||
status,
|
getErrorMessage: (error) =>
|
||||||
errorMessage,
|
error.message?.includes('OCR tools') && error.message?.includes('not installed')
|
||||||
executeOperation,
|
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'
|
||||||
resetResults,
|
: createStandardErrorHandler(t('ocr.error.failed', 'OCR operation failed'))(error),
|
||||||
clearError,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
return useToolOperation(ocrConfig);
|
||||||
|
};
|
||||||
|
86
frontend/src/hooks/tools/shared/useToolApiCalls.ts
Normal file
86
frontend/src/hooks/tools/shared/useToolApiCalls.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import axios, { CancelTokenSource } from 'axios';
|
||||||
|
import { processResponse } from '../../../utils/toolResponseProcessor';
|
||||||
|
import type { ResponseHandler, ProcessingProgress } from './useToolState';
|
||||||
|
|
||||||
|
export interface ApiCallsConfig<TParams = void> {
|
||||||
|
endpoint: string | ((params: TParams) => string);
|
||||||
|
buildFormData: (file: File, params: TParams) => FormData;
|
||||||
|
filePrefix: string;
|
||||||
|
responseHandler?: ResponseHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToolApiCalls = <TParams = void>() => {
|
||||||
|
const cancelTokenRef = useRef<CancelTokenSource | null>(null);
|
||||||
|
|
||||||
|
const processFiles = useCallback(async (
|
||||||
|
params: TParams,
|
||||||
|
validFiles: File[],
|
||||||
|
config: ApiCallsConfig<TParams>,
|
||||||
|
onProgress: (progress: ProcessingProgress) => void,
|
||||||
|
onStatus: (status: string) => void
|
||||||
|
): Promise<File[]> => {
|
||||||
|
const processedFiles: File[] = [];
|
||||||
|
const failedFiles: string[] = [];
|
||||||
|
const total = validFiles.length;
|
||||||
|
|
||||||
|
// Create cancel token for this operation
|
||||||
|
cancelTokenRef.current = axios.CancelToken.source();
|
||||||
|
|
||||||
|
for (let i = 0; i < validFiles.length; i++) {
|
||||||
|
const file = validFiles[i];
|
||||||
|
|
||||||
|
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||||
|
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = config.buildFormData(file, params);
|
||||||
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
|
const response = await axios.post(endpoint, formData, {
|
||||||
|
responseType: 'blob',
|
||||||
|
cancelToken: cancelTokenRef.current.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||||
|
const responseFiles = await processResponse(
|
||||||
|
response.data,
|
||||||
|
[file],
|
||||||
|
config.filePrefix,
|
||||||
|
config.responseHandler
|
||||||
|
);
|
||||||
|
processedFiles.push(...responseFiles);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isCancel(error)) {
|
||||||
|
throw new Error('Operation was cancelled');
|
||||||
|
}
|
||||||
|
console.error(`Failed to process ${file.name}:`, error);
|
||||||
|
failedFiles.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedFiles.length > 0 && processedFiles.length === 0) {
|
||||||
|
throw new Error(`Failed to process all files: ${failedFiles.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
onStatus(`Processed ${processedFiles.length}/${total} files. Failed: ${failedFiles.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedFiles;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelOperation = useCallback(() => {
|
||||||
|
if (cancelTokenRef.current) {
|
||||||
|
cancelTokenRef.current.cancel('Operation cancelled by user');
|
||||||
|
cancelTokenRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
processFiles,
|
||||||
|
cancelOperation,
|
||||||
|
};
|
||||||
|
};
|
264
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
264
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
|
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||||
|
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||||
|
import { useToolResources } from './useToolResources';
|
||||||
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
|
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||||
|
import { type ResponseHandler, processResponse } from '../../../utils/toolResponseProcessor';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for backwards compatibility
|
||||||
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for tool operations defining processing behavior and API integration.
|
||||||
|
*
|
||||||
|
* Supports three patterns:
|
||||||
|
* 1. Single-file tools: multiFileEndpoint: false, processes files individually
|
||||||
|
* 2. Multi-file tools: multiFileEndpoint: true, single API call with all files
|
||||||
|
* 3. Complex tools: customProcessor handles all processing logic
|
||||||
|
*/
|
||||||
|
export interface ToolOperationConfig<TParams = void> {
|
||||||
|
/** Operation identifier for tracking and logging */
|
||||||
|
operationType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint for the operation. Can be static string or function for dynamic routing.
|
||||||
|
* Not used when customProcessor is provided.
|
||||||
|
*/
|
||||||
|
endpoint: string | ((params: TParams) => string);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds FormData for API request. Signature determines processing approach:
|
||||||
|
* - (params, file: File) => FormData: Single-file processing
|
||||||
|
* - (params, files: File[]) => FormData: Multi-file processing
|
||||||
|
* Not used when customProcessor is provided.
|
||||||
|
*/
|
||||||
|
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData);
|
||||||
|
|
||||||
|
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||||
|
filePrefix: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this tool uses backends that accept MultipartFile[] arrays.
|
||||||
|
* - true: Single API call with all files (backend uses MultipartFile[])
|
||||||
|
* - false/undefined: Individual API calls per file (backend uses single MultipartFile)
|
||||||
|
* Ignored when customProcessor is provided.
|
||||||
|
*/
|
||||||
|
multiFileEndpoint?: boolean;
|
||||||
|
|
||||||
|
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
||||||
|
responseHandler?: ResponseHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom processing logic that completely bypasses standard file processing.
|
||||||
|
* When provided, tool handles all API calls, response processing, and file creation.
|
||||||
|
* Use for tools with complex routing logic or non-standard processing requirements.
|
||||||
|
*/
|
||||||
|
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
|
||||||
|
|
||||||
|
/** Validate parameters before execution. Return validation errors if invalid. */
|
||||||
|
validateParams?: (params: TParams) => ValidationResult;
|
||||||
|
|
||||||
|
/** Extract user-friendly error messages from API errors */
|
||||||
|
getErrorMessage?: (error: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete tool operation interface with execution capability
|
||||||
|
*/
|
||||||
|
export interface ToolOperationHook<TParams = void> {
|
||||||
|
// State
|
||||||
|
files: File[];
|
||||||
|
thumbnails: string[];
|
||||||
|
isGeneratingThumbnails: boolean;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
downloadFilename: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
progress: ProcessingProgress | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
||||||
|
resetResults: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
cancelOperation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for backwards compatibility
|
||||||
|
export { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook for tool operations providing consistent error handling, progress tracking,
|
||||||
|
* and FileContext integration. Eliminates boilerplate while maintaining flexibility.
|
||||||
|
*
|
||||||
|
* Supports three tool patterns:
|
||||||
|
* 1. Single-file tools: Set multiFileEndpoint: false, processes files individually
|
||||||
|
* 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files
|
||||||
|
* 3. Complex tools: Provide customProcessor for full control over processing logic
|
||||||
|
*
|
||||||
|
* @param config - Tool operation configuration
|
||||||
|
* @returns Hook interface with state and execution methods
|
||||||
|
*/
|
||||||
|
export const useToolOperation = <TParams = void>(
|
||||||
|
config: ToolOperationConfig<TParams>
|
||||||
|
): ToolOperationHook<TParams> => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
|
||||||
|
|
||||||
|
// Composed hooks
|
||||||
|
const { state, actions } = useToolState();
|
||||||
|
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||||
|
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
|
const executeOperation = useCallback(async (
|
||||||
|
params: TParams,
|
||||||
|
selectedFiles: File[]
|
||||||
|
): Promise<void> => {
|
||||||
|
// Validation
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
actions.setError(t('noFileSelected', 'No files selected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.validateParams) {
|
||||||
|
const validation = config.validateParams(params);
|
||||||
|
if (!validation.valid) {
|
||||||
|
actions.setError(validation.errors?.join(', ') || 'Invalid parameters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
actions.setError(t('noValidFiles', 'No valid files to process'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup operation tracking
|
||||||
|
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
||||||
|
recordOperation(fileId, operation);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
actions.setLoading(true);
|
||||||
|
actions.setError(null);
|
||||||
|
actions.resetResults();
|
||||||
|
cleanupBlobUrls();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let processedFiles: File[];
|
||||||
|
|
||||||
|
if (config.customProcessor) {
|
||||||
|
actions.setStatus('Processing files...');
|
||||||
|
processedFiles = await config.customProcessor(params, validFiles);
|
||||||
|
} else {
|
||||||
|
// Use explicit multiFileEndpoint flag to determine processing approach
|
||||||
|
if (config.multiFileEndpoint) {
|
||||||
|
// Multi-file processing - single API call with all files
|
||||||
|
actions.setStatus('Processing files...');
|
||||||
|
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles);
|
||||||
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
|
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
// Multi-file responses are typically ZIP files that need extraction
|
||||||
|
if (config.responseHandler) {
|
||||||
|
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||||
|
processedFiles = await config.responseHandler(response.data, validFiles);
|
||||||
|
} else {
|
||||||
|
// Default: assume ZIP response for multi-file endpoints
|
||||||
|
processedFiles = await extractZipFiles(response.data);
|
||||||
|
|
||||||
|
if (processedFiles.length === 0) {
|
||||||
|
// Try the generic extraction as fallback
|
||||||
|
processedFiles = await extractAllZipFiles(response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Individual file processing - separate API call per file
|
||||||
|
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
buildFormData: (file: File, params: TParams) => (config.buildFormData as (file: File, params: TParams) => FormData)(file, params),
|
||||||
|
filePrefix: config.filePrefix,
|
||||||
|
responseHandler: config.responseHandler
|
||||||
|
};
|
||||||
|
processedFiles = await processFiles(
|
||||||
|
params,
|
||||||
|
validFiles,
|
||||||
|
apiCallsConfig,
|
||||||
|
actions.setProgress,
|
||||||
|
actions.setStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedFiles.length > 0) {
|
||||||
|
actions.setFiles(processedFiles);
|
||||||
|
|
||||||
|
// Generate thumbnails and download URL concurrently
|
||||||
|
actions.setGeneratingThumbnails(true);
|
||||||
|
const [thumbnails, downloadInfo] = await Promise.all([
|
||||||
|
generateThumbnails(processedFiles),
|
||||||
|
createDownloadInfo(processedFiles, config.operationType)
|
||||||
|
]);
|
||||||
|
actions.setGeneratingThumbnails(false);
|
||||||
|
|
||||||
|
actions.setThumbnails(thumbnails);
|
||||||
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
|
// Add to file context
|
||||||
|
await addFiles(processedFiles);
|
||||||
|
|
||||||
|
markOperationApplied(fileId, operationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||||
|
actions.setError(errorMessage);
|
||||||
|
actions.setStatus('');
|
||||||
|
markOperationFailed(fileId, operationId, errorMessage);
|
||||||
|
} finally {
|
||||||
|
actions.setLoading(false);
|
||||||
|
actions.setProgress(null);
|
||||||
|
}
|
||||||
|
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||||
|
|
||||||
|
const cancelOperation = useCallback(() => {
|
||||||
|
cancelApiCalls();
|
||||||
|
actions.setLoading(false);
|
||||||
|
actions.setProgress(null);
|
||||||
|
actions.setStatus('Operation cancelled');
|
||||||
|
}, [cancelApiCalls, actions]);
|
||||||
|
|
||||||
|
const resetResults = useCallback(() => {
|
||||||
|
cleanupBlobUrls();
|
||||||
|
actions.resetResults();
|
||||||
|
}, [cleanupBlobUrls, actions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
files: state.files,
|
||||||
|
thumbnails: state.thumbnails,
|
||||||
|
isGeneratingThumbnails: state.isGeneratingThumbnails,
|
||||||
|
downloadUrl: state.downloadUrl,
|
||||||
|
downloadFilename: state.downloadFilename,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
status: state.status,
|
||||||
|
errorMessage: state.errorMessage,
|
||||||
|
progress: state.progress,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
executeOperation,
|
||||||
|
resetResults,
|
||||||
|
clearError: actions.clearError,
|
||||||
|
cancelOperation
|
||||||
|
};
|
||||||
|
};
|
114
frontend/src/hooks/tools/shared/useToolResources.ts
Normal file
114
frontend/src/hooks/tools/shared/useToolResources.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||||
|
import { zipFileService } from '../../../services/zipFileService';
|
||||||
|
|
||||||
|
|
||||||
|
export const useToolResources = () => {
|
||||||
|
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const addBlobUrl = useCallback((url: string) => {
|
||||||
|
setBlobUrls(prev => [...prev, url]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupBlobUrls = useCallback(() => {
|
||||||
|
blobUrls.forEach(url => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke blob URL:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setBlobUrls([]);
|
||||||
|
}, [blobUrls]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
blobUrls.forEach(url => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke blob URL during cleanup:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [blobUrls]);
|
||||||
|
|
||||||
|
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
|
||||||
|
const thumbnails: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
|
thumbnails.push(thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||||
|
thumbnails.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbnails;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||||
|
try {
|
||||||
|
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
||||||
|
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
||||||
|
return extractionResult.success ? extractionResult.extractedFiles : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useToolResources.extractZipFiles - Error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||||
|
try {
|
||||||
|
const JSZip = (await import('jszip')).default;
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||||
|
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||||
|
|
||||||
|
const extractedFiles: File[] = [];
|
||||||
|
|
||||||
|
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||||
|
if (!file.dir) {
|
||||||
|
const content = await file.async('blob');
|
||||||
|
const extractedFile = new File([content], filename, { type: 'application/pdf' });
|
||||||
|
extractedFiles.push(extractedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractedFiles;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in extractAllZipFiles:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createDownloadInfo = useCallback(async (
|
||||||
|
files: File[],
|
||||||
|
operationType: string
|
||||||
|
): Promise<{ url: string; filename: string }> => {
|
||||||
|
if (files.length === 1) {
|
||||||
|
const url = URL.createObjectURL(files[0]);
|
||||||
|
addBlobUrl(url);
|
||||||
|
return { url, filename: files[0].name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple files - create zip using shared service
|
||||||
|
const { zipFile } = await zipFileService.createZipFromFiles(files, `${operationType}_results.zip`);
|
||||||
|
const url = URL.createObjectURL(zipFile);
|
||||||
|
addBlobUrl(url);
|
||||||
|
|
||||||
|
return { url, filename: zipFile.name };
|
||||||
|
}, [addBlobUrl]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generateThumbnails,
|
||||||
|
createDownloadInfo,
|
||||||
|
extractZipFiles,
|
||||||
|
extractAllZipFiles,
|
||||||
|
cleanupBlobUrls,
|
||||||
|
};
|
||||||
|
};
|
137
frontend/src/hooks/tools/shared/useToolState.ts
Normal file
137
frontend/src/hooks/tools/shared/useToolState.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useReducer, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface ProcessingProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentFileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationState {
|
||||||
|
files: File[];
|
||||||
|
thumbnails: string[];
|
||||||
|
isGeneratingThumbnails: boolean;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
downloadFilename: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
progress: ProcessingProgress | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationAction =
|
||||||
|
| { type: 'SET_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_FILES'; payload: File[] }
|
||||||
|
| { type: 'SET_THUMBNAILS'; payload: string[] }
|
||||||
|
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
|
||||||
|
| { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } }
|
||||||
|
| { type: 'SET_STATUS'; payload: string }
|
||||||
|
| { type: 'SET_ERROR'; payload: string | null }
|
||||||
|
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
|
||||||
|
| { type: 'RESET_RESULTS' }
|
||||||
|
| { type: 'CLEAR_ERROR' };
|
||||||
|
|
||||||
|
const initialState: OperationState = {
|
||||||
|
files: [],
|
||||||
|
thumbnails: [],
|
||||||
|
isGeneratingThumbnails: false,
|
||||||
|
downloadUrl: null,
|
||||||
|
downloadFilename: '',
|
||||||
|
isLoading: false,
|
||||||
|
status: '',
|
||||||
|
errorMessage: null,
|
||||||
|
progress: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationReducer = (state: OperationState, action: OperationAction): OperationState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_LOADING':
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case 'SET_FILES':
|
||||||
|
return { ...state, files: action.payload };
|
||||||
|
case 'SET_THUMBNAILS':
|
||||||
|
return { ...state, thumbnails: action.payload };
|
||||||
|
case 'SET_GENERATING_THUMBNAILS':
|
||||||
|
return { ...state, isGeneratingThumbnails: action.payload };
|
||||||
|
case 'SET_DOWNLOAD_INFO':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
downloadUrl: action.payload.url,
|
||||||
|
downloadFilename: action.payload.filename
|
||||||
|
};
|
||||||
|
case 'SET_STATUS':
|
||||||
|
return { ...state, status: action.payload };
|
||||||
|
case 'SET_ERROR':
|
||||||
|
return { ...state, errorMessage: action.payload };
|
||||||
|
case 'SET_PROGRESS':
|
||||||
|
return { ...state, progress: action.payload };
|
||||||
|
case 'RESET_RESULTS':
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
isLoading: state.isLoading, // Preserve loading state during reset
|
||||||
|
};
|
||||||
|
case 'CLEAR_ERROR':
|
||||||
|
return { ...state, errorMessage: null };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useToolState = () => {
|
||||||
|
const [state, dispatch] = useReducer(operationReducer, initialState);
|
||||||
|
|
||||||
|
const setLoading = useCallback((loading: boolean) => {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: loading });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFiles = useCallback((files: File[]) => {
|
||||||
|
dispatch({ type: 'SET_FILES', payload: files });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setThumbnails = useCallback((thumbnails: string[]) => {
|
||||||
|
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setGeneratingThumbnails = useCallback((generating: boolean) => {
|
||||||
|
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDownloadInfo = useCallback((url: string | null, filename: string) => {
|
||||||
|
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatus = useCallback((status: string) => {
|
||||||
|
dispatch({ type: 'SET_STATUS', payload: status });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setError = useCallback((error: string | null) => {
|
||||||
|
dispatch({ type: 'SET_ERROR', payload: error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setProgress = useCallback((progress: ProcessingProgress | null) => {
|
||||||
|
dispatch({ type: 'SET_PROGRESS', payload: progress });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetResults = useCallback(() => {
|
||||||
|
dispatch({ type: 'RESET_RESULTS' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
dispatch({ type: 'CLEAR_ERROR' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
actions: {
|
||||||
|
setLoading,
|
||||||
|
setFiles,
|
||||||
|
setThumbnails,
|
||||||
|
setGeneratingThumbnails,
|
||||||
|
setDownloadInfo,
|
||||||
|
setStatus,
|
||||||
|
setError,
|
||||||
|
setProgress,
|
||||||
|
resetResults,
|
||||||
|
clearError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -1,242 +1,82 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||||
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
|
import { SPLIT_MODES } from '../../../constants/splitConstants';
|
||||||
|
|
||||||
export interface SplitOperationHook {
|
|
||||||
executeOperation: (
|
|
||||||
mode: SplitMode | '',
|
|
||||||
parameters: SplitParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSplitOperation = (): SplitOperationHook => {
|
const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
formData.append("fileInput", file);
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (parameters.mode) {
|
||||||
|
case SPLIT_MODES.BY_PAGES:
|
||||||
|
formData.append("pageNumbers", parameters.pages);
|
||||||
|
break;
|
||||||
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
|
formData.append("horizontalDivisions", parameters.hDiv);
|
||||||
|
formData.append("verticalDivisions", parameters.vDiv);
|
||||||
|
formData.append("merge", parameters.merge.toString());
|
||||||
|
break;
|
||||||
|
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||||
|
formData.append(
|
||||||
|
"splitType",
|
||||||
|
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
||||||
|
);
|
||||||
|
formData.append("splitValue", parameters.splitValue);
|
||||||
|
break;
|
||||||
|
case SPLIT_MODES.BY_CHAPTERS:
|
||||||
|
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
||||||
|
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
||||||
|
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEndpoint = (parameters: SplitParameters): string => {
|
||||||
|
switch (parameters.mode) {
|
||||||
|
case SPLIT_MODES.BY_PAGES:
|
||||||
|
return "/api/v1/general/split-pages";
|
||||||
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
|
return "/api/v1/general/split-pdf-by-sections";
|
||||||
|
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||||
|
return "/api/v1/general/split-by-size-or-count";
|
||||||
|
case SPLIT_MODES.BY_CHAPTERS:
|
||||||
|
return "/api/v1/general/split-pdf-by-chapters";
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSplitOperation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management (replacing useOperationResults)
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
return useToolOperation<SplitParameters>({
|
||||||
mode: SplitMode | '',
|
operationType: 'split',
|
||||||
parameters: SplitParameters,
|
endpoint: (params) => getEndpoint(params),
|
||||||
selectedFiles: File[]
|
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
|
||||||
) => {
|
filePrefix: 'split_',
|
||||||
const formData = new FormData();
|
multiFileEndpoint: true, // Single API call with all files
|
||||||
|
validateParams: (params) => {
|
||||||
selectedFiles.forEach(file => {
|
if (!params.mode) {
|
||||||
formData.append("fileInput", file);
|
return { valid: false, errors: [t('split.validation.modeRequired', 'Split mode is required')] };
|
||||||
});
|
|
||||||
|
|
||||||
if (!mode) {
|
|
||||||
throw new Error('Split mode is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = "";
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case SPLIT_MODES.BY_PAGES:
|
|
||||||
formData.append("pageNumbers", parameters.pages);
|
|
||||||
endpoint = "/api/v1/general/split-pages";
|
|
||||||
break;
|
|
||||||
case SPLIT_MODES.BY_SECTIONS:
|
|
||||||
formData.append("horizontalDivisions", parameters.hDiv);
|
|
||||||
formData.append("verticalDivisions", parameters.vDiv);
|
|
||||||
formData.append("merge", parameters.merge.toString());
|
|
||||||
endpoint = "/api/v1/general/split-pdf-by-sections";
|
|
||||||
break;
|
|
||||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
|
||||||
formData.append(
|
|
||||||
"splitType",
|
|
||||||
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
|
||||||
);
|
|
||||||
formData.append("splitValue", parameters.splitValue);
|
|
||||||
endpoint = "/api/v1/general/split-by-size-or-count";
|
|
||||||
break;
|
|
||||||
case SPLIT_MODES.BY_CHAPTERS:
|
|
||||||
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
|
||||||
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
|
||||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
|
||||||
endpoint = "/api/v1/general/split-pdf-by-chapters";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown split mode: ${mode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { formData, endpoint };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createOperation = useCallback((
|
|
||||||
mode: SplitMode | '',
|
|
||||||
parameters: SplitParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
|
||||||
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles[0].name;
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'split',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0].name,
|
|
||||||
parameters: {
|
|
||||||
mode,
|
|
||||||
pages: mode === SPLIT_MODES.BY_PAGES ? parameters.pages : undefined,
|
|
||||||
hDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.hDiv : undefined,
|
|
||||||
vDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.vDiv : undefined,
|
|
||||||
merge: mode === SPLIT_MODES.BY_SECTIONS ? parameters.merge : undefined,
|
|
||||||
splitType: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitType : undefined,
|
|
||||||
splitValue: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitValue : undefined,
|
|
||||||
bookmarkLevel: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.bookmarkLevel : undefined,
|
|
||||||
includeMetadata: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.includeMetadata : undefined,
|
|
||||||
allowDuplicates: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.allowDuplicates : undefined,
|
|
||||||
},
|
|
||||||
fileSize: selectedFiles[0].size
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
if (params.mode === SPLIT_MODES.BY_PAGES && !params.pages) {
|
||||||
}, []);
|
return { valid: false, errors: [t('split.validation.pagesRequired', 'Page numbers are required for split by pages')] };
|
||||||
|
|
||||||
const processResults = useCallback(async (blob: Blob) => {
|
|
||||||
try {
|
|
||||||
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
|
|
||||||
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
|
||||||
|
|
||||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
|
||||||
// Set local state for preview
|
|
||||||
setFiles(extractionResult.extractedFiles);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
// Add extracted files to FileContext for future use
|
|
||||||
await addFiles(extractionResult.extractedFiles);
|
|
||||||
|
|
||||||
const thumbnails = await Promise.all(
|
|
||||||
extractionResult.extractedFiles.map(async (file) => {
|
|
||||||
try {
|
|
||||||
return await generateThumbnailForFile(file);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setThumbnails(thumbnails);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
}
|
}
|
||||||
} catch (extractError) {
|
|
||||||
console.warn('Failed to extract files for preview:', extractError);
|
|
||||||
}
|
|
||||||
}, [addFiles]);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
return { valid: true };
|
||||||
mode: SplitMode | '',
|
},
|
||||||
parameters: SplitParameters,
|
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
|
||||||
selectedFiles: File[]
|
});
|
||||||
) => {
|
};
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(mode, parameters, selectedFiles);
|
|
||||||
const { formData, endpoint } = buildFormData(mode, parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
const blob = new Blob([response.data], { type: "application/zip" });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
|
|
||||||
await processResults(blob);
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
|
||||||
errorMsg = error.response.data;
|
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
setStatus(t("error._value", "Split failed."));
|
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -3,9 +3,7 @@ import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } f
|
|||||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||||
|
|
||||||
export interface SplitParametersHook {
|
export interface SplitParametersHook {
|
||||||
mode: SplitMode | '';
|
|
||||||
parameters: SplitParameters;
|
parameters: SplitParameters;
|
||||||
setMode: (mode: SplitMode | '') => void;
|
|
||||||
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||||
resetParameters: () => void;
|
resetParameters: () => void;
|
||||||
validateParameters: () => boolean;
|
validateParameters: () => boolean;
|
||||||
@ -13,6 +11,7 @@ export interface SplitParametersHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialParameters: SplitParameters = {
|
const initialParameters: SplitParameters = {
|
||||||
|
mode: '',
|
||||||
pages: '',
|
pages: '',
|
||||||
hDiv: '2',
|
hDiv: '2',
|
||||||
vDiv: '2',
|
vDiv: '2',
|
||||||
@ -25,7 +24,6 @@ const initialParameters: SplitParameters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useSplitParameters = (): SplitParametersHook => {
|
export const useSplitParameters = (): SplitParametersHook => {
|
||||||
const [mode, setMode] = useState<SplitMode | ''>('');
|
|
||||||
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
||||||
|
|
||||||
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
||||||
@ -34,13 +32,12 @@ export const useSplitParameters = (): SplitParametersHook => {
|
|||||||
|
|
||||||
const resetParameters = () => {
|
const resetParameters = () => {
|
||||||
setParameters(initialParameters);
|
setParameters(initialParameters);
|
||||||
setMode('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateParameters = () => {
|
const validateParameters = () => {
|
||||||
if (!mode) return false;
|
if (!parameters.mode) return false;
|
||||||
|
|
||||||
switch (mode) {
|
switch (parameters.mode) {
|
||||||
case SPLIT_MODES.BY_PAGES:
|
case SPLIT_MODES.BY_PAGES:
|
||||||
return parameters.pages.trim() !== "";
|
return parameters.pages.trim() !== "";
|
||||||
case SPLIT_MODES.BY_SECTIONS:
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
@ -55,14 +52,12 @@ export const useSplitParameters = (): SplitParametersHook => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getEndpointName = () => {
|
const getEndpointName = () => {
|
||||||
if (!mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
if (!parameters.mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
||||||
return ENDPOINTS[mode as SplitMode];
|
return ENDPOINTS[parameters.mode as SplitMode];
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
|
||||||
parameters,
|
parameters,
|
||||||
setMode,
|
|
||||||
updateParameter,
|
updateParameter,
|
||||||
resetParameters,
|
resetParameters,
|
||||||
validateParameters,
|
validateParameters,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileWithUrl } from '../types/file';
|
||||||
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -63,7 +64,12 @@ export const useFileManager = () => {
|
|||||||
|
|
||||||
const storeFile = useCallback(async (file: File) => {
|
const storeFile = useCallback(async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const storedFile = await fileStorage.storeFile(file);
|
// Generate thumbnail for the file
|
||||||
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
|
|
||||||
|
// Store file with thumbnail
|
||||||
|
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||||
|
|
||||||
// Add the ID to the file object
|
// Add the ID to the file object
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
return storedFile;
|
return storedFile;
|
||||||
@ -111,12 +117,21 @@ export const useFileManager = () => {
|
|||||||
};
|
};
|
||||||
}, [convertToFile]);
|
}, [convertToFile]);
|
||||||
|
|
||||||
|
const touchFile = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fileStorage.touchFile(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to touch file:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
convertToFile,
|
convertToFile,
|
||||||
loadRecentFiles,
|
loadRecentFiles,
|
||||||
handleRemoveFile,
|
handleRemoveFile,
|
||||||
storeFile,
|
storeFile,
|
||||||
|
touchFile,
|
||||||
createFileSelectionHandlers
|
createFileSelectionHandlers
|
||||||
};
|
};
|
||||||
};
|
};
|
@ -1,57 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface UseFilesModalReturn {
|
|
||||||
isFilesModalOpen: boolean;
|
|
||||||
openFilesModal: () => void;
|
|
||||||
closeFilesModal: () => void;
|
|
||||||
onFileSelect?: (file: File) => void;
|
|
||||||
onFilesSelect?: (files: File[]) => void;
|
|
||||||
onModalClose?: () => void;
|
|
||||||
setOnModalClose: (callback: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFilesModalProps {
|
|
||||||
onFileSelect?: (file: File) => void;
|
|
||||||
onFilesSelect?: (files: File[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFilesModal = ({
|
|
||||||
onFileSelect,
|
|
||||||
onFilesSelect
|
|
||||||
}: UseFilesModalProps = {}): UseFilesModalReturn => {
|
|
||||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
|
||||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
|
||||||
|
|
||||||
const openFilesModal = useCallback(() => {
|
|
||||||
setIsFilesModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeFilesModal = useCallback(() => {
|
|
||||||
setIsFilesModalOpen(false);
|
|
||||||
onModalClose?.();
|
|
||||||
}, [onModalClose]);
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: File) => {
|
|
||||||
onFileSelect?.(file);
|
|
||||||
closeFilesModal();
|
|
||||||
}, [onFileSelect, closeFilesModal]);
|
|
||||||
|
|
||||||
const handleFilesSelect = useCallback((files: File[]) => {
|
|
||||||
onFilesSelect?.(files);
|
|
||||||
closeFilesModal();
|
|
||||||
}, [onFilesSelect, closeFilesModal]);
|
|
||||||
|
|
||||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
|
||||||
setOnModalClose(() => callback);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFilesModalOpen,
|
|
||||||
openFilesModal,
|
|
||||||
closeFilesModal,
|
|
||||||
onFileSelect: handleFileSelect,
|
|
||||||
onFilesSelect: handleFilesSelect,
|
|
||||||
onModalClose,
|
|
||||||
setOnModalClose: setModalCloseCallback,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,6 +1,22 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getDocument } from "pdfjs-dist";
|
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
|
import { fileStorage } from "../services/fileStorage";
|
||||||
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal scale for thumbnail generation
|
||||||
|
* Ensures high quality while preventing oversized renders
|
||||||
|
*/
|
||||||
|
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||||
|
const maxWidth = 400; // Max thumbnail width
|
||||||
|
const maxHeight = 600; // Max thumbnail height
|
||||||
|
|
||||||
|
const scaleX = maxWidth / pageViewport.width;
|
||||||
|
const scaleY = maxHeight / pageViewport.height;
|
||||||
|
|
||||||
|
// Don't upscale, only downscale if needed
|
||||||
|
return Math.min(scaleX, scaleY, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for IndexedDB-aware thumbnail loading
|
* Hook for IndexedDB-aware thumbnail loading
|
||||||
@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: for IndexedDB files without stored thumbnails, just use placeholder
|
// Second priority: generate thumbnail for any file type
|
||||||
if (file.storedInIndexedDB && file.id) {
|
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||||
// 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);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
let fileObject: File;
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
|
||||||
const page = await pdf.getPage(1);
|
// Handle IndexedDB files vs regular File objects
|
||||||
const viewport = page.getViewport({ scale: 0.2 });
|
if (file.storedInIndexedDB && file.id) {
|
||||||
const canvas = document.createElement("canvas");
|
// For IndexedDB files, recreate File object from stored data
|
||||||
canvas.width = viewport.width;
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
canvas.height = viewport.height;
|
if (!storedFile) {
|
||||||
const context = canvas.getContext("2d");
|
throw new Error('File not found in IndexedDB');
|
||||||
if (context && !cancelled) {
|
}
|
||||||
await page.render({ canvasContext: context, viewport }).promise;
|
fileObject = new File([storedFile.data], storedFile.name, {
|
||||||
if (!cancelled) setThumb(canvas.toDataURL());
|
type: storedFile.type,
|
||||||
|
lastModified: storedFile.lastModified
|
||||||
|
});
|
||||||
|
} else if (file.file) {
|
||||||
|
// For FileWithUrl objects that have a File object
|
||||||
|
fileObject = file.file;
|
||||||
|
} else if (file.id) {
|
||||||
|
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
||||||
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
|
if (!storedFile) {
|
||||||
|
throw new Error('File not found in IndexedDB and no File object available');
|
||||||
|
}
|
||||||
|
fileObject = new File([storedFile.data], storedFile.name, {
|
||||||
|
type: storedFile.type,
|
||||||
|
lastModified: storedFile.lastModified
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('File object not available and no ID for IndexedDB lookup');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the universal thumbnail generator
|
||||||
|
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||||
|
if (!cancelled && thumbnail) {
|
||||||
|
setThumb(thumbnail);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setThumb(null);
|
||||||
}
|
}
|
||||||
pdf.destroy(); // Clean up memory
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to generate thumbnail for regular file', file.name, error);
|
console.warn('Failed to generate thumbnail for file', file.name, error);
|
||||||
if (!cancelled) setThumb(null);
|
if (!cancelled) setThumb(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setGenerating(false);
|
if (!cancelled) setGenerating(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Large files or files without proper conditions - show placeholder
|
// Large files - generate placeholder
|
||||||
setThumb(null);
|
setThumb(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,82 +1,7 @@
|
|||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { baseToolRegistry, toolEndpoints, type ToolRegistryEntry } from "../data/toolRegistry";
|
import { baseToolRegistry, toolEndpoints, type ToolRegistryEntry } from "../data/toolRegistry";
|
||||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
|
||||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
|
||||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
|
||||||
import ApiIcon from "@mui/icons-material/Api";
|
|
||||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||||
import { ToolDefinition, ToolRegistry, Tool } from "../types/tool";
|
|
||||||
|
|
||||||
// Add entry here with maxFiles, endpoints, and lazy component
|
|
||||||
const availableToolRegistry: Record<string, ToolDefinition> = {
|
|
||||||
split: {
|
|
||||||
id: "split",
|
|
||||||
icon: <ContentCutIcon />,
|
|
||||||
component: React.lazy(() => import("../tools/Split")),
|
|
||||||
maxFiles: 1,
|
|
||||||
category: "manipulation",
|
|
||||||
description: "Split PDF files into smaller parts",
|
|
||||||
endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
|
|
||||||
},
|
|
||||||
compress: {
|
|
||||||
id: "compress",
|
|
||||||
icon: <ZoomInMapIcon />,
|
|
||||||
component: React.lazy(() => import("../tools/Compress")),
|
|
||||||
maxFiles: -1,
|
|
||||||
category: "optimization",
|
|
||||||
description: "Reduce PDF file size",
|
|
||||||
endpoints: ["compress-pdf"]
|
|
||||||
},
|
|
||||||
convert: {
|
|
||||||
id: "convert",
|
|
||||||
icon: <SwapHorizIcon />,
|
|
||||||
component: React.lazy(() => import("../tools/Convert")),
|
|
||||||
maxFiles: -1,
|
|
||||||
category: "manipulation",
|
|
||||||
description: "Change to and from PDF and other formats",
|
|
||||||
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
|
|
||||||
supportedFormats: [
|
|
||||||
// Microsoft Office
|
|
||||||
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
|
|
||||||
// OpenDocument
|
|
||||||
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
|
|
||||||
// Text formats
|
|
||||||
"txt", "text", "xml", "rtf", "html", "lwp", "md",
|
|
||||||
// Images
|
|
||||||
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
|
|
||||||
// StarOffice
|
|
||||||
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
|
|
||||||
// Email formats
|
|
||||||
"eml",
|
|
||||||
// Archive formats
|
|
||||||
"zip",
|
|
||||||
// Other
|
|
||||||
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
swagger: {
|
|
||||||
id: "swagger",
|
|
||||||
icon: <ApiIcon />,
|
|
||||||
component: React.lazy(() => import("../tools/SwaggerUI")),
|
|
||||||
maxFiles: 0,
|
|
||||||
category: "utility",
|
|
||||||
description: "Open API documentation",
|
|
||||||
endpoints: ["swagger-ui"]
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
id: "ocr",
|
|
||||||
icon: <span className="material-symbols-rounded font-size-20">
|
|
||||||
quick_reference_all
|
|
||||||
</span>,
|
|
||||||
component: React.lazy(() => import("../tools/OCR")),
|
|
||||||
maxFiles: -1,
|
|
||||||
category: "utility",
|
|
||||||
description: "Extract text from images using OCR",
|
|
||||||
endpoints: ["ocr-pdf"]
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ToolManagementResult {
|
interface ToolManagementResult {
|
||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
|
@ -1,58 +1,26 @@
|
|||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
|
||||||
|
import { Group } from "@mantine/core";
|
||||||
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
||||||
import { useToolManagement } from "../hooks/useToolManagement";
|
|
||||||
import { useFileHandler } from "../hooks/useFileHandler";
|
|
||||||
import { Group, Box, Button } from "@mantine/core";
|
|
||||||
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
|
||||||
import { PageEditorFunctions } from "../types/pageEditor";
|
|
||||||
import rainbowStyles from '../styles/rainbow.module.css';
|
|
||||||
|
|
||||||
import ToolPicker from "../components/tools/ToolPicker";
|
import ToolPanel from "../components/tools/ToolPanel";
|
||||||
import ToolSearch from "../components/tools/toolPicker/ToolSearch";
|
import Workbench from "../components/layout/Workbench";
|
||||||
import TopControls from "../components/shared/TopControls";
|
|
||||||
import FileEditor from "../components/fileEditor/FileEditor";
|
|
||||||
import PageEditor from "../components/pageEditor/PageEditor";
|
|
||||||
import PageEditorControls from "../components/pageEditor/PageEditorControls";
|
|
||||||
import Viewer from "../components/viewer/Viewer";
|
|
||||||
import ToolRenderer from "../components/tools/ToolRenderer";
|
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
import LandingPage from "../components/shared/LandingPage";
|
import FileManager from "../components/FileManager";
|
||||||
import FileUploadModal from "../components/shared/FileUploadModal";
|
|
||||||
|
|
||||||
|
|
||||||
function HomePageContent() {
|
function HomePageContent() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
|
||||||
const {
|
|
||||||
sidebarState,
|
|
||||||
sidebarRefs,
|
|
||||||
setSidebarsVisible,
|
|
||||||
setLeftPanelView,
|
|
||||||
setReaderMode
|
|
||||||
} = useSidebarContext();
|
|
||||||
|
|
||||||
const { sidebarsVisible, leftPanelView, readerMode } = sidebarState;
|
|
||||||
const { quickAccessRef, toolPanelRef } = sidebarRefs;
|
|
||||||
|
|
||||||
const fileContext = useFileContext();
|
|
||||||
const { activeFiles, currentView, setCurrentView } = fileContext;
|
|
||||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
|
||||||
const { addToActiveFiles } = useFileHandler();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedToolKey,
|
sidebarRefs,
|
||||||
selectedTool,
|
} = useSidebarContext();
|
||||||
toolRegistry,
|
|
||||||
selectTool,
|
|
||||||
clearToolSelection,
|
|
||||||
} = useToolManagement();
|
|
||||||
|
|
||||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
|
const { quickAccessRef } = sidebarRefs;
|
||||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
|
||||||
const [toolSearch, setToolSearch] = useState("");
|
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
||||||
|
|
||||||
|
const { selectedTool } = useToolSelection();
|
||||||
|
|
||||||
// Update file selection context when tool changes
|
// Update file selection context when tool changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -66,250 +34,30 @@ function HomePageContent() {
|
|||||||
}
|
}
|
||||||
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
|
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleToolSelect = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
selectTool(id);
|
|
||||||
setCurrentView('fileEditor'); // Tools use fileEditor view for file selection
|
|
||||||
setLeftPanelView('toolContent');
|
|
||||||
setReaderMode(false);
|
|
||||||
},
|
|
||||||
[selectTool, setCurrentView]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleQuickAccessTools = useCallback(() => {
|
|
||||||
setLeftPanelView('toolPicker');
|
|
||||||
setReaderMode(false);
|
|
||||||
clearToolSelection();
|
|
||||||
}, [clearToolSelection]);
|
|
||||||
|
|
||||||
const handleReaderToggle = useCallback(() => {
|
|
||||||
setReaderMode(true);
|
|
||||||
}, [readerMode]);
|
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
|
||||||
setCurrentView(view as any);
|
|
||||||
}, [setCurrentView]);
|
|
||||||
|
|
||||||
const handleToolSearchSelect = useCallback((toolId: string) => {
|
|
||||||
selectTool(toolId);
|
|
||||||
setCurrentView('fileEditor');
|
|
||||||
setLeftPanelView('toolContent');
|
|
||||||
setReaderMode(false);
|
|
||||||
setToolSearch(''); // Clear search after selection
|
|
||||||
}, [selectTool, setCurrentView]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
gap={0}
|
gap={0}
|
||||||
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
||||||
>
|
>
|
||||||
{/* Quick Access Bar */}
|
|
||||||
<QuickAccessBar
|
<QuickAccessBar
|
||||||
ref={quickAccessRef}
|
ref={quickAccessRef} />
|
||||||
onToolsClick={handleQuickAccessTools}
|
<ToolPanel />
|
||||||
onReaderToggle={handleReaderToggle}
|
<Workbench />
|
||||||
/>
|
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||||
|
|
||||||
{/* Left: Tool Picker or Selected Tool Panel */}
|
|
||||||
<div
|
|
||||||
ref={toolPanelRef}
|
|
||||||
data-sidebar="tool-panel"
|
|
||||||
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
|
||||||
style={{
|
|
||||||
width: sidebarsVisible && !readerMode ? '280px' : '0',
|
|
||||||
backgroundColor: 'var(--bg-toolbar)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: sidebarsVisible && !readerMode ? 1 : 0,
|
|
||||||
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leftPanelView === 'toolPicker' ? (
|
|
||||||
// Tool Picker View
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<ToolPicker
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
onSelect={handleToolSelect}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Selected Tool Content View
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Search bar for quick tool switching */}
|
|
||||||
<div className="mb-4 border-b-1 border-b-[var(--border-default)] mb-4" >
|
|
||||||
<ToolSearch
|
|
||||||
value={toolSearch}
|
|
||||||
onChange={setToolSearch}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
onToolSelect={handleToolSearchSelect}
|
|
||||||
mode="dropdown"
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back button */}
|
|
||||||
<div className="mb-4" style={{ padding: '0 1rem', marginTop: '1rem'}}>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleQuickAccessTools}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
← {t("fileUpload.backToTools", "Back to Tools")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool title */}
|
|
||||||
<div className="mb-4" style={{ marginLeft: '1rem' }}>
|
|
||||||
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool content */}
|
|
||||||
<div className="flex-1 min-h-0" style={{ padding: '0 1rem' }}>
|
|
||||||
<ToolRenderer
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
onPreviewFile={setPreviewFile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main View */}
|
|
||||||
<Box
|
|
||||||
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
|
||||||
style={
|
|
||||||
isRainbowMode
|
|
||||||
? {} // No background color in rainbow mode
|
|
||||||
: { backgroundColor: 'var(--bg-background)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Top Controls */}
|
|
||||||
<TopControls
|
|
||||||
currentView={currentView}
|
|
||||||
setCurrentView={handleViewChange}
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
/>
|
|
||||||
{/* Main content area */}
|
|
||||||
<Box
|
|
||||||
className="flex-1 min-h-0 relative z-10"
|
|
||||||
style={{
|
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!activeFiles[0] ? (
|
|
||||||
<LandingPage
|
|
||||||
title={currentView === "viewer"
|
|
||||||
? t("fileUpload.selectPdfToView", "Select a PDF to view")
|
|
||||||
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : currentView === "fileEditor" ? (
|
|
||||||
<FileEditor
|
|
||||||
toolMode={!!selectedToolKey}
|
|
||||||
showUpload={true}
|
|
||||||
showBulkActions={!selectedToolKey}
|
|
||||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
|
||||||
{...(!selectedToolKey && {
|
|
||||||
onOpenPageEditor: (file) => {
|
|
||||||
handleViewChange("pageEditor");
|
|
||||||
},
|
|
||||||
onMergeFiles: (filesToMerge) => {
|
|
||||||
filesToMerge.forEach(addToActiveFiles);
|
|
||||||
handleViewChange("viewer");
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : currentView === "viewer" ? (
|
|
||||||
<Viewer
|
|
||||||
sidebarsVisible={sidebarsVisible}
|
|
||||||
setSidebarsVisible={setSidebarsVisible}
|
|
||||||
previewFile={previewFile}
|
|
||||||
{...(previewFile && {
|
|
||||||
onClose: () => {
|
|
||||||
setPreviewFile(null); // Clear preview file
|
|
||||||
const previousMode = sessionStorage.getItem('previousMode');
|
|
||||||
if (previousMode === 'split') {
|
|
||||||
selectTool('split');
|
|
||||||
setCurrentView('split');
|
|
||||||
setLeftPanelView('toolContent');
|
|
||||||
sessionStorage.removeItem('previousMode');
|
|
||||||
} else if (previousMode === 'compress') {
|
|
||||||
selectTool('compress');
|
|
||||||
setCurrentView('compress');
|
|
||||||
setLeftPanelView('toolContent');
|
|
||||||
sessionStorage.removeItem('previousMode');
|
|
||||||
} else if (previousMode === 'convert') {
|
|
||||||
selectTool('convert');
|
|
||||||
setCurrentView('convert');
|
|
||||||
setLeftPanelView('toolContent');
|
|
||||||
sessionStorage.removeItem('previousMode');
|
|
||||||
} else {
|
|
||||||
setCurrentView('fileEditor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : currentView === "pageEditor" ? (
|
|
||||||
<>
|
|
||||||
<PageEditor
|
|
||||||
onFunctionsReady={setPageEditorFunctions}
|
|
||||||
/>
|
|
||||||
{pageEditorFunctions && (
|
|
||||||
<PageEditorControls
|
|
||||||
onClosePdf={pageEditorFunctions.closePdf}
|
|
||||||
onUndo={pageEditorFunctions.handleUndo}
|
|
||||||
onRedo={pageEditorFunctions.handleRedo}
|
|
||||||
canUndo={pageEditorFunctions.canUndo}
|
|
||||||
canRedo={pageEditorFunctions.canRedo}
|
|
||||||
onRotate={pageEditorFunctions.handleRotate}
|
|
||||||
onDelete={pageEditorFunctions.handleDelete}
|
|
||||||
onSplit={pageEditorFunctions.handleSplit}
|
|
||||||
onExportSelected={pageEditorFunctions.onExportSelected}
|
|
||||||
onExportAll={pageEditorFunctions.onExportAll}
|
|
||||||
exportLoading={pageEditorFunctions.exportLoading}
|
|
||||||
selectionMode={pageEditorFunctions.selectionMode}
|
|
||||||
selectedPages={pageEditorFunctions.selectedPages}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : selectedToolKey && selectedTool ? (
|
|
||||||
// Fallback: if tool is selected but not in fileEditor view, show tool in main area
|
|
||||||
<ToolRenderer
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LandingPage
|
|
||||||
title="File Management"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Global Modals */}
|
|
||||||
<FileUploadModal selectedTool={selectedTool} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main HomePage component wrapped with FileSelectionProvider
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { setCurrentView } = useFileContext();
|
||||||
return (
|
return (
|
||||||
<FileSelectionProvider>
|
<FileSelectionProvider>
|
||||||
<SidebarProvider>
|
<ToolWorkflowProvider onViewChange={setCurrentView as any /* FIX ME */}>
|
||||||
<HomePageContent />
|
<SidebarProvider>
|
||||||
</SidebarProvider>
|
<HomePageContent />
|
||||||
|
</SidebarProvider>
|
||||||
|
</ToolWorkflowProvider>
|
||||||
</FileSelectionProvider>
|
</FileSelectionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -28,8 +28,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
thumbnailQuality: 'medium',
|
thumbnailQuality: 'medium',
|
||||||
priorityPageCount: 10,
|
priorityPageCount: 10,
|
||||||
useWebWorker: false,
|
useWebWorker: false,
|
||||||
maxRetries: 3,
|
maxRetries: 3
|
||||||
timeoutMs: 300000 // 5 minutes
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
@ -87,7 +86,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
estimatedTime: number
|
estimatedTime: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Create cancellation token
|
// Create cancellation token
|
||||||
const cancellationToken = ProcessingErrorHandler.createTimeoutController(config.timeoutMs);
|
const cancellationToken = new AbortController();
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
const state: ProcessingState = {
|
const state: ProcessingState = {
|
||||||
|
@ -225,6 +225,32 @@ class FileStorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the lastModified timestamp of a file (for most recently used sorting)
|
||||||
|
*/
|
||||||
|
async touchFile(id: string): Promise<boolean> {
|
||||||
|
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 getRequest = store.get(id);
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
const file = getRequest.result;
|
||||||
|
if (file) {
|
||||||
|
// Update lastModified to current timestamp
|
||||||
|
file.lastModified = Date.now();
|
||||||
|
const updateRequest = store.put(file);
|
||||||
|
updateRequest.onsuccess = () => resolve(true);
|
||||||
|
updateRequest.onerror = () => reject(updateRequest.error);
|
||||||
|
} else {
|
||||||
|
resolve(false); // File not found
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getRequest.onerror = () => reject(getRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all stored files
|
* Clear all stored files
|
||||||
*/
|
*/
|
||||||
|
@ -73,7 +73,10 @@
|
|||||||
--bg-raised: #f9fafb;
|
--bg-raised: #f9fafb;
|
||||||
--bg-muted: #f3f4f6;
|
--bg-muted: #f3f4f6;
|
||||||
--bg-background: #f9fafb;
|
--bg-background: #f9fafb;
|
||||||
--bg-toolbar: #FFFFFF;
|
--bg-toolbar: #ffffff;
|
||||||
|
--bg-file-manager: #F5F6F8;
|
||||||
|
--bg-file-list: #ffffff;
|
||||||
|
--btn-open-file: #0A8BFF;
|
||||||
--text-primary: #111827;
|
--text-primary: #111827;
|
||||||
--text-secondary: #4b5563;
|
--text-secondary: #4b5563;
|
||||||
--text-muted: #6b7280;
|
--text-muted: #6b7280;
|
||||||
@ -187,6 +190,9 @@
|
|||||||
--bg-muted: #1F2329;
|
--bg-muted: #1F2329;
|
||||||
--bg-background: #2A2F36;
|
--bg-background: #2A2F36;
|
||||||
--bg-toolbar: #1F2329;
|
--bg-toolbar: #1F2329;
|
||||||
|
--bg-file-manager: #1F2329;
|
||||||
|
--bg-file-list: #2A2F36;
|
||||||
|
--btn-open-file: #0A8BFF;
|
||||||
--text-primary: #f9fafb;
|
--text-primary: #f9fafb;
|
||||||
--text-secondary: #d1d5db;
|
--text-secondary: #d1d5db;
|
||||||
--text-muted: #9ca3af;
|
--text-muted: #9ca3af;
|
||||||
|
@ -23,13 +23,31 @@ import axios from 'axios';
|
|||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockedAxios = vi.mocked(axios);
|
||||||
|
|
||||||
// Mock utility modules
|
// Mock only essential services that are actually called by the tests
|
||||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
vi.mock('../../services/fileStorage', () => ({
|
||||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
fileStorage: {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: `mock-id-${file.name}`,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../utils/api', () => ({
|
vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||||
makeApiUrl: vi.fn((path: string) => `/api/v1${path}`)
|
thumbnailGenerationService: {
|
||||||
|
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
destroy: vi.fn()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create realistic test files
|
// Create realistic test files
|
||||||
@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should correctly map image conversion parameters to API call', async () => {
|
test('should correctly map image conversion parameters to API call', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/jpeg',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.jpg"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
@ -389,7 +414,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
expect(result.current.status).toContain('noFileSelected');
|
expect(result.current.errorMessage).toContain('noFileSelected');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should record operation in FileContext', async () => {
|
test('should record operation in FileContext', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.png"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
test('should clean up blob URLs on reset', async () => {
|
test('should clean up blob URLs on reset', async () => {
|
||||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
mockedAxios.post.mockResolvedValueOnce({
|
||||||
|
data: mockBlob,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'content-disposition': 'attachment; filename="test_converted.png"'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useConvertOperation(), {
|
const { result } = renderHook(() => useConvertOperation(), {
|
||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
|
@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils';
|
|||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockedAxios = vi.mocked(axios);
|
||||||
|
|
||||||
// Mock utility modules
|
// Mock only essential services that are actually called by the tests
|
||||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
vi.mock('../../services/fileStorage', () => ({
|
||||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
fileStorage: {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: `mock-id-${file.name}`,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||||
|
thumbnailGenerationService: {
|
||||||
|
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
destroy: vi.fn()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
@ -33,12 +33,11 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
|
}, [splitParams.parameters, selectedFiles]);
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
try {
|
try {
|
||||||
await splitOperation.executeOperation(
|
await splitOperation.executeOperation(
|
||||||
splitParams.mode,
|
|
||||||
splitParams.parameters,
|
splitParams.parameters,
|
||||||
selectedFiles
|
selectedFiles
|
||||||
);
|
);
|
||||||
@ -105,14 +104,12 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SplitSettings
|
<SplitSettings
|
||||||
mode={splitParams.mode}
|
|
||||||
onModeChange={splitParams.setMode}
|
|
||||||
parameters={splitParams.parameters}
|
parameters={splitParams.parameters}
|
||||||
onParameterChange={splitParams.updateParameter}
|
onParameterChange={splitParams.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{splitParams.mode && (
|
{splitParams.parameters.mode && (
|
||||||
<OperationButton
|
<OperationButton
|
||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
isLoading={splitOperation.isLoading}
|
isLoading={splitOperation.isLoading}
|
||||||
|
@ -69,7 +69,6 @@ export interface ProcessingConfig {
|
|||||||
priorityPageCount: number; // Number of priority pages to process first
|
priorityPageCount: number; // Number of priority pages to process first
|
||||||
useWebWorker: boolean;
|
useWebWorker: boolean;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
timeoutMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileAnalysis {
|
export interface FileAnalysis {
|
||||||
|
@ -28,12 +28,6 @@ export interface SidebarProviderProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuickAccessBar related interfaces
|
|
||||||
export interface QuickAccessBarProps {
|
|
||||||
onToolsClick: () => void;
|
|
||||||
onReaderToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ButtonConfig {
|
export interface ButtonConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
import { StoredFile, fileStorage } from "../services/fileStorage";
|
import { StoredFile, fileStorage } from "../services/fileStorage";
|
||||||
|
|
||||||
export function getFileId(file: File): string {
|
export function getFileId(file: File): string | null {
|
||||||
return (file as File & { id?: string }).id || file.name;
|
return (file as File & { id?: string }).id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -15,19 +15,172 @@ export function calculateScaleFromFileSize(fileSize: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate thumbnail for a PDF file during upload
|
* Generate modern placeholder thumbnail with file extension
|
||||||
|
*/
|
||||||
|
function generatePlaceholderThumbnail(file: File): string {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 120;
|
||||||
|
canvas.height = 150;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Get file extension for color theming
|
||||||
|
const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE';
|
||||||
|
const colorScheme = getFileTypeColorScheme(extension);
|
||||||
|
|
||||||
|
// Create gradient background
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||||
|
gradient.addColorStop(0, colorScheme.bgTop);
|
||||||
|
gradient.addColorStop(1, colorScheme.bgBottom);
|
||||||
|
|
||||||
|
// Rounded rectangle background
|
||||||
|
drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Subtle shadow/border
|
||||||
|
ctx.strokeStyle = colorScheme.border;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Modern document icon
|
||||||
|
drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon);
|
||||||
|
|
||||||
|
// Extension badge
|
||||||
|
drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme);
|
||||||
|
|
||||||
|
// File size with subtle styling
|
||||||
|
const sizeText = formatFileSize(file.size);
|
||||||
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
ctx.fillStyle = colorScheme.textSecondary;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15);
|
||||||
|
|
||||||
|
return canvas.toDataURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color scheme based on file extension
|
||||||
|
*/
|
||||||
|
function getFileTypeColorScheme(extension: string) {
|
||||||
|
const schemes: Record<string, any> = {
|
||||||
|
// Documents
|
||||||
|
'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Spreadsheets
|
||||||
|
'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Presentations
|
||||||
|
'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Default
|
||||||
|
'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return schemes[extension] || schemes['DEFAULT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw rounded rectangle
|
||||||
|
*/
|
||||||
|
function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw modern document icon
|
||||||
|
*/
|
||||||
|
function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) {
|
||||||
|
const size = 24;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
// Document body
|
||||||
|
drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Folded corner
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX + size/2 - 6, centerY - size/2);
|
||||||
|
ctx.lineTo(centerX + size/2, centerY - size/2 + 6);
|
||||||
|
ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#FFFFFF40';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw extension badge
|
||||||
|
*/
|
||||||
|
function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) {
|
||||||
|
const badgeWidth = Math.max(extension.length * 8 + 16, 40);
|
||||||
|
const badgeHeight = 22;
|
||||||
|
|
||||||
|
// Badge background
|
||||||
|
drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11);
|
||||||
|
ctx.fillStyle = colorScheme.badge;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Badge text
|
||||||
|
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
ctx.fillStyle = colorScheme.textPrimary;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(extension, centerX, centerY + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display
|
||||||
|
*/
|
||||||
|
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(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnail for any file type
|
||||||
* Returns base64 data URL or undefined if generation fails
|
* Returns base64 data URL or undefined if generation fails
|
||||||
*/
|
*/
|
||||||
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
||||||
// Skip thumbnail generation for large files to avoid memory issues
|
// Skip thumbnail generation for very large files to avoid memory issues
|
||||||
if (file.size >= 50 * 1024 * 1024) { // 50MB limit
|
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
|
||||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
console.log('Skipping thumbnail generation for large file:', file.name);
|
||||||
return undefined;
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle image files - use original file directly
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
return URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle PDF files
|
||||||
if (!file.type.startsWith('application/pdf')) {
|
if (!file.type.startsWith('application/pdf')) {
|
||||||
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
|
console.log('File is not a PDF or image, generating placeholder:', file.name);
|
||||||
return undefined;
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
33
frontend/src/utils/toolErrorHandler.ts
Normal file
33
frontend/src/utils/toolErrorHandler.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Standardized error handling utilities for tool operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default error extractor that follows the standard pattern
|
||||||
|
*/
|
||||||
|
export const extractErrorMessage = (error: any): string => {
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return 'Operation failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standardized error handler for tool operations
|
||||||
|
* @param fallbackMessage - Message to show when no specific error can be extracted
|
||||||
|
* @returns Error handler function that follows the standard pattern
|
||||||
|
*/
|
||||||
|
export const createStandardErrorHandler = (fallbackMessage: string) => {
|
||||||
|
return (error: any): string => {
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return fallbackMessage;
|
||||||
|
};
|
||||||
|
};
|
28
frontend/src/utils/toolOperationTracker.ts
Normal file
28
frontend/src/utils/toolOperationTracker.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { FileOperation } from '../types/fileContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates operation tracking data for FileContext integration
|
||||||
|
*/
|
||||||
|
export const createOperation = <TParams = void>(
|
||||||
|
operationType: string,
|
||||||
|
params: TParams,
|
||||||
|
selectedFiles: File[]
|
||||||
|
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||||
|
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||||
|
|
||||||
|
const operation: FileOperation = {
|
||||||
|
id: operationId,
|
||||||
|
type: operationType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fileIds: selectedFiles.map(f => f.name),
|
||||||
|
status: 'pending',
|
||||||
|
metadata: {
|
||||||
|
originalFileName: selectedFiles[0]?.name,
|
||||||
|
parameters: params,
|
||||||
|
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { operation, operationId, fileId };
|
||||||
|
};
|
25
frontend/src/utils/toolResponseProcessor.ts
Normal file
25
frontend/src/utils/toolResponseProcessor.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// Note: This utility should be used with useToolResources for ZIP operations
|
||||||
|
|
||||||
|
export type ResponseHandler = (blob: Blob, originalFiles: File[]) => Promise<File[]> | File[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a blob response into File(s).
|
||||||
|
* - If a tool-specific responseHandler is provided, it is used.
|
||||||
|
* - Otherwise, create a single file using the filePrefix + original name.
|
||||||
|
*/
|
||||||
|
export async function processResponse(
|
||||||
|
blob: Blob,
|
||||||
|
originalFiles: File[],
|
||||||
|
filePrefix: string,
|
||||||
|
responseHandler?: ResponseHandler
|
||||||
|
): Promise<File[]> {
|
||||||
|
if (responseHandler) {
|
||||||
|
const out = await responseHandler(blob, originalFiles);
|
||||||
|
return Array.isArray(out) ? out : [out as unknown as File];
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = originalFiles[0]?.name ?? 'result.pdf';
|
||||||
|
const name = `${filePrefix}${original}`;
|
||||||
|
const type = blob.type || 'application/octet-stream';
|
||||||
|
return [new File([blob], name, { type })];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user