mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 18:45:21 +00:00
Merge branch 'V2' into design/V2/navbar
This commit is contained in:
commit
e4371f16cc
@ -7,12 +7,12 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using Vite"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<title>Vite App</title>
|
||||
<title>Stirling PDF</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 8.0 KiB |
@ -7,6 +7,7 @@ import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileSelection } from '../../contexts/FileSelectionContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
@ -31,20 +32,16 @@ interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
toolMode?: boolean;
|
||||
multiSelect?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
onFileSelect?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
toolMode = false,
|
||||
multiSelect = true,
|
||||
showUpload = true,
|
||||
showBulkActions = true,
|
||||
onFileSelect
|
||||
showBulkActions = true
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -63,6 +60,14 @@ const FileEditor = ({
|
||||
markOperationApplied
|
||||
} = fileContext;
|
||||
|
||||
// Get file selection context
|
||||
const {
|
||||
selectedFiles: toolSelectedFiles,
|
||||
setSelectedFiles: setToolSelectedFiles,
|
||||
maxFiles,
|
||||
isToolMode
|
||||
} = useFileSelection();
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -99,14 +104,14 @@ const FileEditor = ({
|
||||
const lastActiveFilesRef = useRef<string[]>([]);
|
||||
const lastProcessedFilesRef = useRef<number>(0);
|
||||
|
||||
// Map context selected file names to local file IDs
|
||||
// Defensive programming: ensure selectedFileIds is always an array
|
||||
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
const localSelectedFiles = files
|
||||
// Map context selections to local file IDs for UI display
|
||||
const localSelectedIds = files
|
||||
.filter(file => {
|
||||
const fileId = (file.file as any).id || file.name;
|
||||
return safeSelectedFileIds.includes(fileId);
|
||||
return contextSelectedIds.includes(fileId);
|
||||
})
|
||||
.map(file => file.id);
|
||||
|
||||
@ -396,44 +401,41 @@ const FileEditor = ({
|
||||
if (!targetFile) return;
|
||||
|
||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
||||
const isSelected = contextSelectedIds.includes(contextFileId);
|
||||
|
||||
if (!multiSelect) {
|
||||
// Single select mode for tools - toggle on/off
|
||||
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId);
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect the file
|
||||
setContextSelectedFiles([]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([]);
|
||||
}
|
||||
} else {
|
||||
// Select the file
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([targetFile.file]);
|
||||
}
|
||||
}
|
||||
let newSelection: string[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
|
||||
} else {
|
||||
// Multi select mode (default)
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.includes(contextFileId)
|
||||
? safePrev.filter(id => id !== contextFileId)
|
||||
: [...safePrev, contextFileId];
|
||||
});
|
||||
|
||||
// Notify parent with selected files
|
||||
if (onFileSelect) {
|
||||
const selectedFiles = files
|
||||
.filter(f => {
|
||||
const fId = (f.file as any).id || f.name;
|
||||
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
|
||||
})
|
||||
.map(f => f.file);
|
||||
onFileSelect(selectedFiles);
|
||||
// Add file to selection
|
||||
if (maxFiles === 1) {
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
|
||||
setStatus(`Maximum ${maxFiles} files can be selected`);
|
||||
return;
|
||||
}
|
||||
newSelection = [...contextSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
|
||||
|
||||
// Update context
|
||||
setContextSelectedFiles(newSelection);
|
||||
|
||||
// Update tool selection context if in tool mode
|
||||
if (isToolMode || toolMode) {
|
||||
const selectedFiles = files
|
||||
.filter(f => {
|
||||
const fId = (f.file as any).id || f.name;
|
||||
return newSelection.includes(fId);
|
||||
})
|
||||
.map(f => f.file);
|
||||
setToolSelectedFiles(selectedFiles);
|
||||
}
|
||||
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
@ -450,15 +452,15 @@ const FileEditor = ({
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
|
||||
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: localSelectedFiles,
|
||||
count: localSelectedFiles.length
|
||||
fileIds: localSelectedIds,
|
||||
count: localSelectedIds.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, localSelectedFiles]);
|
||||
}, [selectionMode, localSelectedIds]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
@ -519,8 +521,8 @@ const FileEditor = ({
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile)
|
||||
? localSelectedFiles
|
||||
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
|
||||
? localSelectedIds
|
||||
: [draggedFile];
|
||||
|
||||
// Update the local files state and sync with activeFiles
|
||||
@ -545,7 +547,7 @@ const FileEditor = ({
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
|
||||
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
@ -764,7 +766,7 @@ const FileEditor = ({
|
||||
) : (
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={localSelectedFiles}
|
||||
selectedItems={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
@ -783,7 +785,7 @@ const FileEditor = ({
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={localSelectedFiles}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
|
14
frontend/src/components/tools/ToolLoadingFallback.tsx
Normal file
14
frontend/src/components/tools/ToolLoadingFallback.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Center, Stack, Loader, Text } from "@mantine/core";
|
||||
|
||||
export default function ToolLoadingFallback({ toolName }: { toolName?: string }) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{toolName ? `Loading ${toolName}...` : "Loading tool..."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
@ -1,15 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Tool = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ToolRegistry = {
|
||||
[id: string]: Tool;
|
||||
};
|
||||
import { ToolRegistry } from "../../types/tool";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
|
@ -1,23 +1,18 @@
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import React, { Suspense } from "react";
|
||||
import { useToolManagement } from "../../hooks/useToolManagement";
|
||||
import { BaseToolProps } from "../../types/tool";
|
||||
import ToolLoadingFallback from "./ToolLoadingFallback";
|
||||
|
||||
interface ToolRendererProps {
|
||||
interface ToolRendererProps extends BaseToolProps {
|
||||
selectedToolKey: string;
|
||||
pdfFile: any;
|
||||
files: FileWithUrl[];
|
||||
toolParams: any;
|
||||
updateParams: (params: any) => void;
|
||||
toolSelectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
|
||||
const ToolRenderer = ({
|
||||
selectedToolKey,
|
||||
files,
|
||||
toolParams,
|
||||
updateParams,
|
||||
toolSelectedFiles = [],
|
||||
onPreviewFile,
|
||||
onComplete,
|
||||
onError,
|
||||
}: ToolRendererProps) => {
|
||||
// Get the tool from registry
|
||||
const { toolRegistry } = useToolManagement();
|
||||
@ -29,39 +24,16 @@ files,
|
||||
|
||||
const ToolComponent = selectedTool.component;
|
||||
|
||||
// Pass tool-specific props
|
||||
switch (selectedToolKey) {
|
||||
case "split":
|
||||
return (
|
||||
<ToolComponent
|
||||
selectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
);
|
||||
case "compress":
|
||||
return (
|
||||
<ToolComponent
|
||||
selectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
);
|
||||
case "merge":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Wrap lazy-loaded component with Suspense
|
||||
return (
|
||||
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
|
||||
<ToolComponent
|
||||
onPreviewFile={onPreviewFile}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolRenderer;
|
||||
|
@ -22,6 +22,7 @@ import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
import { getFileId } from '../utils/fileUtils';
|
||||
|
||||
// Initial state
|
||||
const initialViewerConfig: ViewerConfig = {
|
||||
@ -98,7 +99,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
|
||||
case 'REMOVE_FILES':
|
||||
const remainingFiles = state.activeFiles.filter(file => {
|
||||
const fileId = (file as any).id || file.name;
|
||||
const fileId = getFileId(file);
|
||||
return !action.payload.includes(fileId);
|
||||
});
|
||||
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
||||
@ -347,7 +348,7 @@ export function FileContextProvider({
|
||||
// Cleanup timers and refs
|
||||
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const blobUrls = useRef<Set<string>>(new Set());
|
||||
const pdfDocuments = useRef<Map<string, any>>(new Map());
|
||||
const pdfDocuments = useRef<Map<string, PDFDocument>>(new Map());
|
||||
|
||||
// Enhanced file processing hook
|
||||
const {
|
||||
@ -381,7 +382,7 @@ export function FileContextProvider({
|
||||
blobUrls.current.add(url);
|
||||
}, []);
|
||||
|
||||
const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
|
||||
const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => {
|
||||
// Clean up existing document for this file if any
|
||||
const existing = pdfDocuments.current.get(fileId);
|
||||
if (existing && existing.destroy) {
|
||||
@ -498,7 +499,7 @@ export function FileContextProvider({
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Check if file already has an ID (already in IndexedDB)
|
||||
const fileId = (file as any).id;
|
||||
const fileId = getFileId(file);
|
||||
if (!fileId) {
|
||||
// File doesn't have ID, store it and get the ID
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
@ -680,7 +681,7 @@ export function FileContextProvider({
|
||||
// Utility functions
|
||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||
return state.activeFiles.find(file => {
|
||||
const actualFileId = (file as any).id || file.name;
|
||||
const actualFileId = getFileId(file);
|
||||
return actualFileId === fileId;
|
||||
});
|
||||
}, [state.activeFiles]);
|
||||
|
86
frontend/src/contexts/FileSelectionContext.tsx
Normal file
86
frontend/src/contexts/FileSelectionContext.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import {
|
||||
MaxFiles,
|
||||
FileSelectionContextValue
|
||||
} from '../types/tool';
|
||||
|
||||
interface FileSelectionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
||||
|
||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
||||
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
||||
const selectionCount = selectedFiles.length;
|
||||
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
|
||||
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
|
||||
const isMultiFileMode = maxFiles !== 1;
|
||||
|
||||
const contextValue: FileSelectionContextValue = {
|
||||
selectedFiles,
|
||||
maxFiles,
|
||||
isToolMode,
|
||||
setSelectedFiles,
|
||||
setMaxFiles,
|
||||
setIsToolMode,
|
||||
clearSelection,
|
||||
canSelectMore,
|
||||
isAtLimit,
|
||||
selectionCount,
|
||||
isMultiFileMode
|
||||
};
|
||||
|
||||
return (
|
||||
<FileSelectionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the file selection context.
|
||||
* Throws if used outside a <FileSelectionProvider>.
|
||||
*/
|
||||
export function useFileSelection(): FileSelectionContextValue {
|
||||
const context = useContext(FileSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileSelection must be used within a FileSelectionProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
|
||||
// Use this in tool panels/components that need to know which files are selected and selection limits.
|
||||
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
|
||||
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
|
||||
}
|
||||
|
||||
// Returns actions for manipulating file selection state.
|
||||
// Use this in components that need to update the selection, clear it, or change selection mode.
|
||||
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
|
||||
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
|
||||
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
|
||||
}
|
||||
|
||||
// Returns the raw file selection state (selected files, max files, tool mode).
|
||||
// Use this for low-level state access, e.g. in context-aware UI.
|
||||
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
|
||||
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
|
||||
return { selectedFiles, maxFiles, isToolMode };
|
||||
}
|
||||
|
||||
// Returns computed values derived from file selection state.
|
||||
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
|
||||
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
|
||||
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
|
||||
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
|
||||
}
|
@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
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,
|
||||
selectedFiles: File[]
|
||||
file: File
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
formData.append("fileInput", file);
|
||||
|
||||
if (parameters.compressionMethod === 'quality') {
|
||||
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||
@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles[0].name;
|
||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0].name,
|
||||
originalFileNames: selectedFiles.map(f => f.name),
|
||||
parameters: {
|
||||
compressionLevel: parameters.compressionLevel,
|
||||
grayscale: parameters.grayscale,
|
||||
expectedSize: parameters.expectedSize,
|
||||
},
|
||||
fileSize: selectedFiles[0].size
|
||||
totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
fileCount: selectedFiles.length
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, []);
|
||||
|
||||
const processResults = useCallback(async (blob: Blob, selectedFiles: File[]) => {
|
||||
try {
|
||||
// Check if the response is a PDF file directly or a ZIP file
|
||||
const contentType = blob.type;
|
||||
console.log('Response content type:', contentType);
|
||||
|
||||
if (contentType === 'application/pdf') {
|
||||
// Direct PDF response
|
||||
const originalFileName = selectedFiles[0].name;
|
||||
const pdfFile = new File([blob], `compressed_${originalFileName}`, { type: "application/pdf" });
|
||||
setFiles([pdfFile]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add file to FileContext
|
||||
await addFiles([pdfFile]);
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnail = await generateThumbnailForFile(pdfFile);
|
||||
setThumbnails([thumbnail || '']);
|
||||
setIsGeneratingThumbnails(false);
|
||||
} else {
|
||||
// ZIP file response (like split operation)
|
||||
const zipFile = new File([blob], "compress_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 {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return thumbnail || '';
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
return '';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setThumbnails(thumbnails);
|
||||
setIsGeneratingThumbnails(false);
|
||||
}
|
||||
}
|
||||
} catch (extractError) {
|
||||
console.warn('Failed to process results:', extractError);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
parameters: CompressParameters,
|
||||
@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
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);
|
||||
const { formData, endpoint } = buildFormData(parameters, selectedFiles);
|
||||
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
const compressedFiles: File[] = [];
|
||||
|
||||
// Determine the correct content type from the response
|
||||
const contentType = response.headers['content-type'] || 'application/zip';
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
// Generate dynamic filename based on original file and content type
|
||||
const originalFileName = selectedFiles[0].name;
|
||||
const filename = `compressed_${originalFileName}`;
|
||||
setDownloadFilename(filename);
|
||||
setDownloadUrl(url);
|
||||
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"));
|
||||
|
||||
await processResults(blob, selectedFiles);
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
cleanupBlobUrls();
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
}, [cleanupBlobUrls]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
|
||||
return {
|
||||
executeOperation,
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
|
@ -1,64 +1,75 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
import MergePdfPanel from "../tools/Merge";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||
|
||||
type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<any>;
|
||||
view: string;
|
||||
};
|
||||
|
||||
type ToolRegistry = {
|
||||
[key: string]: ToolRegistryEntry;
|
||||
};
|
||||
// Add entry here with maxFiles, endpoints, and lazy component
|
||||
const toolDefinitions: 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"]
|
||||
},
|
||||
|
||||
const baseToolRegistry = {
|
||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
|
||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "compress" },
|
||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
|
||||
};
|
||||
|
||||
// Tool endpoint mappings
|
||||
const toolEndpoints: Record<string, string[]> = {
|
||||
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"],
|
||||
compress: ["compress-pdf"],
|
||||
merge: ["merge-pdfs"],
|
||||
};
|
||||
|
||||
|
||||
export const useToolManagement = () => {
|
||||
interface ToolManagementResult {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: Tool | null;
|
||||
toolSelectedFileIds: string[];
|
||||
toolRegistry: ToolRegistry;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
setToolSelectedFileIds: (fileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat()));
|
||||
const allEndpoints = Array.from(new Set(
|
||||
Object.values(toolDefinitions).flatMap(tool => tool.endpoints || [])
|
||||
));
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true;
|
||||
const endpoints = toolEndpoints[toolKey] || [];
|
||||
return endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
const tool = toolDefinitions[toolKey];
|
||||
if (!tool?.endpoints) return true;
|
||||
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus]);
|
||||
|
||||
const toolRegistry: ToolRegistry = useMemo(() => {
|
||||
const availableToolRegistry: ToolRegistry = {};
|
||||
Object.keys(baseToolRegistry).forEach(toolKey => {
|
||||
const availableTools: ToolRegistry = {};
|
||||
Object.keys(toolDefinitions).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry],
|
||||
const toolDef = toolDefinitions[toolKey];
|
||||
availableTools[toolKey] = {
|
||||
...toolDef,
|
||||
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
|
||||
};
|
||||
}
|
||||
});
|
||||
return availableToolRegistry;
|
||||
return availableTools;
|
||||
}, [t, isToolAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import React, { useState, useCallback} from "react";
|
||||
import React, { useState, useCallback, useEffect} from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useToolManagement } from "../hooks/useToolManagement";
|
||||
import { Group, Box, Button, Container } 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";
|
||||
@ -15,45 +17,50 @@ import Viewer from "../components/viewer/Viewer";
|
||||
import FileUploadSelector from "../components/shared/FileUploadSelector";
|
||||
import ToolRenderer from "../components/tools/ToolRenderer";
|
||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||
import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig";
|
||||
|
||||
export default function HomePage() {
|
||||
function HomePageContent() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext;
|
||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
||||
|
||||
const {
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
toolParams,
|
||||
toolRegistry,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
updateToolParams,
|
||||
} = useToolManagement();
|
||||
|
||||
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
|
||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
|
||||
|
||||
// Update file selection context when tool changes
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
setMaxFiles(selectedTool.maxFiles);
|
||||
setIsToolMode(true);
|
||||
} else {
|
||||
setMaxFiles(-1);
|
||||
setIsToolMode(false);
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
|
||||
|
||||
|
||||
|
||||
const handleToolSelect = useCallback(
|
||||
(id: string) => {
|
||||
selectTool(id);
|
||||
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
||||
setCurrentView('fileEditor'); // Tools use fileEditor view for file selection
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false);
|
||||
},
|
||||
[selectTool, toolRegistry, setCurrentView]
|
||||
[selectTool, setCurrentView]
|
||||
);
|
||||
|
||||
const handleQuickAccessTools = useCallback(() => {
|
||||
@ -145,7 +152,6 @@ export default function HomePage() {
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
toolSelectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</div>
|
||||
@ -198,14 +204,18 @@ export default function HomePage() {
|
||||
</Container>
|
||||
) : currentView === "fileEditor" ? (
|
||||
<FileEditor
|
||||
onOpenPageEditor={(file) => {
|
||||
handleViewChange("pageEditor");
|
||||
}}
|
||||
onMergeFiles={(filesToMerge) => {
|
||||
// Add merged files to active set
|
||||
filesToMerge.forEach(addToActiveFiles);
|
||||
handleViewChange("viewer");
|
||||
}}
|
||||
toolMode={!!selectedToolKey}
|
||||
showUpload={true}
|
||||
showBulkActions={!selectedToolKey}
|
||||
{...(!selectedToolKey && {
|
||||
onOpenPageEditor: (file) => {
|
||||
handleViewChange("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
filesToMerge.forEach(addToActiveFiles);
|
||||
handleViewChange("viewer");
|
||||
}
|
||||
})}
|
||||
/>
|
||||
) : currentView === "viewer" ? (
|
||||
<Viewer
|
||||
@ -255,27 +265,8 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : currentView === "split" ? (
|
||||
<FileEditor
|
||||
toolMode={true}
|
||||
multiSelect={false}
|
||||
showUpload={true}
|
||||
showBulkActions={true}
|
||||
onFileSelect={(files) => {
|
||||
setToolSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
) : currentView === "compress" ? (
|
||||
<FileEditor
|
||||
toolMode={true}
|
||||
multiSelect={false} // TODO: make this work with multiple files
|
||||
showUpload={true}
|
||||
showBulkActions={true}
|
||||
onFileSelect={(files) => {
|
||||
setToolSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
) : selectedToolKey && selectedTool ? (
|
||||
// Fallback: if tool is selected but not in fileEditor view, show tool in main area
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
@ -302,3 +293,12 @@ export default function HomePage() {
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// Main HomePage component wrapped with FileSelectionProvider
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<FileSelectionProvider>
|
||||
<HomePageContent />
|
||||
</FileSelectionProvider>
|
||||
);
|
||||
}
|
||||
|
@ -103,6 +103,37 @@ export class ZipFileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZIP file from an array of files
|
||||
*/
|
||||
async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
|
||||
// Add each file to the ZIP
|
||||
for (const file of files) {
|
||||
const content = await file.arrayBuffer();
|
||||
zip.file(file.name, content);
|
||||
}
|
||||
|
||||
// Generate ZIP blob
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const zipFile = new File([zipBlob], zipFilename, {
|
||||
type: 'application/zip',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
return { zipFile, size: zipFile.size };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PDF files from a ZIP archive
|
||||
*/
|
||||
|
@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
@ -15,15 +16,12 @@ import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
|
||||
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
||||
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
|
||||
interface CompressProps {
|
||||
selectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
|
||||
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setCurrentMode } = useFileContext();
|
||||
const { selectedFiles } = useToolFileSelection();
|
||||
|
||||
const compressParams = useCompressParameters();
|
||||
const compressOperation = useCompressOperation();
|
||||
@ -37,10 +35,19 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
|
||||
}, [compressParams.parameters, selectedFiles]);
|
||||
|
||||
const handleCompress = async () => {
|
||||
await compressOperation.executeOperation(
|
||||
compressParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
try {
|
||||
await compressOperation.executeOperation(
|
||||
compressParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
if (compressOperation.files && onComplete) {
|
||||
onComplete(compressOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error.message : 'Compress operation failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
@ -56,7 +63,7 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = compressOperation.downloadUrl !== null;
|
||||
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
@ -77,7 +84,11 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
|
||||
isVisible={true}
|
||||
isCollapsed={filesCollapsed}
|
||||
isCompleted={filesCollapsed}
|
||||
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
|
||||
completedMessage={hasFiles ?
|
||||
selectedFiles.length === 1
|
||||
? `Selected: ${selectedFiles[0].name}`
|
||||
: `Selected: ${selectedFiles.length} files`
|
||||
: undefined}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
|
@ -4,26 +4,23 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
|
||||
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
|
||||
interface SplitProps {
|
||||
selectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
|
||||
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setCurrentMode } = useFileContext();
|
||||
const { selectedFiles } = useToolFileSelection();
|
||||
|
||||
const splitParams = useSplitParameters();
|
||||
const splitOperation = useSplitOperation();
|
||||
@ -39,11 +36,20 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
|
||||
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
|
||||
|
||||
const handleSplit = async () => {
|
||||
await splitOperation.executeOperation(
|
||||
splitParams.mode,
|
||||
splitParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
try {
|
||||
await splitOperation.executeOperation(
|
||||
splitParams.mode,
|
||||
splitParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
if (splitOperation.files && onComplete) {
|
||||
onComplete(splitOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error.message : 'Split operation failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
|
@ -36,3 +36,19 @@ export interface UndoRedoState {
|
||||
operations: PageOperation[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export interface PageEditorFunctions {
|
||||
closePdf: () => void;
|
||||
handleUndo: () => void;
|
||||
handleRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
handleRotate: () => void;
|
||||
handleDelete: () => void;
|
||||
handleSplit: () => void;
|
||||
onExportSelected: () => void;
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
selectionMode: boolean;
|
||||
selectedPages: number[];
|
||||
}
|
||||
|
73
frontend/src/types/tool.ts
Normal file
73
frontend/src/types/tool.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
export type MaxFiles = number; // 1=single, >1=limited, -1=unlimited
|
||||
export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security';
|
||||
export type ToolDefinition = Omit<Tool, 'name'>;
|
||||
export type ToolStepType = 'files' | 'settings' | 'results';
|
||||
|
||||
export interface BaseToolProps {
|
||||
onComplete?: (results: File[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export interface ToolStepConfig {
|
||||
type: ToolStepType;
|
||||
title: string;
|
||||
isVisible: boolean;
|
||||
isCompleted: boolean;
|
||||
isCollapsed?: boolean;
|
||||
completedMessage?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
}
|
||||
|
||||
export interface ToolValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
files?: File[];
|
||||
error?: string;
|
||||
downloadUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
component: React.ComponentType<BaseToolProps>;
|
||||
maxFiles: MaxFiles;
|
||||
category?: ToolCategory;
|
||||
description?: string;
|
||||
endpoints?: string[];
|
||||
supportedFormats?: string[];
|
||||
validation?: (files: File[]) => ToolValidationResult;
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<string, Tool>;
|
||||
|
||||
export interface FileSelectionState {
|
||||
selectedFiles: File[];
|
||||
maxFiles: MaxFiles;
|
||||
isToolMode: boolean;
|
||||
}
|
||||
|
||||
export interface FileSelectionActions {
|
||||
setSelectedFiles: (files: File[]) => void;
|
||||
setMaxFiles: (maxFiles: MaxFiles) => void;
|
||||
setIsToolMode: (isToolMode: boolean) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export interface FileSelectionComputed {
|
||||
canSelectMore: boolean;
|
||||
isAtLimit: boolean;
|
||||
selectionCount: number;
|
||||
isMultiFileMode: boolean;
|
||||
}
|
||||
|
||||
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}
|
@ -1,6 +1,10 @@
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { StoredFile, fileStorage } from "../services/fileStorage";
|
||||
|
||||
export function getFileId(file: File): string {
|
||||
return (file as File & { id?: string }).id || file.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated file size formatting utility
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user