mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-20 13:47:46 +02:00
V2 results flow (#4196)
Better tool flow for reusability Pinning Styling of tool flow consumption of files after tooling --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com> Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
parent
1468df3e21
commit
4c17c520d7
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.2.0",
|
"@types/node": "^24.2.1",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
@ -2386,10 +2386,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.2.0",
|
"version": "24.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||||
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
|
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.2.0",
|
"@types/node": "^24.2.1",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Stack, Button } from '@mantine/core';
|
import { Stack, Button, Box } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
import FilePreview from './FilePreview';
|
import FilePreview from '../shared/FilePreview';
|
||||||
import FileInfoCard from './FileInfoCard';
|
import FileInfoCard from './FileInfoCard';
|
||||||
import CompactFileDetails from './CompactFileDetails';
|
import CompactFileDetails from './CompactFileDetails';
|
||||||
|
|
||||||
@ -76,15 +76,18 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<FilePreview
|
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
|
||||||
currentFile={currentFile}
|
<FilePreview
|
||||||
thumbnail={getCurrentThumbnail()}
|
file={currentFile}
|
||||||
numberOfFiles={selectedFiles.length}
|
thumbnail={getCurrentThumbnail()}
|
||||||
isAnimating={isAnimating}
|
showStacking={true}
|
||||||
modalHeight={modalHeight}
|
showNavigation={true}
|
||||||
onPrevious={handlePrevious}
|
totalFiles={selectedFiles.length}
|
||||||
onNext={handleNext}
|
isAnimating={isAnimating}
|
||||||
/>
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: File Details */}
|
{/* Section 2: File Details */}
|
||||||
<FileInfoCard
|
<FileInfoCard
|
||||||
|
@ -1,156 +0,0 @@
|
|||||||
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;
|
|
@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
import FileOperationHistory from '../history/FileOperationHistory';
|
import FileOperationHistory from '../history/FileOperationHistory';
|
||||||
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -66,6 +69,10 @@ const FileThumbnail = ({
|
|||||||
}: FileThumbnailProps) => {
|
}: FileThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||||
|
|
||||||
|
// Find the actual File object that corresponds to this FileItem
|
||||||
|
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@ -301,6 +308,32 @@ const FileThumbnail = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{actualFile && (
|
||||||
|
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
||||||
|
<ActionIcon
|
||||||
|
size="md"
|
||||||
|
variant="subtle"
|
||||||
|
c={isFilePinned(actualFile) ? "yellow" : "white"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isFilePinned(actualFile)) {
|
||||||
|
unpinFile(actualFile);
|
||||||
|
onSetStatus(`Unpinned ${file.name}`);
|
||||||
|
} else {
|
||||||
|
pinFile(actualFile);
|
||||||
|
onSetStatus(`Pinned ${file.name}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFilePinned(actualFile) ? (
|
||||||
|
<PushPinIcon style={{ fontSize: 20 }} />
|
||||||
|
) : (
|
||||||
|
<PushPinOutlinedIcon style={{ fontSize: 20 }} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip label="Close File">
|
<Tooltip label="Close File">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
|
96
frontend/src/components/shared/FilePreview.tsx
Normal file
96
frontend/src/components/shared/FilePreview.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||||
|
import DocumentStack from './filePreview/DocumentStack';
|
||||||
|
import HoverOverlay from './filePreview/HoverOverlay';
|
||||||
|
import NavigationArrows from './filePreview/NavigationArrows';
|
||||||
|
|
||||||
|
export interface FilePreviewProps {
|
||||||
|
// Core file data
|
||||||
|
file: File | FileWithUrl | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
|
||||||
|
// Optional features
|
||||||
|
showStacking?: boolean;
|
||||||
|
showHoverOverlay?: boolean;
|
||||||
|
showNavigation?: boolean;
|
||||||
|
|
||||||
|
// State
|
||||||
|
totalFiles?: number;
|
||||||
|
isAnimating?: boolean;
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
onFileClick?: (file: File | FileWithUrl | null) => void;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
|
file,
|
||||||
|
thumbnail,
|
||||||
|
showStacking = false,
|
||||||
|
showHoverOverlay = false,
|
||||||
|
showNavigation = false,
|
||||||
|
totalFiles = 1,
|
||||||
|
isAnimating = false,
|
||||||
|
onFileClick,
|
||||||
|
onPrevious,
|
||||||
|
onNext
|
||||||
|
}) => {
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const hasMultipleFiles = totalFiles > 1;
|
||||||
|
|
||||||
|
// Animation styles
|
||||||
|
const animationStyle = isAnimating ? {
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: 'scale(0.95) translateX(1.25rem)',
|
||||||
|
opacity: 0.7
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
// Build the component composition
|
||||||
|
let content = (
|
||||||
|
<DocumentThumbnail
|
||||||
|
file={file}
|
||||||
|
thumbnail={thumbnail}
|
||||||
|
style={animationStyle}
|
||||||
|
onClick={() => onFileClick?.(file)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with hover overlay if needed
|
||||||
|
if (showHoverOverlay && onFileClick) {
|
||||||
|
content = <HoverOverlay>{content}</HoverOverlay>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with document stack if needed
|
||||||
|
if (showStacking) {
|
||||||
|
content = (
|
||||||
|
<DocumentStack totalFiles={totalFiles}>
|
||||||
|
{content}
|
||||||
|
</DocumentStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with navigation if needed
|
||||||
|
if (showNavigation && hasMultipleFiles && onPrevious && onNext) {
|
||||||
|
content = (
|
||||||
|
<NavigationArrows
|
||||||
|
onPrevious={onPrevious}
|
||||||
|
onNext={onNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</NavigationArrows>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%' }}>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
65
frontend/src/components/shared/filePreview/DocumentStack.tsx
Normal file
65
frontend/src/components/shared/filePreview/DocumentStack.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface DocumentStackProps {
|
||||||
|
totalFiles: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentStack: React.FC<DocumentStackProps> = ({
|
||||||
|
totalFiles,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const stackDocumentBaseStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackDocumentShadows = {
|
||||||
|
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
|
{/* Background documents (stack effect) */}
|
||||||
|
{totalFiles >= 3 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||||
|
boxShadow: stackDocumentShadows.back,
|
||||||
|
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalFiles >= 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 container */}
|
||||||
|
<Box style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 3,
|
||||||
|
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentStack;
|
@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Center, Image } from '@mantine/core';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import { FileWithUrl } from '../../../types/file';
|
||||||
|
|
||||||
|
export interface DocumentThumbnailProps {
|
||||||
|
file: File | FileWithUrl | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||||
|
file,
|
||||||
|
thumbnail,
|
||||||
|
style = {},
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
position: 'relative' as const,
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
|
if (thumbnail) {
|
||||||
|
return (
|
||||||
|
<Box style={containerStyle} onClick={onClick}>
|
||||||
|
<Image
|
||||||
|
src={thumbnail}
|
||||||
|
alt={`Preview of ${file.name}`}
|
||||||
|
fit="contain"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={containerStyle} onClick={onClick}>
|
||||||
|
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||||
|
<PictureAsPdfIcon
|
||||||
|
style={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
color: 'var(--mantine-color-gray-6)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentThumbnail;
|
63
frontend/src/components/shared/filePreview/HoverOverlay.tsx
Normal file
63
frontend/src/components/shared/filePreview/HoverOverlay.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
|
||||||
|
export interface HoverOverlayProps {
|
||||||
|
onMouseEnter?: (e: React.MouseEvent) => void;
|
||||||
|
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HoverOverlay: React.FC<HoverOverlayProps> = ({
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const defaultMouseEnter = (e: React.MouseEvent) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '1';
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMouseLeave = (e: React.MouseEvent) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onMouseEnter={onMouseEnter || defaultMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave || defaultMouseLeave}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<Box
|
||||||
|
className="hover-overlay"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HoverOverlay;
|
@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, ActionIcon } from '@mantine/core';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
|
||||||
|
export interface NavigationArrowsProps {
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationArrows: React.FC<NavigationArrowsProps> = ({
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
disabled = false,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const navigationArrowStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
|
{/* Left Navigation Arrow */}
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
color="blue"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
left: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Navigation Arrow */}
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
color="blue"
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
...navigationArrowStyle,
|
||||||
|
right: '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationArrows;
|
@ -39,7 +39,7 @@ export default function ToolPanel() {
|
|||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: isPanelVisible ? '20rem' : '0',
|
width: isPanelVisible ? '20rem' : '0',
|
||||||
padding: isPanelVisible ? '0.5rem' : '0'
|
padding: '0'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -1,14 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Stack, Text, NumberInput, Select } from "@mantine/core";
|
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
|
||||||
interface CompressParameters {
|
|
||||||
compressionMethod: 'quality' | 'filesize';
|
|
||||||
compressionLevel: number;
|
|
||||||
fileSizeValue: string;
|
|
||||||
fileSizeUnit: 'KB' | 'MB';
|
|
||||||
grayscale: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompressSettingsProps {
|
interface CompressSettingsProps {
|
||||||
parameters: CompressParameters;
|
parameters: CompressParameters;
|
||||||
@ -22,6 +15,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|
||||||
|
<Divider ml='-md'></Divider>
|
||||||
{/* Compression Method */}
|
{/* Compression Method */}
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" fw={500}>Compression Method</Text>
|
<Text size="sm" fw={500}>Compression Method</Text>
|
||||||
@ -54,6 +49,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
{/* Quality Adjustment */}
|
{/* Quality Adjustment */}
|
||||||
{parameters.compressionMethod === 'quality' && (
|
{parameters.compressionMethod === 'quality' && (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
<Divider />
|
||||||
<Text size="sm" fw={500}>Compression Level</Text>
|
<Text size="sm" fw={500}>Compression Level</Text>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<input
|
<input
|
||||||
@ -68,7 +64,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
onTouchStart={() => setIsSliding(true)}
|
onTouchStart={() => setIsSliding(true)}
|
||||||
onTouchEnd={() => setIsSliding(false)}
|
onTouchEnd={() => setIsSliding(false)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '6px',
|
height: '6px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
@ -107,6 +103,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Divider/>
|
||||||
|
|
||||||
{/* File Size Input */}
|
{/* File Size Input */}
|
||||||
{parameters.compressionMethod === 'filesize' && (
|
{parameters.compressionMethod === 'filesize' && (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@ -141,7 +139,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
|
|
||||||
{/* Compression Options */}
|
{/* Compression Options */}
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<label
|
<label
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
|
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
|
||||||
>
|
>
|
||||||
@ -158,4 +156,4 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CompressSettings;
|
export default CompressSettings;
|
||||||
|
27
frontend/src/components/tools/shared/FileMetadata.tsx
Normal file
27
frontend/src/components/tools/shared/FileMetadata.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text } from '@mantine/core';
|
||||||
|
import { formatFileSize, getFileDate } from '../../../utils/fileUtils';
|
||||||
|
|
||||||
|
export interface FileMetadataProps {
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileMetadata = ({ file }: FileMetadataProps) => {
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Stack gap="0.125rem">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{file.type || 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{getFileDate(file)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileMetadata;
|
@ -3,17 +3,15 @@ import { Text } from '@mantine/core';
|
|||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: File[];
|
selectedFiles?: File[];
|
||||||
isCompleted?: boolean;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
showFileName?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileStatusIndicator = ({
|
const FileStatusIndicator = ({
|
||||||
selectedFiles = [],
|
selectedFiles = [],
|
||||||
isCompleted = false,
|
placeholder = "Select a PDF file in the main view to get started"
|
||||||
placeholder = "Select a PDF file in the main view to get started",
|
|
||||||
showFileName = true
|
|
||||||
}: FileStatusIndicatorProps) => {
|
}: FileStatusIndicatorProps) => {
|
||||||
|
|
||||||
|
// Only show content when no files are selected
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@ -22,19 +20,8 @@ const FileStatusIndicator = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCompleted) {
|
// Return nothing when files are selected
|
||||||
return (
|
return null;
|
||||||
<Text size="sm" c="green">
|
|
||||||
✓ Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text size="sm" c="blue">
|
|
||||||
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileStatusIndicator;
|
export default FileStatusIndicator;
|
28
frontend/src/components/tools/shared/FilesToolStep.tsx
Normal file
28
frontend/src/components/tools/shared/FilesToolStep.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import FileStatusIndicator from './FileStatusIndicator';
|
||||||
|
|
||||||
|
export interface FilesToolStepProps {
|
||||||
|
selectedFiles: File[];
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onCollapsedClick?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilesToolStep(
|
||||||
|
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||||
|
props: FilesToolStepProps
|
||||||
|
): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return createStep(t("files.title", "Files"), {
|
||||||
|
isVisible: true,
|
||||||
|
isCollapsed: props.isCollapsed,
|
||||||
|
onCollapsedClick: props.onCollapsedClick
|
||||||
|
}, (
|
||||||
|
<FileStatusIndicator
|
||||||
|
selectedFiles={props.selectedFiles}
|
||||||
|
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
51
frontend/src/components/tools/shared/NavigationControls.tsx
Normal file
51
frontend/src/components/tools/shared/NavigationControls.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Group, ActionIcon, Text } from '@mantine/core';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
|
||||||
|
export interface NavigationControlsProps {
|
||||||
|
currentIndex: number;
|
||||||
|
totalFiles: number;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationControls = ({
|
||||||
|
currentIndex,
|
||||||
|
totalFiles,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
}: NavigationControlsProps) => {
|
||||||
|
if (totalFiles <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" mt="xs">
|
||||||
|
<Group justify="center" gap="xs">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={totalFiles <= 1}
|
||||||
|
data-testid="review-panel-prev"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon style={{ fontSize: '1rem' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentIndex + 1} of {totalFiles}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={totalFiles <= 1}
|
||||||
|
data-testid="review-panel-next"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon style={{ fontSize: '1rem' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationControls;
|
@ -24,7 +24,7 @@ const OperationButton = ({
|
|||||||
submitText,
|
submitText,
|
||||||
variant = 'filled',
|
variant = 'filled',
|
||||||
color = 'blue',
|
color = 'blue',
|
||||||
fullWidth = true,
|
fullWidth = false,
|
||||||
mt = 'md',
|
mt = 'md',
|
||||||
type = 'button',
|
type = 'button',
|
||||||
'data-testid': dataTestId
|
'data-testid': dataTestId
|
||||||
@ -36,14 +36,17 @@ const OperationButton = ({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
|
mr='md'
|
||||||
|
ml='md'
|
||||||
mt={mt}
|
mt={mt}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
color={color}
|
color={color}
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
|
style={{ minHeight: '2.5rem' }}
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? (loadingText || t("loading", "Loading..."))
|
? (loadingText || t("loading", "Loading..."))
|
||||||
: (submitText || t("submit", "Submit"))
|
: (submitText || t("submit", "Submit"))
|
||||||
}
|
}
|
||||||
@ -51,4 +54,4 @@ const OperationButton = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OperationButton;
|
export default OperationButton;
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text, Loader, Stack, Center, Flex } from '@mantine/core';
|
||||||
|
import FilePreview from '../../shared/FilePreview';
|
||||||
|
import FileMetadata from './FileMetadata';
|
||||||
|
import NavigationControls from './NavigationControls';
|
||||||
|
|
||||||
export interface ResultFile {
|
export interface ReviewFile {
|
||||||
file: File;
|
file: File;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResultsPreviewProps {
|
export interface ResultsPreviewProps {
|
||||||
files: ResultFile[];
|
files: ReviewFile[];
|
||||||
isGeneratingThumbnails?: boolean;
|
isGeneratingThumbnails?: boolean;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
title?: string;
|
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
loadingMessage?: string;
|
loadingMessage?: string;
|
||||||
}
|
}
|
||||||
@ -18,14 +21,17 @@ const ResultsPreview = ({
|
|||||||
files,
|
files,
|
||||||
isGeneratingThumbnails = false,
|
isGeneratingThumbnails = false,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
title,
|
|
||||||
emptyMessage = "No files to preview",
|
emptyMessage = "No files to preview",
|
||||||
loadingMessage = "Generating previews..."
|
loadingMessage = "Generating previews..."
|
||||||
}: ResultsPreviewProps) => {
|
}: ResultsPreviewProps) => {
|
||||||
const formatSize = (size: number) => {
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
|
const handlePrevious = () => {
|
||||||
return `${size} B`;
|
setCurrentIndex((prev) => (prev === 0 ? files.length - 1 : prev - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setCurrentIndex((prev) => (prev === files.length - 1 ? 0 : prev + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (files.length === 0 && !isGeneratingThumbnails) {
|
if (files.length === 0 && !isGeneratingThumbnails) {
|
||||||
@ -36,78 +42,59 @@ const ResultsPreview = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isGeneratingThumbnails) {
|
||||||
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }} data-testid="results-preview-container">
|
return (
|
||||||
{title && (
|
<Center p="lg" data-testid="review-panel-loading">
|
||||||
<Text fw={500} size="md" mb="sm" data-testid="results-preview-title">
|
<Stack align="center" gap="sm">
|
||||||
{title} ({files.length} files)
|
<Loader size="sm" />
|
||||||
</Text>
|
<Text size="sm" c="dimmed">{loadingMessage}</Text>
|
||||||
)}
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isGeneratingThumbnails ? (
|
const currentFile = files[currentIndex];
|
||||||
<Center p="lg" data-testid="results-preview-loading">
|
if (!currentFile) return null;
|
||||||
<Stack align="center" gap="sm">
|
|
||||||
<Loader size="sm" />
|
return (
|
||||||
<Text size="sm" c="dimmed">{loadingMessage}</Text>
|
<Box p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.5rem', maxWidth: '100%' }} data-testid="review-panel-container">
|
||||||
</Stack>
|
|
||||||
</Center>
|
{/* File name at the top */}
|
||||||
) : (
|
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
|
||||||
<Grid data-testid="results-preview-grid">
|
<Text
|
||||||
{files.map((result, index) => (
|
size="sm"
|
||||||
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
|
fw={500}
|
||||||
<Paper
|
style={{
|
||||||
p="xs"
|
wordBreak: 'break-word',
|
||||||
withBorder
|
lineHeight: 1.4
|
||||||
onClick={() => onFileClick?.(result.file)}
|
}}
|
||||||
data-testid={`results-preview-thumbnail-${index}`}
|
title={currentFile.file.name}
|
||||||
style={{
|
>
|
||||||
textAlign: 'center',
|
{currentFile.file.name}
|
||||||
height: '10rem',
|
</Text>
|
||||||
width:'5rem',
|
</Box>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
<Flex gap="md" align="flex-start" style={{ minHeight: '7.5rem', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
|
||||||
cursor: onFileClick ? 'pointer' : 'default',
|
<Box style={{ width: '6.25rem', height: '7.5rem', flexShrink: 0 }}>
|
||||||
transition: 'all 0.2s ease'
|
<FilePreview
|
||||||
}}
|
file={currentFile.file}
|
||||||
>
|
thumbnail={currentFile.thumbnail}
|
||||||
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
showHoverOverlay={true}
|
||||||
{result.thumbnail ? (
|
onFileClick={onFileClick ? (file) => file && onFileClick(file as File) : undefined}
|
||||||
<Image
|
/>
|
||||||
src={result.thumbnail}
|
</Box>
|
||||||
alt={`Preview of ${result.file.name}`}
|
<FileMetadata file={currentFile.file} />
|
||||||
style={{
|
</Flex>
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '9rem',
|
{/* Navigation controls */}
|
||||||
objectFit: 'contain'
|
<NavigationControls
|
||||||
}}
|
currentIndex={currentIndex}
|
||||||
/>
|
totalFiles={files.length}
|
||||||
) : (
|
onPrevious={handlePrevious}
|
||||||
<Text size="xs" c="dimmed">No preview</Text>
|
onNext={handleNext}
|
||||||
)}
|
/>
|
||||||
</Box>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
c="dimmed"
|
|
||||||
mt="xs"
|
|
||||||
style={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
title={result.file.name}
|
|
||||||
>
|
|
||||||
{result.file.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{formatSize(result.file.size)}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
</Grid.Col>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ResultsPreview;
|
export default ResultsPreview;
|
||||||
|
65
frontend/src/components/tools/shared/ReviewToolStep.tsx
Normal file
65
frontend/src/components/tools/shared/ReviewToolStep.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Stack, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import ErrorNotification from './ErrorNotification';
|
||||||
|
import ResultsPreview from './ResultsPreview';
|
||||||
|
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
||||||
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
|
|
||||||
|
export interface ReviewToolStepProps<TParams = unknown> {
|
||||||
|
isVisible: boolean;
|
||||||
|
operation: ToolOperationHook<TParams>;
|
||||||
|
title?: string;
|
||||||
|
onFileClick?: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReviewToolStep<TParams = unknown>(
|
||||||
|
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||||
|
props: ReviewToolStepProps<TParams>
|
||||||
|
): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { operation } = props;
|
||||||
|
|
||||||
|
const previewFiles = operation.files?.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
thumbnail: operation.thumbnails[index]
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return createStep(t("review", "Review"), {
|
||||||
|
isVisible: props.isVisible,
|
||||||
|
_excludeFromCount: true,
|
||||||
|
_noPadding: true
|
||||||
|
}, (
|
||||||
|
<Stack gap="sm" >
|
||||||
|
<ErrorNotification
|
||||||
|
error={operation.errorMessage}
|
||||||
|
onClose={operation.clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{previewFiles.length > 0 && (
|
||||||
|
<ResultsPreview
|
||||||
|
files={previewFiles}
|
||||||
|
onFileClick={props.onFileClick}
|
||||||
|
isGeneratingThumbnails={operation.isGeneratingThumbnails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operation.downloadUrl && (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={operation.downloadUrl}
|
||||||
|
download={operation.downloadFilename}
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
color="blue"
|
||||||
|
fullWidth
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{t("download", "Download")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SuggestedToolsSection />
|
||||||
|
</Stack>
|
||||||
|
));
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||||
|
export interface SuggestedToolsSectionProps {}
|
||||||
|
|
||||||
|
export function SuggestedToolsSection(): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const suggestedTools = useSuggestedTools();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
{t('editYourNewFiles', 'Edit your new File(s)')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
{suggestedTools.map((tool) => {
|
||||||
|
const IconComponent = tool.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={tool.name}
|
||||||
|
p="sm"
|
||||||
|
withBorder
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={tool.navigate}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconComponent fontSize="small" />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{tool.title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,14 @@
|
|||||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||||
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
|
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
|
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
|
||||||
|
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
|
||||||
|
import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep';
|
||||||
|
|
||||||
interface ToolStepContextType {
|
interface ToolStepContextType {
|
||||||
visibleStepCount: number;
|
visibleStepCount: number;
|
||||||
getStepNumber: () => number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolStepContext = createContext<ToolStepContextType | null>(null);
|
const ToolStepContext = createContext<ToolStepContextType | null>(null);
|
||||||
@ -16,12 +17,13 @@ export interface ToolStepProps {
|
|||||||
title: string;
|
title: string;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
isCompleted?: boolean;
|
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
completedMessage?: string;
|
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
showNumber?: boolean;
|
showNumber?: boolean;
|
||||||
|
_stepNumber?: number; // Internal prop set by ToolStepContainer
|
||||||
|
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
|
||||||
|
_noPadding?: boolean; // Internal prop to exclude from default left padding
|
||||||
tooltip?: {
|
tooltip?: {
|
||||||
content?: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
tips?: TooltipTip[];
|
tips?: TooltipTip[];
|
||||||
@ -56,7 +58,7 @@ const renderTooltipTitle = (
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{title}
|
{title}
|
||||||
@ -68,39 +70,40 @@ const ToolStep = ({
|
|||||||
title,
|
title,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
isCompleted = false,
|
|
||||||
onCollapsedClick,
|
onCollapsedClick,
|
||||||
children,
|
children,
|
||||||
completedMessage,
|
|
||||||
helpText,
|
helpText,
|
||||||
showNumber,
|
showNumber,
|
||||||
|
_stepNumber,
|
||||||
|
_noPadding,
|
||||||
tooltip
|
tooltip
|
||||||
}: ToolStepProps) => {
|
}: ToolStepProps) => {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
const parent = useContext(ToolStepContext);
|
const parent = useContext(ToolStepContext);
|
||||||
|
|
||||||
// Auto-detect if we should show numbers based on sibling count
|
// Auto-detect if we should show numbers based on sibling count
|
||||||
const shouldShowNumber = useMemo(() => {
|
const shouldShowNumber = useMemo(() => {
|
||||||
if (showNumber !== undefined) return showNumber;
|
if (showNumber !== undefined) return showNumber;
|
||||||
return parent ? parent.visibleStepCount >= 3 : false;
|
return parent ? parent.visibleStepCount >= 3 : false;
|
||||||
}, [showNumber, parent]);
|
}, [showNumber, parent]);
|
||||||
|
|
||||||
const stepNumber = parent?.getStepNumber?.() || 1;
|
const stepNumber = _stepNumber;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<div>
|
||||||
p="md"
|
<div
|
||||||
withBorder
|
style={{
|
||||||
style={{
|
padding: '1rem',
|
||||||
opacity: isCollapsed ? 0.8 : 1,
|
opacity: isCollapsed ? 0.8 : 1,
|
||||||
transition: 'opacity 0.2s ease'
|
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||||
}}
|
transition: 'opacity 0.2s ease, color 0.2s ease'
|
||||||
>
|
}}
|
||||||
|
>
|
||||||
{/* Chevron icon to collapse/expand the step */}
|
{/* Chevron icon to collapse/expand the step */}
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
mb="sm"
|
mb="sm"
|
||||||
style={{
|
style={{
|
||||||
cursor: onCollapsedClick ? 'pointer' : 'default'
|
cursor: onCollapsedClick ? 'pointer' : 'default'
|
||||||
@ -115,37 +118,24 @@ const ToolStep = ({
|
|||||||
)}
|
)}
|
||||||
{renderTooltipTitle(title, tooltip, isCollapsed)}
|
{renderTooltipTitle(title, tooltip, isCollapsed)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRightIcon style={{
|
<ChevronRightIcon style={{
|
||||||
fontSize: '1.2rem',
|
fontSize: '1.2rem',
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
opacity: onCollapsedClick ? 1 : 0.5
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<ExpandMoreIcon style={{
|
<ExpandMoreIcon style={{
|
||||||
fontSize: '1.2rem',
|
fontSize: '1.2rem',
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
opacity: onCollapsedClick ? 1 : 0.5
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{isCollapsed ? (
|
{!isCollapsed && (
|
||||||
<Box>
|
<Stack gap="md" pl={_noPadding ? 0 : "md"}>
|
||||||
{isCompleted && completedMessage && (
|
|
||||||
<Text size="sm" c="green">
|
|
||||||
✓ {completedMessage}
|
|
||||||
{onCollapsedClick && (
|
|
||||||
<Text span c="dimmed" size="xs" ml="sm">
|
|
||||||
(click to change)
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Stack gap="md">
|
|
||||||
{helpText && (
|
{helpText && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{helpText}
|
{helpText}
|
||||||
@ -154,36 +144,77 @@ const ToolStep = ({
|
|||||||
{children}
|
{children}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</div>
|
||||||
|
<Divider style={{ marginLeft: '1rem', marginRight: '-0.5rem' }} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolStepContainerProps {
|
// ToolStepFactory for creating numbered steps
|
||||||
children: React.ReactNode;
|
export function createToolSteps() {
|
||||||
|
let stepNumber = 1;
|
||||||
|
const steps: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
const create = (
|
||||||
|
title: string,
|
||||||
|
props: Omit<ToolStepProps, 'title' | '_stepNumber'> = {},
|
||||||
|
children?: React.ReactNode
|
||||||
|
): React.ReactElement => {
|
||||||
|
const isVisible = props.isVisible !== false;
|
||||||
|
const currentStepNumber = isVisible ? stepNumber++ : undefined;
|
||||||
|
|
||||||
|
const step = React.createElement(ToolStep, {
|
||||||
|
...props,
|
||||||
|
title,
|
||||||
|
_stepNumber: currentStepNumber,
|
||||||
|
children,
|
||||||
|
key: `step-${title.toLowerCase().replace(/\s+/g, '-')}`
|
||||||
|
});
|
||||||
|
|
||||||
|
steps.push(step);
|
||||||
|
return step;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFilesStep = (props: FilesToolStepProps): React.ReactElement => {
|
||||||
|
return createFilesToolStep(create, props);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createReviewStep = <TParams = unknown>(props: ReviewToolStepProps<TParams>): React.ReactElement => {
|
||||||
|
return createReviewToolStep(create, props);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibleCount = () => {
|
||||||
|
return steps.filter(step => {
|
||||||
|
const props = step.props as ToolStepProps;
|
||||||
|
const isVisible = props.isVisible !== false;
|
||||||
|
const excludeFromCount = props._excludeFromCount === true;
|
||||||
|
return isVisible && !excludeFromCount;
|
||||||
|
}).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { create, createFilesStep, createReviewStep, getVisibleCount, steps };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
|
// Context provider wrapper for tools using the factory
|
||||||
const stepCounterRef = useRef(0);
|
export function ToolStepProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// Count visible steps from children that are ToolStep elements
|
||||||
// Count visible ToolStep children
|
|
||||||
const visibleStepCount = useMemo(() => {
|
const visibleStepCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
React.Children.forEach(children, (child) => {
|
React.Children.forEach(children, (child) => {
|
||||||
if (React.isValidElement(child) && child.type === ToolStep) {
|
if (React.isValidElement(child) && child.type === ToolStep) {
|
||||||
const isVisible = (child.props as ToolStepProps).isVisible !== false;
|
const props = child.props as ToolStepProps;
|
||||||
if (isVisible) count++;
|
const isVisible = props.isVisible !== false;
|
||||||
|
const excludeFromCount = props._excludeFromCount === true;
|
||||||
|
if (isVisible && !excludeFromCount) count++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
}, [children]);
|
}, [children]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
visibleStepCount,
|
visibleStepCount
|
||||||
getStepNumber: () => ++stepCounterRef.current
|
|
||||||
}), [visibleStepCount]);
|
}), [visibleStepCount]);
|
||||||
|
|
||||||
stepCounterRef.current = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolStepContext.Provider value={contextValue}>
|
<ToolStepContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
@ -191,4 +222,6 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { FilesToolStepProps } from './FilesToolStep';
|
||||||
|
export type { ReviewToolStepProps } from './ReviewToolStep';
|
||||||
export default ToolStep;
|
export default ToolStep;
|
||||||
|
104
frontend/src/components/tools/shared/createToolFlow.tsx
Normal file
104
frontend/src/components/tools/shared/createToolFlow.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||||
|
import OperationButton from './OperationButton';
|
||||||
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
|
|
||||||
|
export interface FilesStepConfig {
|
||||||
|
selectedFiles: File[];
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
onCollapsedClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddleStepConfig {
|
||||||
|
title: string;
|
||||||
|
isVisible?: boolean;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onCollapsedClick?: () => void;
|
||||||
|
content: React.ReactNode;
|
||||||
|
tooltip?: {
|
||||||
|
content?: React.ReactNode;
|
||||||
|
tips?: any[];
|
||||||
|
header?: {
|
||||||
|
title: string;
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteButtonConfig {
|
||||||
|
text: string;
|
||||||
|
loadingText: string;
|
||||||
|
onClick: () => Promise<void>;
|
||||||
|
isVisible?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewStepConfig {
|
||||||
|
isVisible: boolean;
|
||||||
|
operation: ToolOperationHook<any>;
|
||||||
|
title: string;
|
||||||
|
onFileClick?: (file: File) => void;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolFlowConfig {
|
||||||
|
files: FilesStepConfig;
|
||||||
|
steps: MiddleStepConfig[];
|
||||||
|
executeButton?: ExecuteButtonConfig;
|
||||||
|
review: ReviewStepConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a flexible tool flow with configurable steps and state management left to the tool.
|
||||||
|
* Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
|
||||||
|
*/
|
||||||
|
export function createToolFlow(config: ToolFlowConfig) {
|
||||||
|
const steps = createToolSteps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
|
||||||
|
<ToolStepProvider>
|
||||||
|
{/* Files Step */}
|
||||||
|
{steps.createFilesStep({
|
||||||
|
selectedFiles: config.files.selectedFiles,
|
||||||
|
isCollapsed: config.files.isCollapsed,
|
||||||
|
placeholder: config.files.placeholder,
|
||||||
|
onCollapsedClick: config.files.onCollapsedClick
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Middle Steps */}
|
||||||
|
{config.steps.map((stepConfig, index) =>
|
||||||
|
steps.create(stepConfig.title, {
|
||||||
|
isVisible: stepConfig.isVisible,
|
||||||
|
isCollapsed: stepConfig.isCollapsed,
|
||||||
|
onCollapsedClick: stepConfig.onCollapsedClick,
|
||||||
|
tooltip: stepConfig.tooltip
|
||||||
|
}, stepConfig.content)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Button */}
|
||||||
|
{config.executeButton && config.executeButton.isVisible !== false && (
|
||||||
|
<OperationButton
|
||||||
|
onClick={config.executeButton.onClick}
|
||||||
|
isLoading={config.review.operation.isLoading}
|
||||||
|
disabled={config.executeButton.disabled}
|
||||||
|
loadingText={config.executeButton.loadingText}
|
||||||
|
submitText={config.executeButton.text}
|
||||||
|
data-testid={config.executeButton.testId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Step */}
|
||||||
|
{steps.createReviewStep({
|
||||||
|
isVisible: config.review.isVisible,
|
||||||
|
operation: config.review.operation,
|
||||||
|
title: config.review.title,
|
||||||
|
onFileClick: config.review.onFileClick
|
||||||
|
})}
|
||||||
|
</ToolStepProvider>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = {
|
|||||||
const initialState: FileContextState = {
|
const initialState: FileContextState = {
|
||||||
activeFiles: [],
|
activeFiles: [],
|
||||||
processedFiles: new Map(),
|
processedFiles: new Map(),
|
||||||
|
pinnedFiles: new Set(),
|
||||||
currentMode: 'pageEditor',
|
currentMode: 'pageEditor',
|
||||||
currentView: 'fileEditor', // Legacy field
|
currentView: 'fileEditor', // Legacy field
|
||||||
currentTool: null, // Legacy field
|
currentTool: null, // Legacy field
|
||||||
@ -77,6 +78,9 @@ type FileContextAction =
|
|||||||
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
||||||
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
||||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
|
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
|
||||||
|
| { type: 'PIN_FILE'; payload: File }
|
||||||
|
| { type: 'UNPIN_FILE'; payload: File }
|
||||||
|
| { type: 'CONSUME_FILES'; payload: { inputFiles: File[]; outputFiles: File[] } }
|
||||||
| { type: 'RESET_CONTEXT' }
|
| { type: 'RESET_CONTEXT' }
|
||||||
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
|
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
|
||||||
|
|
||||||
@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
showNavigationWarning: action.payload
|
showNavigationWarning: action.payload
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'PIN_FILE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pinnedFiles: new Set([...state.pinnedFiles, action.payload])
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UNPIN_FILE':
|
||||||
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
|
newPinnedFiles.delete(action.payload);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pinnedFiles: newPinnedFiles
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'CONSUME_FILES': {
|
||||||
|
const { inputFiles, outputFiles } = action.payload;
|
||||||
|
const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file));
|
||||||
|
|
||||||
|
// Remove unpinned input files and add output files
|
||||||
|
const newActiveFiles = [
|
||||||
|
...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)),
|
||||||
|
...outputFiles
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update processed files map - remove consumed files, keep pinned ones
|
||||||
|
const newProcessedFiles = new Map(state.processedFiles);
|
||||||
|
unpinnedInputFiles.forEach(file => {
|
||||||
|
newProcessedFiles.delete(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeFiles: newActiveFiles,
|
||||||
|
processedFiles: newProcessedFiles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'RESET_CONTEXT':
|
case 'RESET_CONTEXT':
|
||||||
return {
|
return {
|
||||||
...initialState
|
...initialState
|
||||||
@ -560,6 +601,46 @@ export function FileContextProvider({
|
|||||||
dispatch({ type: 'CLEAR_SELECTIONS' });
|
dispatch({ type: 'CLEAR_SELECTIONS' });
|
||||||
}, [cleanupAllFiles]);
|
}, [cleanupAllFiles]);
|
||||||
|
|
||||||
|
// File pinning functions
|
||||||
|
const pinFile = useCallback((file: File) => {
|
||||||
|
dispatch({ type: 'PIN_FILE', payload: file });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unpinFile = useCallback((file: File) => {
|
||||||
|
dispatch({ type: 'UNPIN_FILE', payload: file });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isFilePinned = useCallback((file: File): boolean => {
|
||||||
|
return state.pinnedFiles.has(file);
|
||||||
|
}, [state.pinnedFiles]);
|
||||||
|
|
||||||
|
// File consumption function
|
||||||
|
const consumeFiles = useCallback(async (inputFiles: File[], outputFiles: File[]): Promise<void> => {
|
||||||
|
dispatch({ type: 'CONSUME_FILES', payload: { inputFiles, outputFiles } });
|
||||||
|
|
||||||
|
// Store new output files if persistence is enabled
|
||||||
|
if (enablePersistence) {
|
||||||
|
for (const file of outputFiles) {
|
||||||
|
try {
|
||||||
|
const fileId = getFileId(file);
|
||||||
|
if (!fileId) {
|
||||||
|
try {
|
||||||
|
const thumbnail = await (thumbnailGenerationService as any).generateThumbnail(file);
|
||||||
|
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||||
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||||
|
} catch (thumbnailError) {
|
||||||
|
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) {
|
||||||
|
console.error('Failed to store output file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enablePersistence, state.pinnedFiles]);
|
||||||
|
|
||||||
// Navigation guard system functions
|
// Navigation guard system functions
|
||||||
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
||||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
|
||||||
@ -783,6 +864,10 @@ export function FileContextProvider({
|
|||||||
removeFiles,
|
removeFiles,
|
||||||
replaceFile,
|
replaceFile,
|
||||||
clearAllFiles,
|
clearAllFiles,
|
||||||
|
pinFile,
|
||||||
|
unpinFile,
|
||||||
|
isFilePinned,
|
||||||
|
consumeFiles,
|
||||||
setCurrentMode,
|
setCurrentMode,
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
setCurrentTool,
|
setCurrentTool,
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
MaxFiles,
|
MaxFiles,
|
||||||
FileSelectionContextValue
|
FileSelectionContextValue
|
||||||
} from '../types/tool';
|
} from '../types/tool';
|
||||||
|
import { useFileContext } from './FileContext';
|
||||||
|
|
||||||
interface FileSelectionProviderProps {
|
interface FileSelectionProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -11,10 +12,23 @@ interface FileSelectionProviderProps {
|
|||||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
||||||
|
const { activeFiles } = useFileContext();
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
||||||
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Sync selected files with active files - remove any selected files that are no longer active
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const activeFileSet = new Set(activeFiles);
|
||||||
|
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
|
||||||
|
|
||||||
|
if (validSelectedFiles.length !== selectedFiles.length) {
|
||||||
|
setSelectedFiles(validSelectedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeFiles, selectedFiles]);
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -15,11 +15,11 @@ interface ToolWorkflowState {
|
|||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
leftPanelView: 'toolPicker' | 'toolContent';
|
leftPanelView: 'toolPicker' | 'toolContent';
|
||||||
readerMode: boolean;
|
readerMode: boolean;
|
||||||
|
|
||||||
// File/Preview State
|
// File/Preview State
|
||||||
previewFile: File | null;
|
previewFile: File | null;
|
||||||
pageEditorFunctions: PageEditorFunctions | null;
|
pageEditorFunctions: PageEditorFunctions | null;
|
||||||
|
|
||||||
// Search State
|
// Search State
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
|||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
toolRegistry: any; // From useToolManagement
|
toolRegistry: any; // From useToolManagement
|
||||||
|
|
||||||
// UI Actions
|
// UI Actions
|
||||||
setSidebarsVisible: (visible: boolean) => void;
|
setSidebarsVisible: (visible: boolean) => void;
|
||||||
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
|
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
|
||||||
@ -80,16 +80,16 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
|||||||
setPreviewFile: (file: File | null) => void;
|
setPreviewFile: (file: File | null) => void;
|
||||||
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
|
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
|
|
||||||
// Tool Actions
|
// Tool Actions
|
||||||
selectTool: (toolId: string) => void;
|
selectTool: (toolId: string) => void;
|
||||||
clearToolSelection: () => void;
|
clearToolSelection: () => void;
|
||||||
|
|
||||||
// Workflow Actions (compound actions)
|
// Workflow Actions (compound actions)
|
||||||
handleToolSelect: (toolId: string) => void;
|
handleToolSelect: (toolId: string) => void;
|
||||||
handleBackToTools: () => void;
|
handleBackToTools: () => void;
|
||||||
handleReaderToggle: () => void;
|
handleReaderToggle: () => void;
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
filteredTools: [string, any][]; // Filtered by search
|
filteredTools: [string, any][]; // Filtered by search
|
||||||
isPanelVisible: boolean;
|
isPanelVisible: boolean;
|
||||||
@ -106,7 +106,7 @@ interface ToolWorkflowProviderProps {
|
|||||||
|
|
||||||
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
||||||
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||||
|
|
||||||
// Tool management hook
|
// Tool management hook
|
||||||
const {
|
const {
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
@ -181,7 +181,7 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
|||||||
);
|
);
|
||||||
}, [toolRegistry, state.searchQuery]);
|
}, [toolRegistry, state.searchQuery]);
|
||||||
|
|
||||||
const isPanelVisible = useMemo(() =>
|
const isPanelVisible = useMemo(() =>
|
||||||
state.sidebarsVisible && !state.readerMode,
|
state.sidebarsVisible && !state.readerMode,
|
||||||
[state.sidebarsVisible, state.readerMode]
|
[state.sidebarsVisible, state.readerMode]
|
||||||
);
|
);
|
||||||
@ -193,7 +193,7 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
|||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setSidebarsVisible,
|
setSidebarsVisible,
|
||||||
setLeftPanelView,
|
setLeftPanelView,
|
||||||
@ -203,12 +203,12 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
selectTool,
|
selectTool,
|
||||||
clearToolSelection,
|
clearToolSelection,
|
||||||
|
|
||||||
// Workflow Actions
|
// Workflow Actions
|
||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
handleBackToTools,
|
handleBackToTools,
|
||||||
handleReaderToggle,
|
handleReaderToggle,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
filteredTools,
|
filteredTools,
|
||||||
isPanelVisible,
|
isPanelVisible,
|
||||||
@ -232,5 +232,5 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
|
|||||||
|
|
||||||
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
|
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
|
||||||
export const useToolSelection = useToolWorkflow;
|
export const useToolSelection = useToolWorkflow;
|
||||||
export const useToolPanelState = useToolWorkflow;
|
export const useToolPanelState = useToolWorkflow;
|
||||||
export const useWorkbenchState = useToolWorkflow;
|
export const useWorkbenchState = useToolWorkflow;
|
||||||
|
@ -37,8 +37,7 @@ function stripExt(name: string): string {
|
|||||||
return i > 0 ? name.slice(0, i) : name;
|
return i > 0 ? name.slice(0, i) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature must be (file, params)
|
const buildFormData = (parameters: OCRParameters, file: File): FormData => {
|
||||||
const buildFormData = (file: File, parameters: OCRParameters): FormData => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('fileInput', file);
|
formData.append('fileInput', file);
|
||||||
parameters.languages.forEach((lang) => formData.append('languages', lang));
|
parameters.languages.forEach((lang) => formData.append('languages', lang));
|
||||||
@ -99,7 +98,7 @@ export const useOCROperation = () => {
|
|||||||
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||||
operationType: 'ocr',
|
operationType: 'ocr',
|
||||||
endpoint: '/api/v1/misc/ocr-pdf',
|
endpoint: '/api/v1/misc/ocr-pdf',
|
||||||
buildFormData: buildFormData as any /* FIX ME */,
|
buildFormData,
|
||||||
filePrefix: 'ocr_',
|
filePrefix: 'ocr_',
|
||||||
multiFileEndpoint: false, // Process files individually
|
multiFileEndpoint: false, // Process files individually
|
||||||
responseHandler, // use shared flow
|
responseHandler, // use shared flow
|
||||||
|
@ -5,7 +5,7 @@ import type { ProcessingProgress } from './useToolState';
|
|||||||
|
|
||||||
export interface ApiCallsConfig<TParams = void> {
|
export interface ApiCallsConfig<TParams = void> {
|
||||||
endpoint: string | ((params: TParams) => string);
|
endpoint: string | ((params: TParams) => string);
|
||||||
buildFormData: (file: File, params: TParams) => FormData;
|
buildFormData: (params: TParams, file: File) => FormData;
|
||||||
filePrefix: string;
|
filePrefix: string;
|
||||||
responseHandler?: ResponseHandler;
|
responseHandler?: ResponseHandler;
|
||||||
}
|
}
|
||||||
@ -34,7 +34,7 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = config.buildFormData(file, params);
|
const formData = config.buildFormData(params, file);
|
||||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
const response = await axios.post(endpoint, formData, {
|
const response = await axios.post(endpoint, formData, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@ -170,7 +170,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
// Individual file processing - separate API call per file
|
// Individual file processing - separate API call per file
|
||||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
buildFormData: (file: File, params: TParams) => (config.buildFormData as (params: TParams, file: File) => FormData /* FIX ME */)(params, file),
|
buildFormData: config.buildFormData as (params: TParams, file: File) => FormData,
|
||||||
filePrefix: config.filePrefix,
|
filePrefix: config.filePrefix,
|
||||||
responseHandler: config.responseHandler
|
responseHandler: config.responseHandler
|
||||||
};
|
};
|
||||||
@ -198,8 +198,8 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setThumbnails(thumbnails);
|
actions.setThumbnails(thumbnails);
|
||||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
// Add to file context
|
// Consume input files and add output files (will replace unpinned inputs)
|
||||||
await addFiles(processedFiles);
|
await consumeFiles(validFiles, processedFiles);
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
}
|
}
|
||||||
|
59
frontend/src/hooks/useSuggestedTools.ts
Normal file
59
frontend/src/hooks/useSuggestedTools.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||||
|
|
||||||
|
// Material UI Icons
|
||||||
|
import CompressIcon from '@mui/icons-material/Compress';
|
||||||
|
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||||
|
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||||
|
import CropIcon from '@mui/icons-material/Crop';
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
|
|
||||||
|
export interface SuggestedTool {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ComponentType<any>;
|
||||||
|
navigate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||||
|
{
|
||||||
|
name: 'compress',
|
||||||
|
title: 'Compress',
|
||||||
|
icon: CompressIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'convert',
|
||||||
|
title: 'Convert',
|
||||||
|
icon: SwapHorizIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sanitize',
|
||||||
|
title: 'Sanitize',
|
||||||
|
icon: CleaningServicesIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'split',
|
||||||
|
title: 'Split',
|
||||||
|
icon: CropIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ocr',
|
||||||
|
title: 'OCR',
|
||||||
|
icon: TextFieldsIcon
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useSuggestedTools(): SuggestedTool[] {
|
||||||
|
const { handleToolSelect, selectedToolKey } = useToolWorkflow();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// Filter out the current tool
|
||||||
|
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.name !== selectedToolKey);
|
||||||
|
|
||||||
|
// Add navigation function to each tool
|
||||||
|
return filteredTools.map(tool => ({
|
||||||
|
...tool,
|
||||||
|
navigate: () => handleToolSelect(tool.name)
|
||||||
|
}));
|
||||||
|
}, [selectedToolKey, handleToolSelect]);
|
||||||
|
}
|
@ -1,16 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Box, Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
@ -39,154 +33,84 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addPasswordOperation.resetResults();
|
addPasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [addPasswordParams.parameters, selectedFiles]);
|
}, [addPasswordParams.parameters]);
|
||||||
|
|
||||||
const handleAddPassword = async () => {
|
const handleAddPassword = async () => {
|
||||||
try {
|
try {
|
||||||
await addPasswordOperation.executeOperation(
|
await addPasswordOperation.executeOperation(addPasswordParams.fullParameters, selectedFiles);
|
||||||
addPasswordParams.fullParameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (addPasswordOperation.files && onComplete) {
|
if (addPasswordOperation.files && onComplete) {
|
||||||
onComplete(addPasswordOperation.files);
|
onComplete(addPasswordOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : t('addPassword.error.failed', 'Add password operation failed'));
|
onError(error instanceof Error ? error.message : t("addPassword.error.failed", "Add password operation failed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'addPassword');
|
sessionStorage.setItem("previousMode", "addPassword");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
addPasswordOperation.resetResults();
|
addPasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode('addPassword');
|
setCurrentMode("addPassword");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
||||||
const filesCollapsed = hasFiles;
|
const passwordsCollapsed = !hasFiles || hasResults;
|
||||||
const passwordsCollapsed = hasResults;
|
|
||||||
const permissionsCollapsed = collapsedPermissions || hasResults;
|
const permissionsCollapsed = collapsedPermissions || hasResults;
|
||||||
|
|
||||||
const previewResults = useMemo(() =>
|
return createToolFlow({
|
||||||
addPasswordOperation.files?.map((file, index) => ({
|
files: {
|
||||||
file,
|
selectedFiles,
|
||||||
thumbnail: addPasswordOperation.thumbnails[index]
|
isCollapsed: hasFiles || hasResults,
|
||||||
})) || [],
|
},
|
||||||
[addPasswordOperation.files, addPasswordOperation.thumbnails]
|
steps: [
|
||||||
);
|
{
|
||||||
|
title: t("addPassword.passwords.stepTitle", "Passwords & Encryption"),
|
||||||
return (
|
isCollapsed: passwordsCollapsed,
|
||||||
<ToolStepContainer>
|
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
|
||||||
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
|
tooltip: addPasswordTips,
|
||||||
{/* Files Step */}
|
content: (
|
||||||
<ToolStep
|
|
||||||
title={t('files.title', 'Files')}
|
|
||||||
isVisible={true}
|
|
||||||
isCollapsed={filesCollapsed}
|
|
||||||
isCompleted={filesCollapsed}
|
|
||||||
completedMessage={hasFiles ?
|
|
||||||
selectedFiles.length === 1
|
|
||||||
? t('files.selected.single', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
|
|
||||||
: t('files.selected.multiple', 'Selected: {{count}} files', { count: selectedFiles.length })
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder={t('files.placeholder', 'Select a PDF file in the main view to get started')}
|
|
||||||
/>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Passwords & Encryption Step */}
|
|
||||||
<ToolStep
|
|
||||||
title={t('addPassword.title', 'Passwords & Encryption')}
|
|
||||||
isVisible={hasFiles}
|
|
||||||
isCollapsed={passwordsCollapsed}
|
|
||||||
isCompleted={passwordsCollapsed}
|
|
||||||
onCollapsedClick={hasResults ? handleSettingsReset : undefined}
|
|
||||||
completedMessage={passwordsCollapsed ? t('addPassword.passwords.completed', 'Passwords configured') : undefined}
|
|
||||||
tooltip={addPasswordTips}
|
|
||||||
>
|
|
||||||
<AddPasswordSettings
|
<AddPasswordSettings
|
||||||
parameters={addPasswordParams.parameters}
|
parameters={addPasswordParams.parameters}
|
||||||
onParameterChange={addPasswordParams.updateParameter}
|
onParameterChange={addPasswordParams.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Permissions Step */}
|
{
|
||||||
<ToolStep
|
title: t("changePermissions.title", "Document Permissions"),
|
||||||
title={t('changePermissions.title', 'Document Permissions')}
|
isCollapsed: permissionsCollapsed,
|
||||||
isVisible={hasFiles}
|
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedPermissions(!collapsedPermissions),
|
||||||
isCollapsed={permissionsCollapsed}
|
content: (
|
||||||
isCompleted={permissionsCollapsed}
|
|
||||||
onCollapsedClick={hasResults ? handleSettingsReset : () => setCollapsedPermissions(!collapsedPermissions)}
|
|
||||||
tooltip={addPasswordPermissionsTips}
|
|
||||||
>
|
|
||||||
<ChangePermissionsSettings
|
<ChangePermissionsSettings
|
||||||
parameters={addPasswordParams.permissions.parameters}
|
parameters={addPasswordParams.permissions.parameters}
|
||||||
onParameterChange={addPasswordParams.permissions.updateParameter}
|
onParameterChange={addPasswordParams.permissions.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
<Box mt="md">
|
],
|
||||||
<OperationButton
|
executeButton: {
|
||||||
onClick={handleAddPassword}
|
text: t("addPassword.submit", "Encrypt"),
|
||||||
isLoading={addPasswordOperation.isLoading}
|
isVisible: !hasResults,
|
||||||
disabled={!addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled}
|
loadingText: t("loading"),
|
||||||
loadingText={t('loading')}
|
onClick: handleAddPassword,
|
||||||
submitText={t('addPassword.submit', 'Encrypt')}
|
disabled: !addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
/>
|
},
|
||||||
</Box>
|
review: {
|
||||||
|
isVisible: hasResults,
|
||||||
{/* Results Step */}
|
operation: addPasswordOperation,
|
||||||
<ToolStep
|
title: t("addPassword.results.title", "Encrypted PDFs"),
|
||||||
title={t('results.title', 'Results')}
|
onFileClick: handleThumbnailClick,
|
||||||
isVisible={hasResults}
|
},
|
||||||
>
|
});
|
||||||
<Stack gap="sm">
|
};
|
||||||
{addPasswordOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{addPasswordOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={addPasswordOperation.errorMessage}
|
|
||||||
onClose={addPasswordOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{addPasswordOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={addPasswordOperation.downloadUrl}
|
|
||||||
download={addPasswordOperation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{t("download", "Download")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={addPasswordOperation.isGeneratingThumbnails}
|
|
||||||
title={t('addPassword.results.title', 'Encrypted PDFs')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddPassword;
|
export default AddPassword;
|
||||||
|
@ -1,16 +1,10 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
|
|
||||||
@ -34,137 +28,73 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
changePermissionsOperation.resetResults();
|
changePermissionsOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [changePermissionsParams.parameters, selectedFiles]);
|
}, [changePermissionsParams.parameters]);
|
||||||
|
|
||||||
const handleChangePermissions = async () => {
|
const handleChangePermissions = async () => {
|
||||||
try {
|
try {
|
||||||
await changePermissionsOperation.executeOperation(
|
await changePermissionsOperation.executeOperation(changePermissionsParams.parameters, selectedFiles);
|
||||||
changePermissionsParams.parameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (changePermissionsOperation.files && onComplete) {
|
if (changePermissionsOperation.files && onComplete) {
|
||||||
onComplete(changePermissionsOperation.files);
|
onComplete(changePermissionsOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : t('changePermissions.error.failed', 'Change permissions operation failed'));
|
onError(
|
||||||
|
error instanceof Error ? error.message : t("changePermissions.error.failed", "Change permissions operation failed")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'changePermissions');
|
sessionStorage.setItem("previousMode", "changePermissions");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
changePermissionsOperation.resetResults();
|
changePermissionsOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode('changePermissions');
|
setCurrentMode("changePermissions");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
|
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
|
||||||
const filesCollapsed = hasFiles;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
const settingsCollapsed = hasResults;
|
|
||||||
|
|
||||||
const previewResults = useMemo(() =>
|
return createToolFlow({
|
||||||
changePermissionsOperation.files?.map((file, index) => ({
|
files: {
|
||||||
file,
|
selectedFiles,
|
||||||
thumbnail: changePermissionsOperation.thumbnails[index]
|
isCollapsed: hasFiles || hasResults,
|
||||||
})) || [],
|
},
|
||||||
[changePermissionsOperation.files, changePermissionsOperation.thumbnails]
|
steps: [
|
||||||
);
|
{
|
||||||
|
title: t("changePermissions.title", "Document Permissions"),
|
||||||
return (
|
isCollapsed: settingsCollapsed,
|
||||||
<ToolStepContainer>
|
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
||||||
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
|
tooltip: changePermissionsTips,
|
||||||
{/* Files Step */}
|
content: (
|
||||||
<ToolStep
|
<ChangePermissionsSettings
|
||||||
title={t('files.title', 'Files')}
|
parameters={changePermissionsParams.parameters}
|
||||||
isVisible={true}
|
onParameterChange={changePermissionsParams.updateParameter}
|
||||||
isCollapsed={filesCollapsed}
|
disabled={endpointLoading}
|
||||||
isCompleted={filesCollapsed}
|
|
||||||
completedMessage={hasFiles ?
|
|
||||||
selectedFiles.length === 1
|
|
||||||
? t('files.selected.single', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
|
|
||||||
: t('files.selected.multiple', 'Selected: {{count}} files', { count: selectedFiles.length })
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder={t('files.placeholder', 'Select a PDF file in the main view to get started')}
|
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Permissions Step */}
|
],
|
||||||
<ToolStep
|
executeButton: {
|
||||||
title={t('changePermissions.title', 'Document Permissions')}
|
text: t("changePermissions.submit", "Change Permissions"),
|
||||||
isVisible={hasFiles}
|
isVisible: !hasResults,
|
||||||
isCollapsed={settingsCollapsed}
|
loadingText: t("loading"),
|
||||||
isCompleted={settingsCollapsed}
|
onClick: handleChangePermissions,
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
disabled: !changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
completedMessage={settingsCollapsed ? t('changePermissions.completed', 'Permissions changed') : undefined}
|
},
|
||||||
tooltip={changePermissionsTips}
|
review: {
|
||||||
>
|
isVisible: hasResults,
|
||||||
<Stack gap="sm">
|
operation: changePermissionsOperation,
|
||||||
<ChangePermissionsSettings
|
title: t("changePermissions.results.title", "Modified PDFs"),
|
||||||
parameters={changePermissionsParams.parameters}
|
onFileClick: handleThumbnailClick,
|
||||||
onParameterChange={changePermissionsParams.updateParameter}
|
},
|
||||||
disabled={endpointLoading}
|
});
|
||||||
/>
|
};
|
||||||
|
|
||||||
<OperationButton
|
|
||||||
onClick={handleChangePermissions}
|
|
||||||
isLoading={changePermissionsOperation.isLoading}
|
|
||||||
disabled={!changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled}
|
|
||||||
loadingText={t('loading')}
|
|
||||||
submitText={t('changePermissions.submit', 'Change Permissions')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Results Step */}
|
|
||||||
<ToolStep
|
|
||||||
title={t('results.title', 'Results')}
|
|
||||||
isVisible={hasResults}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{changePermissionsOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{changePermissionsOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={changePermissionsOperation.errorMessage}
|
|
||||||
onClose={changePermissionsOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{changePermissionsOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={changePermissionsOperation.downloadUrl}
|
|
||||||
download={changePermissionsOperation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{t("download", "Download")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={changePermissionsOperation.isGeneratingThumbnails}
|
|
||||||
title={t('changePermissions.results.title', 'Modified PDFs')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChangePermissions;
|
export default ChangePermissions;
|
||||||
|
@ -1,16 +1,10 @@
|
|||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
|
|
||||||
@ -34,138 +28,71 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
compressOperation.resetResults();
|
compressOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [compressParams.parameters, selectedFiles]);
|
}, [compressParams.parameters]);
|
||||||
|
|
||||||
const handleCompress = async () => {
|
const handleCompress = async () => {
|
||||||
try {
|
try {
|
||||||
await compressOperation.executeOperation(
|
await compressOperation.executeOperation(compressParams.parameters, selectedFiles);
|
||||||
compressParams.parameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (compressOperation.files && onComplete) {
|
if (compressOperation.files && onComplete) {
|
||||||
onComplete(compressOperation.files);
|
onComplete(compressOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : 'Compress operation failed');
|
onError(error instanceof Error ? error.message : "Compress operation failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'compress');
|
sessionStorage.setItem("previousMode", "compress");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
compressOperation.resetResults();
|
compressOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode('compress');
|
setCurrentMode("compress");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
|
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
|
||||||
const filesCollapsed = hasFiles;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
const settingsCollapsed = hasResults;
|
|
||||||
|
|
||||||
const previewResults = useMemo(() =>
|
return createToolFlow({
|
||||||
compressOperation.files?.map((file, index) => ({
|
files: {
|
||||||
file,
|
selectedFiles,
|
||||||
thumbnail: compressOperation.thumbnails[index]
|
isCollapsed: hasFiles && !hasResults,
|
||||||
})) || [],
|
},
|
||||||
[compressOperation.files, compressOperation.thumbnails]
|
steps: [
|
||||||
);
|
{
|
||||||
|
title: "Settings",
|
||||||
return (
|
isCollapsed: settingsCollapsed,
|
||||||
<ToolStepContainer>
|
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
||||||
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
tooltip: compressTips,
|
||||||
{/* Files Step */}
|
content: (
|
||||||
<ToolStep
|
<CompressSettings
|
||||||
title="Files"
|
parameters={compressParams.parameters}
|
||||||
isVisible={true}
|
onParameterChange={compressParams.updateParameter}
|
||||||
isCollapsed={filesCollapsed}
|
disabled={endpointLoading}
|
||||||
isCompleted={filesCollapsed}
|
|
||||||
completedMessage={hasFiles ?
|
|
||||||
selectedFiles.length === 1
|
|
||||||
? `Selected: ${selectedFiles[0].name}`
|
|
||||||
: `Selected: ${selectedFiles.length} files`
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder="Select a PDF file in the main view to get started"
|
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Settings Step */}
|
],
|
||||||
<ToolStep
|
executeButton: {
|
||||||
title="Settings"
|
text: t("compress.submit", "Compress"),
|
||||||
isVisible={hasFiles}
|
isVisible: !hasResults,
|
||||||
isCollapsed={settingsCollapsed}
|
loadingText: t("loading"),
|
||||||
isCompleted={settingsCollapsed}
|
onClick: handleCompress,
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
|
},
|
||||||
tooltip={compressTips}
|
review: {
|
||||||
>
|
isVisible: hasResults,
|
||||||
<Stack gap="sm">
|
operation: compressOperation,
|
||||||
<CompressSettings
|
title: t("compress.title", "Compression Results"),
|
||||||
parameters={compressParams.parameters}
|
onFileClick: handleThumbnailClick,
|
||||||
onParameterChange={compressParams.updateParameter}
|
},
|
||||||
disabled={endpointLoading}
|
});
|
||||||
/>
|
};
|
||||||
|
|
||||||
<OperationButton
|
|
||||||
onClick={handleCompress}
|
|
||||||
isLoading={compressOperation.isLoading}
|
|
||||||
disabled={!compressParams.validateParameters() || !hasFiles || !endpointEnabled}
|
|
||||||
loadingText={t("loading")}
|
|
||||||
submitText="Compress and Review"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Results Step */}
|
|
||||||
<ToolStep
|
|
||||||
title="Results"
|
|
||||||
isVisible={hasResults}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{compressOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{compressOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={compressOperation.errorMessage}
|
|
||||||
onClose={compressOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{compressOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={compressOperation.downloadUrl}
|
|
||||||
download={compressOperation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{t("download", "Download")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={compressOperation.isGeneratingThumbnails}
|
|
||||||
title="Compression Results"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default Compress;
|
export default Compress;
|
||||||
|
@ -1,16 +1,10 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 ConvertSettings from "../components/tools/convert/ConvertSettings";
|
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||||
|
|
||||||
@ -27,15 +21,13 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const convertParams = useConvertParameters();
|
const convertParams = useConvertParameters();
|
||||||
const convertOperation = useConvertOperation();
|
const convertOperation = useConvertOperation();
|
||||||
|
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
|
||||||
convertParams.getEndpointName()
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
scrollContainerRef.current.scrollTo({
|
scrollContainerRef.current.scrollTo({
|
||||||
top: scrollContainerRef.current.scrollHeight,
|
top: scrollContainerRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -79,133 +71,67 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
|
|
||||||
const handleConvert = async () => {
|
const handleConvert = async () => {
|
||||||
try {
|
try {
|
||||||
await convertOperation.executeOperation(
|
await convertOperation.executeOperation(convertParams.parameters, selectedFiles);
|
||||||
convertParams.parameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (convertOperation.files && onComplete) {
|
if (convertOperation.files && onComplete) {
|
||||||
onComplete(convertOperation.files);
|
onComplete(convertOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : 'Convert operation failed');
|
onError(error instanceof Error ? error.message : "Convert operation failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'convert');
|
sessionStorage.setItem("previousMode", "convert");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
convertOperation.resetResults();
|
convertOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode('convert');
|
setCurrentMode("convert");
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewResults = useMemo(() =>
|
return createToolFlow({
|
||||||
convertOperation.files?.map((file, index) => ({
|
files: {
|
||||||
file,
|
selectedFiles,
|
||||||
thumbnail: convertOperation.thumbnails[index]
|
isCollapsed: filesCollapsed,
|
||||||
})) || [],
|
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
|
||||||
[convertOperation.files, convertOperation.thumbnails]
|
},
|
||||||
);
|
steps: [
|
||||||
|
{
|
||||||
return (
|
title: t("convert.settings", "Settings"),
|
||||||
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
|
isCollapsed: settingsCollapsed,
|
||||||
<ToolStepContainer>
|
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
||||||
<Stack gap="sm" p="sm">
|
content: (
|
||||||
<ToolStep
|
<ConvertSettings
|
||||||
title={t("convert.files", "Files")}
|
parameters={convertParams.parameters}
|
||||||
isVisible={true}
|
onParameterChange={convertParams.updateParameter}
|
||||||
isCollapsed={filesCollapsed}
|
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
||||||
isCompleted={filesCollapsed}
|
|
||||||
completedMessage={hasFiles ?
|
|
||||||
selectedFiles.length === 1
|
|
||||||
? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
|
|
||||||
: t('filesSelected', '{{count}} files selected', { count: selectedFiles.length })
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
placeholder={t("convert.selectFilesPlaceholder", "Select files in the main view to get started")}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
<ToolStep
|
],
|
||||||
title={t("convert.settings", "Settings")}
|
executeButton: {
|
||||||
isVisible={true}
|
text: t("convert.convertFiles", "Convert Files"),
|
||||||
isCollapsed={settingsCollapsed}
|
loadingText: t("convert.converting", "Converting..."),
|
||||||
isCompleted={settingsCollapsed}
|
onClick: handleConvert,
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
isVisible: !hasResults,
|
||||||
completedMessage={settingsCollapsed ? t("convert.conversionCompleted", "Conversion completed") : undefined}
|
disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
>
|
testId: "convert-button",
|
||||||
<Stack gap="sm">
|
},
|
||||||
<ConvertSettings
|
review: {
|
||||||
parameters={convertParams.parameters}
|
isVisible: hasResults,
|
||||||
onParameterChange={convertParams.updateParameter}
|
operation: convertOperation,
|
||||||
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
title: t("convert.conversionResults", "Conversion Results"),
|
||||||
selectedFiles={selectedFiles}
|
onFileClick: handleThumbnailClick,
|
||||||
disabled={endpointLoading}
|
testId: "conversion-results",
|
||||||
/>
|
},
|
||||||
|
});
|
||||||
{hasFiles && convertParams.parameters.fromExtension && convertParams.parameters.toExtension && (
|
|
||||||
<OperationButton
|
|
||||||
onClick={handleConvert}
|
|
||||||
isLoading={convertOperation.isLoading}
|
|
||||||
disabled={!convertParams.validateParameters() || !hasFiles || !endpointEnabled}
|
|
||||||
loadingText={t("convert.converting", "Converting...")}
|
|
||||||
submitText={t("convert.convertFiles", "Convert Files")}
|
|
||||||
data-testid="convert-button"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
<ToolStep
|
|
||||||
title={t("convert.results", "Results")}
|
|
||||||
isVisible={hasResults}
|
|
||||||
data-testid="conversion-results"
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{convertOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{convertOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={convertOperation.errorMessage}
|
|
||||||
onClose={convertOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{convertOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={convertOperation.downloadUrl}
|
|
||||||
download={convertOperation.downloadFilename || t("convert.defaultFilename", "converted_file")}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
data-testid="download-button"
|
|
||||||
>
|
|
||||||
{t("convert.downloadConverted", "Download Converted File")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={convertOperation.isGeneratingThumbnails}
|
|
||||||
title={t("convert.conversionResults", "Conversion Results")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Convert;
|
export default Convert;
|
||||||
|
@ -15,12 +15,7 @@ export interface MergePdfPanelProps {
|
|||||||
updateParams: (newParams: Partial<MergePdfPanelProps["params"]>) => void;
|
updateParams: (newParams: Partial<MergePdfPanelProps["params"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
const MergePdfPanel: React.FC<MergePdfPanelProps> = ({ files, setDownloadUrl, params, updateParams }) => {
|
||||||
files,
|
|
||||||
setDownloadUrl,
|
|
||||||
params,
|
|
||||||
updateParams,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedFiles, setSelectedFiles] = useState<boolean[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<boolean[]>([]);
|
||||||
const [downloadUrl, setLocalDownloadUrl] = useState<string | null>(null);
|
const [downloadUrl, setLocalDownloadUrl] = useState<string | null>(null);
|
||||||
@ -51,7 +46,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
|||||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||||
const actualFile = new File([blob], storedFile.name, {
|
const actualFile = new File([blob], storedFile.name, {
|
||||||
type: storedFile.type,
|
type: storedFile.type,
|
||||||
lastModified: storedFile.lastModified
|
lastModified: storedFile.lastModified,
|
||||||
});
|
});
|
||||||
formData.append("fileInput", actualFile);
|
formData.append("fileInput", actualFile);
|
||||||
}
|
}
|
||||||
@ -83,9 +78,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (index: number) => {
|
const handleCheckboxChange = (index: number) => {
|
||||||
setSelectedFiles((prev) =>
|
setSelectedFiles((prev) => prev.map((selected, i) => (i === index ? !selected : selected)));
|
||||||
prev.map((selected, i) => (i === index ? !selected : selected))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedCount = selectedFiles.filter(Boolean).length;
|
const selectedCount = selectedFiles.filter(Boolean).length;
|
||||||
@ -96,7 +89,9 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" h={200}>
|
<Stack align="center" justify="center" h={200}>
|
||||||
<Loader size="md" />
|
<Loader size="md" />
|
||||||
<Text size="sm" c="dimmed">{t("loading", "Loading...")}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -112,55 +107,42 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text fw={500} size="lg">{t("merge.header")}</Text>
|
<Text fw={500} size="lg">
|
||||||
<Stack gap={4}>
|
{t("merge.header")}
|
||||||
{files.map((file, index) => (
|
</Text>
|
||||||
<Group key={index} gap="xs">
|
<Stack gap={4}>
|
||||||
<Checkbox
|
{files.map((file, index) => (
|
||||||
checked={selectedFiles[index] || false}
|
<Group key={index} gap="xs">
|
||||||
onChange={() => handleCheckboxChange(index)}
|
<Checkbox checked={selectedFiles[index] || false} onChange={() => handleCheckboxChange(index)} />
|
||||||
/>
|
<Text size="sm">{file.name}</Text>
|
||||||
<Text size="sm">{file.name}</Text>
|
</Group>
|
||||||
</Group>
|
))}
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{selectedCount < 2 && (
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
{t("multiPdfPrompt")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleMerge}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={selectedCount < 2 || isLoading}
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
{t("merge.submit")}
|
|
||||||
</Button>
|
|
||||||
{errorMessage && (
|
|
||||||
<Alert color="red" mt="sm">
|
|
||||||
{errorMessage}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={downloadUrl}
|
|
||||||
download="merged.pdf"
|
|
||||||
color="green"
|
|
||||||
variant="light"
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
{t("downloadPdf")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Checkbox
|
|
||||||
label={t("merge.removeCertSign")}
|
|
||||||
checked={removeDuplicates}
|
|
||||||
onChange={() => updateParams({ removeDuplicates: !removeDuplicates })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{selectedCount < 2 && (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
{t("multiPdfPrompt")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleMerge} loading={isLoading} disabled={selectedCount < 2 || isLoading} mt="md">
|
||||||
|
{t("merge.submit")}
|
||||||
|
</Button>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert color="red" mt="sm">
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{downloadUrl && (
|
||||||
|
<Button component="a" href={downloadUrl} download="merged.pdf" color="green" variant="light" mt="md">
|
||||||
|
{t("downloadPdf")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Checkbox
|
||||||
|
label={t("merge.removeCertSign")}
|
||||||
|
checked={removeDuplicates}
|
||||||
|
onChange={() => updateParams({ removeDuplicates: !removeDuplicates })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,16 +1,10 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Button, Stack, Text, Box } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 OCRSettings from "../components/tools/ocr/OCRSettings";
|
import OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||||
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
|
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
|
||||||
@ -30,7 +24,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const ocrTips = useOCRTips();
|
const ocrTips = useOCRTips();
|
||||||
|
|
||||||
// Step expansion state management
|
// Step expansion state management
|
||||||
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
|
const [expandedStep, setExpandedStep] = useState<"files" | "settings" | "advanced" | null>("files");
|
||||||
|
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("ocr-pdf");
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("ocr-pdf");
|
||||||
|
|
||||||
@ -41,11 +35,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ocrOperation.resetResults();
|
ocrOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [ocrParams.parameters, selectedFiles]);
|
}, [ocrParams.parameters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFiles.length > 0 && expandedStep === 'files') {
|
if (selectedFiles.length > 0 && expandedStep === "files") {
|
||||||
setExpandedStep('settings');
|
setExpandedStep("settings");
|
||||||
}
|
}
|
||||||
}, [selectedFiles.length, expandedStep]);
|
}, [selectedFiles.length, expandedStep]);
|
||||||
|
|
||||||
@ -58,161 +52,88 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
|
|
||||||
const handleOCR = async () => {
|
const handleOCR = async () => {
|
||||||
try {
|
try {
|
||||||
await ocrOperation.executeOperation(
|
await ocrOperation.executeOperation(ocrParams.parameters, selectedFiles);
|
||||||
ocrParams.parameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (ocrOperation.files && onComplete) {
|
if (ocrOperation.files && onComplete) {
|
||||||
onComplete(ocrOperation.files);
|
onComplete(ocrOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : 'OCR operation failed');
|
onError(error instanceof Error ? error.message : "OCR operation failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'ocr');
|
sessionStorage.setItem("previousMode", "ocr");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSettingsReset = () => {
|
||||||
|
ocrOperation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
setCurrentMode("ocr");
|
||||||
|
};
|
||||||
|
|
||||||
// Step visibility and collapse logic
|
const settingsCollapsed = expandedStep !== "settings";
|
||||||
const filesVisible = true;
|
|
||||||
const settingsVisible = true;
|
|
||||||
const resultsVisible = hasResults;
|
|
||||||
|
|
||||||
const filesCollapsed = expandedStep !== 'files';
|
return createToolFlow({
|
||||||
const settingsCollapsed = expandedStep !== 'settings';
|
files: {
|
||||||
|
selectedFiles,
|
||||||
const previewResults = useMemo(() =>
|
isCollapsed: hasFiles || hasResults,
|
||||||
ocrOperation.files?.map((file: File, index: number) => ({
|
},
|
||||||
file,
|
steps: [
|
||||||
thumbnail: ocrOperation.thumbnails[index]
|
{
|
||||||
})) || [],
|
title: "Settings",
|
||||||
[ocrOperation.files, ocrOperation.thumbnails]
|
isCollapsed: !hasFiles || settingsCollapsed,
|
||||||
);
|
onCollapsedClick: hasResults
|
||||||
|
? handleSettingsReset
|
||||||
return (
|
: () => {
|
||||||
<ToolStepContainer>
|
if (!hasFiles) return; // Only allow if files are selected
|
||||||
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
setExpandedStep(expandedStep === "settings" ? null : "settings");
|
||||||
{/* Files Step */}
|
},
|
||||||
<ToolStep
|
tooltip: ocrTips,
|
||||||
title="Files"
|
content: (
|
||||||
isVisible={filesVisible}
|
<OCRSettings
|
||||||
isCollapsed={hasFiles ? filesCollapsed : false}
|
parameters={ocrParams.parameters}
|
||||||
isCompleted={hasFiles}
|
onParameterChange={ocrParams.updateParameter}
|
||||||
onCollapsedClick={undefined}
|
disabled={endpointLoading}
|
||||||
completedMessage={hasFiles && filesCollapsed ?
|
|
||||||
selectedFiles.length === 1
|
|
||||||
? `Selected: ${selectedFiles[0].name}`
|
|
||||||
: `Selected: ${selectedFiles.length} files`
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder="Select a PDF file in the main view to get started"
|
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Settings Step */}
|
{
|
||||||
<ToolStep
|
title: "Advanced",
|
||||||
title="Settings"
|
isCollapsed: expandedStep !== "advanced",
|
||||||
isVisible={settingsVisible}
|
onCollapsedClick: hasResults
|
||||||
isCollapsed={settingsCollapsed}
|
? handleSettingsReset
|
||||||
isCompleted={hasFiles && hasValidSettings}
|
: () => {
|
||||||
onCollapsedClick={() => {
|
if (!hasFiles) return; // Only allow if files are selected
|
||||||
if (!hasFiles) return; // Only allow if files are selected
|
setExpandedStep(expandedStep === "advanced" ? null : "advanced");
|
||||||
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
|
},
|
||||||
}}
|
content: (
|
||||||
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
|
|
||||||
tooltip={ocrTips}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<OCRSettings
|
|
||||||
parameters={ocrParams.parameters}
|
|
||||||
onParameterChange={ocrParams.updateParameter}
|
|
||||||
disabled={endpointLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Advanced Step */}
|
|
||||||
<ToolStep
|
|
||||||
title="Advanced"
|
|
||||||
isVisible={true}
|
|
||||||
isCollapsed={expandedStep !== 'advanced'}
|
|
||||||
isCompleted={hasFiles && hasResults}
|
|
||||||
onCollapsedClick={() => {
|
|
||||||
if (!hasFiles) return; // Only allow if files are selected
|
|
||||||
setExpandedStep(expandedStep === 'advanced' ? null : 'advanced');
|
|
||||||
}}
|
|
||||||
completedMessage={hasFiles && hasResults && expandedStep !== 'advanced' ? "OCR processing completed" : undefined}
|
|
||||||
>
|
|
||||||
<AdvancedOCRSettings
|
<AdvancedOCRSettings
|
||||||
advancedOptions={ocrParams.parameters.additionalOptions}
|
advancedOptions={ocrParams.parameters.additionalOptions}
|
||||||
ocrRenderType={ocrParams.parameters.ocrRenderType}
|
ocrRenderType={ocrParams.parameters.ocrRenderType}
|
||||||
onParameterChange={ocrParams.updateParameter}
|
onParameterChange={ocrParams.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Process Button - Available after all configuration */}
|
],
|
||||||
{hasValidSettings && !hasResults && (
|
executeButton: {
|
||||||
<Box mt="md">
|
text: t("ocr.operation.submit", "Process OCR and Review"),
|
||||||
<OperationButton
|
loadingText: t("loading"),
|
||||||
onClick={handleOCR}
|
onClick: handleOCR,
|
||||||
isLoading={ocrOperation.isLoading}
|
isVisible: hasValidSettings && !hasResults,
|
||||||
disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled}
|
disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
loadingText={t("loading")}
|
},
|
||||||
submitText="Process OCR and Review"
|
review: {
|
||||||
/>
|
isVisible: hasResults,
|
||||||
</Box>
|
operation: ocrOperation,
|
||||||
)}
|
title: t("ocr.results.title", "OCR Results"),
|
||||||
|
onFileClick: handleThumbnailClick,
|
||||||
{/* Results Step */}
|
},
|
||||||
<ToolStep
|
});
|
||||||
title="Results"
|
};
|
||||||
isVisible={resultsVisible}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{ocrOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{ocrOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={ocrOperation.errorMessage}
|
|
||||||
onClose={ocrOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ocrOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={ocrOperation.downloadUrl}
|
|
||||||
download={ocrOperation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{t("download", "Download")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={ocrOperation.isGeneratingThumbnails}
|
|
||||||
title="OCR Results"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OCR;
|
export default OCR;
|
||||||
|
@ -1,15 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||||
|
|
||||||
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
||||||
@ -27,27 +21,22 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const sanitizeOperation = useSanitizeOperation();
|
const sanitizeOperation = useSanitizeOperation();
|
||||||
|
|
||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(sanitizeParams.getEndpointName());
|
||||||
sanitizeParams.getEndpointName()
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sanitizeOperation.resetResults();
|
sanitizeOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [sanitizeParams.parameters, selectedFiles]);
|
}, [sanitizeParams.parameters]);
|
||||||
|
|
||||||
const handleSanitize = async () => {
|
const handleSanitize = async () => {
|
||||||
try {
|
try {
|
||||||
await sanitizeOperation.executeOperation(
|
await sanitizeOperation.executeOperation(sanitizeParams.parameters, selectedFiles);
|
||||||
sanitizeParams.parameters,
|
|
||||||
selectedFiles,
|
|
||||||
);
|
|
||||||
if (sanitizeOperation.files && onComplete) {
|
if (sanitizeOperation.files && onComplete) {
|
||||||
onComplete(sanitizeOperation.files);
|
onComplete(sanitizeOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'));
|
onError(error instanceof Error ? error.message : t("sanitize.error.generic", "Sanitization failed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -55,112 +44,54 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
sanitizeOperation.resetResults();
|
sanitizeOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
|
setCurrentMode("sanitize");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'sanitize');
|
sessionStorage.setItem("previousMode", "sanitize");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = sanitizeOperation.files.length > 0;
|
const hasResults = sanitizeOperation.files.length > 0;
|
||||||
const filesCollapsed = hasFiles;
|
const filesCollapsed = hasFiles || hasResults;
|
||||||
const settingsCollapsed = hasResults;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
|
|
||||||
return (
|
return createToolFlow({
|
||||||
<ToolStepContainer>
|
files: {
|
||||||
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
|
selectedFiles,
|
||||||
{/* Files Step */}
|
isCollapsed: filesCollapsed,
|
||||||
<ToolStep
|
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
title={t('sanitize.steps.files', 'Files')}
|
},
|
||||||
isVisible={true}
|
steps: [
|
||||||
isCollapsed={filesCollapsed}
|
{
|
||||||
isCompleted={filesCollapsed}
|
title: t("sanitize.steps.settings", "Settings"),
|
||||||
completedMessage={hasFiles ?
|
isCollapsed: settingsCollapsed,
|
||||||
selectedFiles.length === 1
|
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
||||||
? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
|
content: (
|
||||||
: t('filesSelected', 'Selected: {{count}} files', { count: selectedFiles.length })
|
<SanitizeSettings
|
||||||
: undefined}
|
parameters={sanitizeParams.parameters}
|
||||||
>
|
onParameterChange={sanitizeParams.updateParameter}
|
||||||
<FileStatusIndicator
|
disabled={endpointLoading}
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder={t('sanitize.files.placeholder', 'Select a PDF file in the main view to get started')}
|
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Settings Step */}
|
],
|
||||||
<ToolStep
|
executeButton: {
|
||||||
title={t('sanitize.steps.settings', 'Settings')}
|
text: t("sanitize.submit", "Sanitize PDF"),
|
||||||
isVisible={hasFiles}
|
isVisible: !hasResults,
|
||||||
isCollapsed={settingsCollapsed}
|
loadingText: t("loading"),
|
||||||
isCompleted={settingsCollapsed}
|
onClick: handleSanitize,
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
completedMessage={settingsCollapsed ? t('sanitize.completed', 'Sanitization completed') : undefined}
|
},
|
||||||
>
|
review: {
|
||||||
<Stack gap="sm">
|
isVisible: hasResults,
|
||||||
<SanitizeSettings
|
operation: sanitizeOperation,
|
||||||
parameters={sanitizeParams.parameters}
|
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
||||||
onParameterChange={sanitizeParams.updateParameter}
|
onFileClick: handleThumbnailClick,
|
||||||
disabled={endpointLoading}
|
},
|
||||||
/>
|
});
|
||||||
|
};
|
||||||
<OperationButton
|
|
||||||
onClick={handleSanitize}
|
|
||||||
isLoading={sanitizeOperation.isLoading}
|
|
||||||
disabled={!sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled}
|
|
||||||
loadingText={t("loading")}
|
|
||||||
submitText={t("sanitize.submit", "Sanitize PDF")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Results Step */}
|
|
||||||
<ToolStep
|
|
||||||
title={t('sanitize.steps.results', 'Results')}
|
|
||||||
isVisible={hasResults}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{sanitizeOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{sanitizeOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={sanitizeOperation.errorMessage}
|
|
||||||
onClose={sanitizeOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{sanitizeOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={sanitizeOperation.downloadUrl}
|
|
||||||
download={sanitizeOperation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{sanitizeOperation.files.length === 1
|
|
||||||
? t("download", "Download")
|
|
||||||
: t("downloadZip", "Download ZIP")
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={sanitizeOperation.files.map((file, index) => ({
|
|
||||||
file,
|
|
||||||
thumbnail: sanitizeOperation.thumbnails[index]
|
|
||||||
}))}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={sanitizeOperation.isGeneratingThumbnails}
|
|
||||||
title={t("sanitize.sanitizationResults", "Sanitization Results")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Sanitize;
|
export default Sanitize;
|
||||||
|
@ -1,16 +1,10 @@
|
|||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Button, Stack, Text } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
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 SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
|
|
||||||
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||||
@ -26,141 +20,77 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const splitOperation = useSplitOperation();
|
const splitOperation = useSplitOperation();
|
||||||
|
|
||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName());
|
||||||
splitParams.getEndpointName()
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [splitParams.parameters, selectedFiles]);
|
}, [splitParams.parameters]);
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
try {
|
try {
|
||||||
await splitOperation.executeOperation(
|
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
|
||||||
splitParams.parameters,
|
|
||||||
selectedFiles
|
|
||||||
);
|
|
||||||
if (splitOperation.files && onComplete) {
|
if (splitOperation.files && onComplete) {
|
||||||
onComplete(splitOperation.files);
|
onComplete(splitOperation.files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error instanceof Error ? error.message : 'Split operation failed');
|
onError(error instanceof Error ? error.message : "Split operation failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem('previousMode', 'split');
|
sessionStorage.setItem("previousMode", "split");
|
||||||
setCurrentMode('viewer');
|
setCurrentMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode('split');
|
setCurrentMode("split");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = splitOperation.downloadUrl !== null;
|
const hasResults = splitOperation.downloadUrl !== null;
|
||||||
const filesCollapsed = hasFiles;
|
const filesCollapsed = hasFiles || hasResults;
|
||||||
const settingsCollapsed = hasResults;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
|
|
||||||
const previewResults = useMemo(() =>
|
return createToolFlow({
|
||||||
splitOperation.files?.map((file, index) => ({
|
files: {
|
||||||
file,
|
selectedFiles,
|
||||||
thumbnail: splitOperation.thumbnails[index]
|
isCollapsed: filesCollapsed,
|
||||||
})) || [],
|
placeholder: "Select a PDF file in the main view to get started",
|
||||||
[splitOperation.files, splitOperation.thumbnails]
|
},
|
||||||
);
|
steps: [
|
||||||
|
{
|
||||||
return (
|
title: "Settings",
|
||||||
<ToolStepContainer>
|
isCollapsed: settingsCollapsed,
|
||||||
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
|
||||||
{/* Files Step */}
|
content: (
|
||||||
<ToolStep
|
<SplitSettings
|
||||||
title="Files"
|
parameters={splitParams.parameters}
|
||||||
isVisible={true}
|
onParameterChange={splitParams.updateParameter}
|
||||||
isCollapsed={filesCollapsed}
|
disabled={endpointLoading}
|
||||||
isCompleted={filesCollapsed}
|
|
||||||
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
|
|
||||||
>
|
|
||||||
<FileStatusIndicator
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
placeholder="Select a PDF file in the main view to get started"
|
|
||||||
/>
|
/>
|
||||||
</ToolStep>
|
),
|
||||||
|
},
|
||||||
{/* Settings Step */}
|
],
|
||||||
<ToolStep
|
executeButton: {
|
||||||
title="Settings"
|
text: t("split.submit", "Split PDF"),
|
||||||
isVisible={hasFiles}
|
loadingText: t("loading"),
|
||||||
isCollapsed={settingsCollapsed}
|
onClick: handleSplit,
|
||||||
isCompleted={settingsCollapsed}
|
isVisible: !hasResults,
|
||||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled,
|
||||||
completedMessage={settingsCollapsed ? "Split completed" : undefined}
|
},
|
||||||
>
|
review: {
|
||||||
<Stack gap="sm">
|
isVisible: hasResults,
|
||||||
<SplitSettings
|
operation: splitOperation,
|
||||||
parameters={splitParams.parameters}
|
title: "Split Results",
|
||||||
onParameterChange={splitParams.updateParameter}
|
onFileClick: handleThumbnailClick,
|
||||||
disabled={endpointLoading}
|
},
|
||||||
/>
|
});
|
||||||
|
};
|
||||||
{splitParams.parameters.mode && (
|
|
||||||
<OperationButton
|
|
||||||
onClick={handleSplit}
|
|
||||||
isLoading={splitOperation.isLoading}
|
|
||||||
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled}
|
|
||||||
loadingText={t("loading")}
|
|
||||||
submitText={t("split.submit", "Split PDF")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
|
|
||||||
{/* Results Step */}
|
|
||||||
<ToolStep
|
|
||||||
title="Results"
|
|
||||||
isVisible={hasResults}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{splitOperation.status && (
|
|
||||||
<Text size="sm" c="dimmed">{splitOperation.status}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorNotification
|
|
||||||
error={splitOperation.errorMessage}
|
|
||||||
onClose={splitOperation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{splitOperation.downloadUrl && (
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={splitOperation.downloadUrl}
|
|
||||||
download="split_output.zip"
|
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
|
||||||
fullWidth
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{t("download", "Download")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ResultsPreview
|
|
||||||
files={previewResults}
|
|
||||||
onFileClick={handleThumbnailClick}
|
|
||||||
isGeneratingThumbnails={splitOperation.isGeneratingThumbnails}
|
|
||||||
title="Split Results"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</ToolStep>
|
|
||||||
</Stack>
|
|
||||||
</ToolStepContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Split;
|
export default Split;
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from "react";
|
||||||
import { BaseToolProps } from '../types/tool';
|
import { BaseToolProps } from "../types/tool";
|
||||||
|
|
||||||
const SwaggerUI: React.FC<BaseToolProps> = () => {
|
const SwaggerUI: React.FC<BaseToolProps> = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect to Swagger UI
|
// Redirect to Swagger UI
|
||||||
window.open('/swagger-ui/5.21.0/index.html', '_blank');
|
window.open("/swagger-ui/5.21.0/index.html", "_blank");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
<div style={{ textAlign: "center", padding: "2rem" }}>
|
||||||
<p>Opening Swagger UI in a new tab...</p>
|
<p>Opening Swagger UI in a new tab...</p>
|
||||||
<p>If it didn't open automatically, <a href="/swagger-ui/5.21.0/index.html" target="_blank" rel="noopener noreferrer">click here</a></p>
|
<p>
|
||||||
|
If it didn't open automatically,{" "}
|
||||||
|
<a href="/swagger-ui/5.21.0/index.html" target="_blank" rel="noopener noreferrer">
|
||||||
|
click here
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SwaggerUI;
|
export default SwaggerUI;
|
||||||
|
@ -66,6 +66,7 @@ export interface FileContextState {
|
|||||||
// Core file management
|
// Core file management
|
||||||
activeFiles: File[];
|
activeFiles: File[];
|
||||||
processedFiles: Map<File, ProcessedFile>;
|
processedFiles: Map<File, ProcessedFile>;
|
||||||
|
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
|
||||||
|
|
||||||
// Current navigation state
|
// Current navigation state
|
||||||
currentMode: ModeType;
|
currentMode: ModeType;
|
||||||
@ -106,6 +107,14 @@ export interface FileContextActions {
|
|||||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
||||||
clearAllFiles: () => void;
|
clearAllFiles: () => void;
|
||||||
|
|
||||||
|
// File pinning
|
||||||
|
pinFile: (file: File) => void;
|
||||||
|
unpinFile: (file: File) => void;
|
||||||
|
isFilePinned: (file: File) => boolean;
|
||||||
|
|
||||||
|
// File consumption (replace unpinned files with outputs)
|
||||||
|
consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
setCurrentMode: (mode: ModeType) => void;
|
setCurrentMode: (mode: ModeType) => void;
|
||||||
|
@ -15,9 +15,7 @@ export interface ToolStepConfig {
|
|||||||
type: ToolStepType;
|
type: ToolStepType;
|
||||||
title: string;
|
title: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCompleted: boolean;
|
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
completedMessage?: string;
|
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user