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:
ConnorYoh 2025-08-15 14:43:30 +01:00 committed by GitHub
parent 1468df3e21
commit 4c17c520d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1474 additions and 1274 deletions

View File

@ -39,7 +39,7 @@
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
@ -2386,10 +2386,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}

View File

@ -65,7 +65,7 @@
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",

View File

@ -1,9 +1,9 @@
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 { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import FilePreview from './FilePreview';
import FilePreview from '../shared/FilePreview';
import FileInfoCard from './FileInfoCard';
import CompactFileDetails from './CompactFileDetails';
@ -76,15 +76,18 @@ const FileDetails: React.FC<FileDetailsProps> = ({
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
{/* Section 1: Thumbnail Preview */}
<FilePreview
currentFile={currentFile}
thumbnail={getCurrentThumbnail()}
numberOfFiles={selectedFiles.length}
isAnimating={isAnimating}
modalHeight={modalHeight}
onPrevious={handlePrevious}
onNext={handleNext}
/>
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
<FilePreview
file={currentFile}
thumbnail={getCurrentThumbnail()}
showStacking={true}
showNavigation={true}
totalFiles={selectedFiles.length}
isAnimating={isAnimating}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</Box>
{/* Section 2: File Details */}
<FileInfoCard

View File

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

View File

@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
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 styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
import { useFileContext } from '../../contexts/FileContext';
interface FileItem {
id: string;
@ -66,6 +69,10 @@ const FileThumbnail = ({
}: FileThumbnailProps) => {
const { t } = useTranslation();
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) => {
if (bytes === 0) return '0 B';
@ -301,6 +308,32 @@ const FileThumbnail = ({
</ActionIcon>
</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">
<ActionIcon
size="md"

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

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

View File

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

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

View File

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

View File

@ -39,7 +39,7 @@ export default function ToolPanel() {
}`}
style={{
width: isPanelVisible ? '20rem' : '0',
padding: isPanelVisible ? '0.5rem' : '0'
padding: '0'
}}
>
<div

View File

@ -1,14 +1,7 @@
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";
interface CompressParameters {
compressionMethod: 'quality' | 'filesize';
compressionLevel: number;
fileSizeValue: string;
fileSizeUnit: 'KB' | 'MB';
grayscale: boolean;
}
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
interface CompressSettingsProps {
parameters: CompressParameters;
@ -22,6 +15,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
return (
<Stack gap="md">
<Divider ml='-md'></Divider>
{/* Compression Method */}
<Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text>
@ -54,6 +49,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && (
<Stack gap="sm">
<Divider />
<Text size="sm" fw={500}>Compression Level</Text>
<div style={{ position: 'relative' }}>
<input
@ -68,7 +64,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
onTouchStart={() => setIsSliding(true)}
onTouchEnd={() => setIsSliding(false)}
disabled={disabled}
style={{
style={{
width: '100%',
height: '6px',
borderRadius: '3px',
@ -107,6 +103,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Stack>
)}
<Divider/>
{/* File Size Input */}
{parameters.compressionMethod === 'filesize' && (
<Stack gap="sm">
@ -141,7 +139,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Compression Options */}
<Stack gap="sm">
<label
<label
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"
>
@ -158,4 +156,4 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
);
};
export default CompressSettings;
export default CompressSettings;

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

View File

@ -3,17 +3,15 @@ import { Text } from '@mantine/core';
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
isCompleted?: boolean;
placeholder?: string;
showFileName?: boolean;
}
const FileStatusIndicator = ({
selectedFiles = [],
isCompleted = false,
placeholder = "Select a PDF file in the main view to get started",
showFileName = true
placeholder = "Select a PDF file in the main view to get started"
}: FileStatusIndicatorProps) => {
// Only show content when no files are selected
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
@ -22,19 +20,8 @@ const FileStatusIndicator = ({
);
}
if (isCompleted) {
return (
<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>
);
// Return nothing when files are selected
return null;
}
export default FileStatusIndicator;

View 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")}
/>
));
}

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

View File

@ -24,7 +24,7 @@ const OperationButton = ({
submitText,
variant = 'filled',
color = 'blue',
fullWidth = true,
fullWidth = false,
mt = 'md',
type = 'button',
'data-testid': dataTestId
@ -36,14 +36,17 @@ const OperationButton = ({
type={type}
onClick={onClick}
fullWidth={fullWidth}
mr='md'
ml='md'
mt={mt}
loading={isLoading}
disabled={disabled}
variant={variant}
color={color}
data-testid={dataTestId}
style={{ minHeight: '2.5rem' }}
>
{isLoading
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
@ -51,4 +54,4 @@ const OperationButton = ({
);
}
export default OperationButton;
export default OperationButton;

View File

@ -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;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
files: ReviewFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
@ -18,14 +21,17 @@ const ResultsPreview = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ResultsPreviewProps) => {
const formatSize = (size: number) => {
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
const [currentIndex, setCurrentIndex] = useState(0);
const handlePrevious = () => {
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) {
@ -36,78 +42,59 @@ const ResultsPreview = ({
);
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }} data-testid="results-preview-container">
{title && (
<Text fw={500} size="md" mb="sm" data-testid="results-preview-title">
{title} ({files.length} files)
</Text>
)}
if (isGeneratingThumbnails) {
return (
<Center p="lg" data-testid="review-panel-loading">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
);
}
{isGeneratingThumbnails ? (
<Center p="lg" data-testid="results-preview-loading">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid data-testid="results-preview-grid">
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
data-testid={`results-preview-thumbnail-${index}`}
style={{
textAlign: 'center',
height: '10rem',
width:'5rem',
display: 'flex',
flexDirection: 'column',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'all 0.2s ease'
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.thumbnail ? (
<Image
src={result.thumbnail}
alt={`Preview of ${result.file.name}`}
style={{
maxWidth: '100%',
maxHeight: '9rem',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</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>
)}
const currentFile = files[currentIndex];
if (!currentFile) return null;
return (
<Box p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.5rem', maxWidth: '100%' }} data-testid="review-panel-container">
{/* File name at the top */}
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
<Text
size="sm"
fw={500}
style={{
wordBreak: 'break-word',
lineHeight: 1.4
}}
title={currentFile.file.name}
>
{currentFile.file.name}
</Text>
</Box>
<Flex gap="md" align="flex-start" style={{ minHeight: '7.5rem', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
<Box style={{ width: '6.25rem', height: '7.5rem', flexShrink: 0 }}>
<FilePreview
file={currentFile.file}
thumbnail={currentFile.thumbnail}
showHoverOverlay={true}
onFileClick={onFileClick ? (file) => file && onFileClick(file as File) : undefined}
/>
</Box>
<FileMetadata file={currentFile.file} />
</Flex>
{/* Navigation controls */}
<NavigationControls
currentIndex={currentIndex}
totalFiles={files.length}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</Box>
);
}
};
export default ResultsPreview;

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

View File

@ -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>
);
}

View File

@ -1,13 +1,14 @@
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 ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep';
interface ToolStepContextType {
visibleStepCount: number;
getStepNumber: () => number;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
@ -16,12 +17,13 @@ export interface ToolStepProps {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
isCompleted?: boolean;
onCollapsedClick?: () => void;
children?: React.ReactNode;
completedMessage?: string;
helpText?: string;
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?: {
content?: React.ReactNode;
tips?: TooltipTip[];
@ -56,7 +58,7 @@ const renderTooltipTitle = (
</Tooltip>
);
}
return (
<Text fw={500} size="lg">
{title}
@ -68,39 +70,40 @@ const ToolStep = ({
title,
isVisible = true,
isCollapsed = false,
isCompleted = false,
onCollapsedClick,
children,
completedMessage,
helpText,
showNumber,
_stepNumber,
_noPadding,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber, parent]);
const stepNumber = parent?.getStepNumber?.() || 1;
const stepNumber = _stepNumber;
return (
<Paper
p="md"
withBorder
style={{
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
>
<div>
<div
style={{
padding: '1rem',
opacity: isCollapsed ? 0.8 : 1,
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
transition: 'opacity 0.2s ease, color 0.2s ease'
}}
>
{/* Chevron icon to collapse/expand the step */}
<Flex
align="center"
justify="space-between"
<Flex
align="center"
justify="space-between"
mb="sm"
style={{
cursor: onCollapsedClick ? 'pointer' : 'default'
@ -115,37 +118,24 @@ const ToolStep = ({
)}
{renderTooltipTitle(title, tooltip, isCollapsed)}
</Flex>
{isCollapsed ? (
<ChevronRightIcon style={{
fontSize: '1.2rem',
<ChevronRightIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
) : (
<ExpandMoreIcon style={{
fontSize: '1.2rem',
<ExpandMoreIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
)}
</Flex>
{isCollapsed ? (
<Box>
{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">
{!isCollapsed && (
<Stack gap="md" pl={_noPadding ? 0 : "md"}>
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
@ -154,36 +144,77 @@ const ToolStep = ({
{children}
</Stack>
)}
</Paper>
</div>
<Divider style={{ marginLeft: '1rem', marginRight: '-0.5rem' }} />
</div>
);
}
export interface ToolStepContainerProps {
children: React.ReactNode;
// ToolStepFactory for creating numbered steps
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) => {
const stepCounterRef = useRef(0);
// Count visible ToolStep children
// Context provider wrapper for tools using the factory
export function ToolStepProvider({ children }: { children: React.ReactNode }) {
// Count visible steps from children that are ToolStep elements
const visibleStepCount = useMemo(() => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = (child.props as ToolStepProps).isVisible !== false;
if (isVisible) count++;
const props = child.props as ToolStepProps;
const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
if (isVisible && !excludeFromCount) count++;
}
});
return count;
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount,
getStepNumber: () => ++stepCounterRef.current
visibleStepCount
}), [visibleStepCount]);
stepCounterRef.current = 0;
return (
<ToolStepContext.Provider value={contextValue}>
{children}
@ -191,4 +222,6 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
);
}
export type { FilesToolStepProps } from './FilesToolStep';
export type { ReviewToolStepProps } from './ReviewToolStep';
export default ToolStep;

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

View File

@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = {
const initialState: FileContextState = {
activeFiles: [],
processedFiles: new Map(),
pinnedFiles: new Set(),
currentMode: 'pageEditor',
currentView: 'fileEditor', // Legacy field
currentTool: null, // Legacy field
@ -77,6 +78,9 @@ type FileContextAction =
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
| { 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: 'LOAD_STATE'; payload: Partial<FileContextState> };
@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
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':
return {
...initialState
@ -560,6 +601,46 @@ export function FileContextProvider({
dispatch({ type: 'CLEAR_SELECTIONS' });
}, [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
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
@ -783,6 +864,10 @@ export function FileContextProvider({
removeFiles,
replaceFile,
clearAllFiles,
pinFile,
unpinFile,
isFilePinned,
consumeFiles,
setCurrentMode,
setCurrentView,
setCurrentTool,

View File

@ -1,8 +1,9 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import {
MaxFiles,
FileSelectionContextValue
} from '../types/tool';
import { useFileContext } from './FileContext';
interface FileSelectionProviderProps {
children: ReactNode;
@ -11,10 +12,23 @@ interface FileSelectionProviderProps {
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
const { activeFiles } = useFileContext();
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
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(() => {
setSelectedFiles([]);
}, []);

View File

@ -15,11 +15,11 @@ interface ToolWorkflowState {
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent';
readerMode: boolean;
// File/Preview State
previewFile: File | null;
pageEditorFunctions: PageEditorFunctions | null;
// Search State
searchQuery: string;
}
@ -72,7 +72,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedToolKey: string | null;
selectedTool: Tool | null;
toolRegistry: any; // From useToolManagement
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
@ -80,16 +80,16 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
setPreviewFile: (file: File | null) => void;
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
setSearchQuery: (query: string) => void;
// Tool Actions
selectTool: (toolId: string) => void;
clearToolSelection: () => void;
// Workflow Actions (compound actions)
handleToolSelect: (toolId: string) => void;
handleBackToTools: () => void;
handleReaderToggle: () => void;
// Computed values
filteredTools: [string, any][]; // Filtered by search
isPanelVisible: boolean;
@ -106,7 +106,7 @@ interface ToolWorkflowProviderProps {
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
// Tool management hook
const {
selectedToolKey,
@ -181,7 +181,7 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
);
}, [toolRegistry, state.searchQuery]);
const isPanelVisible = useMemo(() =>
const isPanelVisible = useMemo(() =>
state.sidebarsVisible && !state.readerMode,
[state.sidebarsVisible, state.readerMode]
);
@ -193,7 +193,7 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
selectedToolKey,
selectedTool,
toolRegistry,
// Actions
setSidebarsVisible,
setLeftPanelView,
@ -203,12 +203,12 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
setSearchQuery,
selectTool,
clearToolSelection,
// Workflow Actions
handleToolSelect,
handleBackToTools,
handleReaderToggle,
// Computed
filteredTools,
isPanelVisible,
@ -232,5 +232,5 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
// Convenience exports for specific use cases (optional - components can use useToolWorkflow directly)
export const useToolSelection = useToolWorkflow;
export const useToolPanelState = useToolWorkflow;
export const useWorkbenchState = useToolWorkflow;
export const useToolPanelState = useToolWorkflow;
export const useWorkbenchState = useToolWorkflow;

View File

@ -37,8 +37,7 @@ function stripExt(name: string): string {
return i > 0 ? name.slice(0, i) : name;
}
// Signature must be (file, params)
const buildFormData = (file: File, parameters: OCRParameters): FormData => {
const buildFormData = (parameters: OCRParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
parameters.languages.forEach((lang) => formData.append('languages', lang));
@ -99,7 +98,7 @@ export const useOCROperation = () => {
const ocrConfig: ToolOperationConfig<OCRParameters> = {
operationType: 'ocr',
endpoint: '/api/v1/misc/ocr-pdf',
buildFormData: buildFormData as any /* FIX ME */,
buildFormData,
filePrefix: 'ocr_',
multiFileEndpoint: false, // Process files individually
responseHandler, // use shared flow

View File

@ -5,7 +5,7 @@ import type { ProcessingProgress } from './useToolState';
export interface ApiCallsConfig<TParams = void> {
endpoint: string | ((params: TParams) => string);
buildFormData: (file: File, params: TParams) => FormData;
buildFormData: (params: TParams, file: File) => FormData;
filePrefix: string;
responseHandler?: ResponseHandler;
}
@ -34,7 +34,7 @@ export const useToolApiCalls = <TParams = void>() => {
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
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 response = await axios.post(endpoint, formData, {
responseType: 'blob',

View File

@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@ -170,7 +170,7 @@ export const useToolOperation = <TParams = void>(
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
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,
responseHandler: config.responseHandler
};
@ -198,8 +198,8 @@ export const useToolOperation = <TParams = void>(
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Add to file context
await addFiles(processedFiles);
// Consume input files and add output files (will replace unpinned inputs)
await consumeFiles(validFiles, processedFiles);
markOperationApplied(fileId, operationId);
}

View 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]);
}

View File

@ -1,16 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Button, Stack, Text } from "@mantine/core";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
@ -39,154 +33,84 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => {
addPasswordOperation.resetResults();
onPreviewFile?.(null);
}, [addPasswordParams.parameters, selectedFiles]);
}, [addPasswordParams.parameters]);
const handleAddPassword = async () => {
try {
await addPasswordOperation.executeOperation(
addPasswordParams.fullParameters,
selectedFiles
);
await addPasswordOperation.executeOperation(addPasswordParams.fullParameters, selectedFiles);
if (addPasswordOperation.files && onComplete) {
onComplete(addPasswordOperation.files);
}
} catch (error) {
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) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'addPassword');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "addPassword");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
addPasswordOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('addPassword');
setCurrentMode("addPassword");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const passwordsCollapsed = hasResults;
const passwordsCollapsed = !hasFiles || hasResults;
const permissionsCollapsed = collapsedPermissions || hasResults;
const previewResults = useMemo(() =>
addPasswordOperation.files?.map((file, index) => ({
file,
thumbnail: addPasswordOperation.thumbnails[index]
})) || [],
[addPasswordOperation.files, addPasswordOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<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}
>
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles || hasResults,
},
steps: [
{
title: t("addPassword.passwords.stepTitle", "Passwords & Encryption"),
isCollapsed: passwordsCollapsed,
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
tooltip: addPasswordTips,
content: (
<AddPasswordSettings
parameters={addPasswordParams.parameters}
onParameterChange={addPasswordParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Permissions Step */}
<ToolStep
title={t('changePermissions.title', 'Document Permissions')}
isVisible={hasFiles}
isCollapsed={permissionsCollapsed}
isCompleted={permissionsCollapsed}
onCollapsedClick={hasResults ? handleSettingsReset : () => setCollapsedPermissions(!collapsedPermissions)}
tooltip={addPasswordPermissionsTips}
>
),
},
{
title: t("changePermissions.title", "Document Permissions"),
isCollapsed: permissionsCollapsed,
onCollapsedClick: hasResults ? handleSettingsReset : () => setCollapsedPermissions(!collapsedPermissions),
content: (
<ChangePermissionsSettings
parameters={addPasswordParams.permissions.parameters}
onParameterChange={addPasswordParams.permissions.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
<Box mt="md">
<OperationButton
onClick={handleAddPassword}
isLoading={addPasswordOperation.isLoading}
disabled={!addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t('loading')}
submitText={t('addPassword.submit', 'Encrypt')}
/>
</Box>
{/* Results Step */}
<ToolStep
title={t('results.title', 'Results')}
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>
);
}
),
},
],
executeButton: {
text: t("addPassword.submit", "Encrypt"),
isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleAddPassword,
disabled: !addPasswordParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: addPasswordOperation,
title: t("addPassword.results.title", "Encrypted PDFs"),
onFileClick: handleThumbnailClick,
},
});
};
export default AddPassword;

View File

@ -1,16 +1,10 @@
import { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
@ -34,137 +28,73 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps
useEffect(() => {
changePermissionsOperation.resetResults();
onPreviewFile?.(null);
}, [changePermissionsParams.parameters, selectedFiles]);
}, [changePermissionsParams.parameters]);
const handleChangePermissions = async () => {
try {
await changePermissionsOperation.executeOperation(
changePermissionsParams.parameters,
selectedFiles
);
await changePermissionsOperation.executeOperation(changePermissionsParams.parameters, selectedFiles);
if (changePermissionsOperation.files && onComplete) {
onComplete(changePermissionsOperation.files);
}
} catch (error) {
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) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'changePermissions');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "changePermissions");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
changePermissionsOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('changePermissions');
setCurrentMode("changePermissions");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const settingsCollapsed = !hasFiles || hasResults;
const previewResults = useMemo(() =>
changePermissionsOperation.files?.map((file, index) => ({
file,
thumbnail: changePermissionsOperation.thumbnails[index]
})) || [],
[changePermissionsOperation.files, changePermissionsOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<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')}
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles || hasResults,
},
steps: [
{
title: t("changePermissions.title", "Document Permissions"),
isCollapsed: settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
tooltip: changePermissionsTips,
content: (
<ChangePermissionsSettings
parameters={changePermissionsParams.parameters}
onParameterChange={changePermissionsParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Permissions Step */}
<ToolStep
title={t('changePermissions.title', 'Document Permissions')}
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? t('changePermissions.completed', 'Permissions changed') : undefined}
tooltip={changePermissionsTips}
>
<Stack gap="sm">
<ChangePermissionsSettings
parameters={changePermissionsParams.parameters}
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>
);
}
),
},
],
executeButton: {
text: t("changePermissions.submit", "Change Permissions"),
isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleChangePermissions,
disabled: !changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: changePermissionsOperation,
title: t("changePermissions.results.title", "Modified PDFs"),
onFileClick: handleThumbnailClick,
},
});
};
export default ChangePermissions;

View File

@ -1,16 +1,10 @@
import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import CompressSettings from "../components/tools/compress/CompressSettings";
@ -34,138 +28,71 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => {
compressOperation.resetResults();
onPreviewFile?.(null);
}, [compressParams.parameters, selectedFiles]);
}, [compressParams.parameters]);
const handleCompress = async () => {
try {
await compressOperation.executeOperation(
compressParams.parameters,
selectedFiles
);
await compressOperation.executeOperation(compressParams.parameters, selectedFiles);
if (compressOperation.files && onComplete) {
onComplete(compressOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Compress operation failed');
onError(error instanceof Error ? error.message : "Compress operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'compress');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "compress");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
compressOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('compress');
setCurrentMode("compress");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const settingsCollapsed = !hasFiles || hasResults;
const previewResults = useMemo(() =>
compressOperation.files?.map((file, index) => ({
file,
thumbnail: compressOperation.thumbnails[index]
})) || [],
[compressOperation.files, compressOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title="Files"
isVisible={true}
isCollapsed={filesCollapsed}
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"
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles && !hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
tooltip: compressTips,
content: (
<CompressSettings
parameters={compressParams.parameters}
onParameterChange={compressParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title="Settings"
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
tooltip={compressTips}
>
<Stack gap="sm">
<CompressSettings
parameters={compressParams.parameters}
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>
);
}
),
},
],
executeButton: {
text: t("compress.submit", "Compress"),
isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleCompress,
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: compressOperation,
title: t("compress.title", "Compression Results"),
onFileClick: handleThumbnailClick,
},
});
};
export default Compress;

View File

@ -1,16 +1,10 @@
import React, { useEffect, useMemo, useRef } from "react";
import { Button, Stack, Text } from "@mantine/core";
import React, { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
@ -27,15 +21,13 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const convertParams = useConvertParameters();
const convertOperation = useConvertOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
convertParams.getEndpointName()
);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
const scrollToBottom = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
behavior: "smooth",
});
}
};
@ -79,133 +71,67 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleConvert = async () => {
try {
await convertOperation.executeOperation(
convertParams.parameters,
selectedFiles
);
await convertOperation.executeOperation(convertParams.parameters, selectedFiles);
if (convertOperation.files && onComplete) {
onComplete(convertOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Convert operation failed');
onError(error instanceof Error ? error.message : "Convert operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'convert');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "convert");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
convertOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('convert');
setCurrentMode("convert");
};
const previewResults = useMemo(() =>
convertOperation.files?.map((file, index) => ({
file,
thumbnail: convertOperation.thumbnails[index]
})) || [],
[convertOperation.files, convertOperation.thumbnails]
);
return (
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
<ToolStepContainer>
<Stack gap="sm" p="sm">
<ToolStep
title={t("convert.files", "Files")}
isVisible={true}
isCollapsed={filesCollapsed}
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
return createToolFlow({
files: {
selectedFiles,
isCollapsed: filesCollapsed,
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
},
steps: [
{
title: t("convert.settings", "Settings"),
isCollapsed: settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
content: (
<ConvertSettings
parameters={convertParams.parameters}
onParameterChange={convertParams.updateParameter}
getAvailableToExtensions={convertParams.getAvailableToExtensions}
selectedFiles={selectedFiles}
placeholder={t("convert.selectFilesPlaceholder", "Select files in the main view to get started")}
disabled={endpointLoading}
/>
</ToolStep>
<ToolStep
title={t("convert.settings", "Settings")}
isVisible={true}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? t("convert.conversionCompleted", "Conversion completed") : undefined}
>
<Stack gap="sm">
<ConvertSettings
parameters={convertParams.parameters}
onParameterChange={convertParams.updateParameter}
getAvailableToExtensions={convertParams.getAvailableToExtensions}
selectedFiles={selectedFiles}
disabled={endpointLoading}
/>
{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>
);
),
},
],
executeButton: {
text: t("convert.convertFiles", "Convert Files"),
loadingText: t("convert.converting", "Converting..."),
onClick: handleConvert,
isVisible: !hasResults,
disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled,
testId: "convert-button",
},
review: {
isVisible: hasResults,
operation: convertOperation,
title: t("convert.conversionResults", "Conversion Results"),
onFileClick: handleThumbnailClick,
testId: "conversion-results",
},
});
};
export default Convert;

View File

@ -15,12 +15,7 @@ export interface MergePdfPanelProps {
updateParams: (newParams: Partial<MergePdfPanelProps["params"]>) => void;
}
const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
files,
setDownloadUrl,
params,
updateParams,
}) => {
const MergePdfPanel: React.FC<MergePdfPanelProps> = ({ files, setDownloadUrl, params, updateParams }) => {
const { t } = useTranslation();
const [selectedFiles, setSelectedFiles] = useState<boolean[]>([]);
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 actualFile = new File([blob], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
lastModified: storedFile.lastModified,
});
formData.append("fileInput", actualFile);
}
@ -83,9 +78,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
};
const handleCheckboxChange = (index: number) => {
setSelectedFiles((prev) =>
prev.map((selected, i) => (i === index ? !selected : selected))
);
setSelectedFiles((prev) => prev.map((selected, i) => (i === index ? !selected : selected)));
};
const selectedCount = selectedFiles.filter(Boolean).length;
@ -96,7 +89,9 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
return (
<Stack align="center" justify="center" h={200}>
<Loader size="md" />
<Text size="sm" c="dimmed">{t("loading", "Loading...")}</Text>
<Text size="sm" c="dimmed">
{t("loading", "Loading...")}
</Text>
</Stack>
);
}
@ -112,55 +107,42 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
}
return (
<Stack>
<Text fw={500} size="lg">{t("merge.header")}</Text>
<Stack gap={4}>
{files.map((file, index) => (
<Group key={index} gap="xs">
<Checkbox
checked={selectedFiles[index] || false}
onChange={() => handleCheckboxChange(index)}
/>
<Text size="sm">{file.name}</Text>
</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>
<Text fw={500} size="lg">
{t("merge.header")}
</Text>
<Stack gap={4}>
{files.map((file, index) => (
<Group key={index} gap="xs">
<Checkbox checked={selectedFiles[index] || false} onChange={() => handleCheckboxChange(index)} />
<Text size="sm">{file.name}</Text>
</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>
);
};

View File

@ -1,16 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button, Stack, Text, Box } from "@mantine/core";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import OCRSettings from "../components/tools/ocr/OCRSettings";
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
@ -30,7 +24,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const ocrTips = useOCRTips();
// 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");
@ -41,11 +35,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => {
ocrOperation.resetResults();
onPreviewFile?.(null);
}, [ocrParams.parameters, selectedFiles]);
}, [ocrParams.parameters]);
useEffect(() => {
if (selectedFiles.length > 0 && expandedStep === 'files') {
setExpandedStep('settings');
if (selectedFiles.length > 0 && expandedStep === "files") {
setExpandedStep("settings");
}
}, [selectedFiles.length, expandedStep]);
@ -58,161 +52,88 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleOCR = async () => {
try {
await ocrOperation.executeOperation(
ocrParams.parameters,
selectedFiles
);
await ocrOperation.executeOperation(ocrParams.parameters, selectedFiles);
if (ocrOperation.files && onComplete) {
onComplete(ocrOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'OCR operation failed');
onError(error instanceof Error ? error.message : "OCR operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'ocr');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "ocr");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
ocrOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("ocr");
};
// Step visibility and collapse logic
const filesVisible = true;
const settingsVisible = true;
const resultsVisible = hasResults;
const settingsCollapsed = expandedStep !== "settings";
const filesCollapsed = expandedStep !== 'files';
const settingsCollapsed = expandedStep !== 'settings';
const previewResults = useMemo(() =>
ocrOperation.files?.map((file: File, index: number) => ({
file,
thumbnail: ocrOperation.thumbnails[index]
})) || [],
[ocrOperation.files, ocrOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title="Files"
isVisible={filesVisible}
isCollapsed={hasFiles ? filesCollapsed : false}
isCompleted={hasFiles}
onCollapsedClick={undefined}
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"
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles || hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: !hasFiles || settingsCollapsed,
onCollapsedClick: hasResults
? handleSettingsReset
: () => {
if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === "settings" ? null : "settings");
},
tooltip: ocrTips,
content: (
<OCRSettings
parameters={ocrParams.parameters}
onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title="Settings"
isVisible={settingsVisible}
isCollapsed={settingsCollapsed}
isCompleted={hasFiles && hasValidSettings}
onCollapsedClick={() => {
if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
}}
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}
>
),
},
{
title: "Advanced",
isCollapsed: expandedStep !== "advanced",
onCollapsedClick: hasResults
? handleSettingsReset
: () => {
if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === "advanced" ? null : "advanced");
},
content: (
<AdvancedOCRSettings
advancedOptions={ocrParams.parameters.additionalOptions}
ocrRenderType={ocrParams.parameters.ocrRenderType}
onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Process Button - Available after all configuration */}
{hasValidSettings && !hasResults && (
<Box mt="md">
<OperationButton
onClick={handleOCR}
isLoading={ocrOperation.isLoading}
disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")}
submitText="Process OCR and Review"
/>
</Box>
)}
{/* 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>
);
}
),
},
],
executeButton: {
text: t("ocr.operation.submit", "Process OCR and Review"),
loadingText: t("loading"),
onClick: handleOCR,
isVisible: hasValidSettings && !hasResults,
disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: ocrOperation,
title: t("ocr.results.title", "OCR Results"),
onFileClick: handleThumbnailClick,
},
});
};
export default OCR;

View File

@ -1,15 +1,9 @@
import { useEffect } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
@ -27,27 +21,22 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const sanitizeOperation = useSanitizeOperation();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
sanitizeParams.getEndpointName()
);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(sanitizeParams.getEndpointName());
useEffect(() => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
}, [sanitizeParams.parameters, selectedFiles]);
}, [sanitizeParams.parameters]);
const handleSanitize = async () => {
try {
await sanitizeOperation.executeOperation(
sanitizeParams.parameters,
selectedFiles,
);
await sanitizeOperation.executeOperation(sanitizeParams.parameters, selectedFiles);
if (sanitizeOperation.files && onComplete) {
onComplete(sanitizeOperation.files);
}
} catch (error) {
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 = () => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("sanitize");
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'sanitize');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "sanitize");
setCurrentMode("viewer");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = sanitizeOperation.files.length > 0;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const filesCollapsed = hasFiles || hasResults;
const settingsCollapsed = !hasFiles || hasResults;
return (
<ToolStepContainer>
<Stack gap="sm" h="94vh" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('sanitize.steps.files', 'Files')}
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ?
selectedFiles.length === 1
? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
: t('filesSelected', 'Selected: {{count}} files', { count: selectedFiles.length })
: undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder={t('sanitize.files.placeholder', 'Select a PDF file in the main view to get started')}
return createToolFlow({
files: {
selectedFiles,
isCollapsed: filesCollapsed,
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
title: t("sanitize.steps.settings", "Settings"),
isCollapsed: settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
content: (
<SanitizeSettings
parameters={sanitizeParams.parameters}
onParameterChange={sanitizeParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title={t('sanitize.steps.settings', 'Settings')}
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? t('sanitize.completed', 'Sanitization completed') : undefined}
>
<Stack gap="sm">
<SanitizeSettings
parameters={sanitizeParams.parameters}
onParameterChange={sanitizeParams.updateParameter}
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>
);
}
),
},
],
executeButton: {
text: t("sanitize.submit", "Sanitize PDF"),
isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleSanitize,
disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: sanitizeOperation,
title: t("sanitize.sanitizationResults", "Sanitization Results"),
onFileClick: handleThumbnailClick,
},
});
};
export default Sanitize;

View File

@ -1,16 +1,10 @@
import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
@ -26,141 +20,77 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const splitOperation = useSplitOperation();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
splitParams.getEndpointName()
);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName());
useEffect(() => {
splitOperation.resetResults();
onPreviewFile?.(null);
}, [splitParams.parameters, selectedFiles]);
}, [splitParams.parameters]);
const handleSplit = async () => {
try {
await splitOperation.executeOperation(
splitParams.parameters,
selectedFiles
);
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
if (splitOperation.files && onComplete) {
onComplete(splitOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Split operation failed');
onError(error instanceof Error ? error.message : "Split operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'split');
setCurrentMode('viewer');
sessionStorage.setItem("previousMode", "split");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
splitOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('split');
setCurrentMode("split");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const filesCollapsed = hasFiles || hasResults;
const settingsCollapsed = !hasFiles || hasResults;
const previewResults = useMemo(() =>
splitOperation.files?.map((file, index) => ({
file,
thumbnail: splitOperation.thumbnails[index]
})) || [],
[splitOperation.files, splitOperation.thumbnails]
);
return (
<ToolStepContainer>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title="Files"
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder="Select a PDF file in the main view to get started"
return createToolFlow({
files: {
selectedFiles,
isCollapsed: filesCollapsed,
placeholder: "Select a PDF file in the main view to get started",
},
steps: [
{
title: "Settings",
isCollapsed: settingsCollapsed,
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
content: (
<SplitSettings
parameters={splitParams.parameters}
onParameterChange={splitParams.updateParameter}
disabled={endpointLoading}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title="Settings"
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Split completed" : undefined}
>
<Stack gap="sm">
<SplitSettings
parameters={splitParams.parameters}
onParameterChange={splitParams.updateParameter}
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>
);
}
),
},
],
executeButton: {
text: t("split.submit", "Split PDF"),
loadingText: t("loading"),
onClick: handleSplit,
isVisible: !hasResults,
disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: splitOperation,
title: "Split Results",
onFileClick: handleThumbnailClick,
},
});
};
export default Split;

View File

@ -1,18 +1,23 @@
import React, { useEffect } from 'react';
import { BaseToolProps } from '../types/tool';
import React, { useEffect } from "react";
import { BaseToolProps } from "../types/tool";
const SwaggerUI: React.FC<BaseToolProps> = () => {
useEffect(() => {
// 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 (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div style={{ textAlign: "center", padding: "2rem" }}>
<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>
);
};
export default SwaggerUI;
export default SwaggerUI;

View File

@ -66,6 +66,7 @@ export interface FileContextState {
// Core file management
activeFiles: File[];
processedFiles: Map<File, ProcessedFile>;
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
// Current navigation state
currentMode: ModeType;
@ -106,6 +107,14 @@ export interface FileContextActions {
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<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
setCurrentMode: (mode: ModeType) => void;

View File

@ -15,9 +15,7 @@ export interface ToolStepConfig {
type: ToolStepType;
title: string;
isVisible: boolean;
isCompleted: boolean;
isCollapsed?: boolean;
completedMessage?: string;
onCollapsedClick?: () => void;
}