Toolstep factory and generic files and results steps

This commit is contained in:
Connor Yoh 2025-08-12 16:29:17 +01:00
parent 1bf9da08af
commit 9d2ca3c8c8
10 changed files with 331 additions and 417 deletions

View File

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

View File

@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select } from "@mantine/core"; import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface CompressParameters { interface CompressParameters {
@ -22,6 +22,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
return ( return (
<Stack gap="md"> <Stack gap="md">
<Divider ml='-md'></Divider>
{/* Compression Method */} {/* Compression Method */}
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text> <Text size="sm" fw={500}>Compression Method</Text>
@ -54,6 +56,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Quality Adjustment */} {/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && ( {parameters.compressionMethod === 'quality' && (
<Stack gap="sm"> <Stack gap="sm">
<Divider />
<Text size="sm" fw={500}>Compression Level</Text> <Text size="sm" fw={500}>Compression Level</Text>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<input <input
@ -68,7 +71,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
onTouchStart={() => setIsSliding(true)} onTouchStart={() => setIsSliding(true)}
onTouchEnd={() => setIsSliding(false)} onTouchEnd={() => setIsSliding(false)}
disabled={disabled} disabled={disabled}
style={{ style={{
width: '100%', width: '100%',
height: '6px', height: '6px',
borderRadius: '3px', borderRadius: '3px',
@ -107,6 +110,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Stack> </Stack>
)} )}
<Divider/>
{/* File Size Input */} {/* File Size Input */}
{parameters.compressionMethod === 'filesize' && ( {parameters.compressionMethod === 'filesize' && (
<Stack gap="sm"> <Stack gap="sm">
@ -141,7 +146,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Compression Options */} {/* Compression Options */}
<Stack gap="sm"> <Stack gap="sm">
<label <label
style={{ display: 'flex', alignItems: 'center', gap: '8px' }} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability" title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
> >
@ -158,4 +163,4 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
); );
}; };
export default CompressSettings; export default CompressSettings;

View File

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

View File

@ -1,9 +1,11 @@
import React, { createContext, useContext, useMemo, useRef } from 'react'; import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box, Flex } from '@mantine/core'; import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent'; import { TooltipTip } from '../../shared/tooltip/TooltipContent';
import { createFilesToolStep, FilesToolStepProps } from './createFilesToolStep';
import { createResultsToolStep, ResultsToolStepProps } from './createResultsToolStep';
interface ToolStepContextType { interface ToolStepContextType {
visibleStepCount: number; visibleStepCount: number;
@ -56,7 +58,7 @@ const renderTooltipTitle = (
</Tooltip> </Tooltip>
); );
} }
return ( return (
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{title} {title}
@ -80,28 +82,29 @@ const ToolStep = ({
if (!isVisible) return null; if (!isVisible) return null;
const parent = useContext(ToolStepContext); const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count // Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => { const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber; if (showNumber !== undefined) return showNumber;
return parent ? parent.visibleStepCount >= 3 : false; return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber, parent]); }, [showNumber, parent]);
const stepNumber = _stepNumber || 1; const stepNumber = _stepNumber;
return ( return (
<Paper <div>
p="md" <div
withBorder style={{
style={{ padding: '1rem',
opacity: isCollapsed ? 0.8 : 1, opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease' color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
}} transition: 'opacity 0.2s ease, color 0.2s ease'
> }}
>
{/* Chevron icon to collapse/expand the step */} {/* Chevron icon to collapse/expand the step */}
<Flex <Flex
align="center" align="center"
justify="space-between" justify="space-between"
mb="sm" mb="sm"
style={{ style={{
cursor: onCollapsedClick ? 'pointer' : 'default' cursor: onCollapsedClick ? 'pointer' : 'default'
@ -116,16 +119,16 @@ const ToolStep = ({
)} )}
{renderTooltipTitle(title, tooltip, isCollapsed)} {renderTooltipTitle(title, tooltip, isCollapsed)}
</Flex> </Flex>
{isCollapsed ? ( {isCollapsed ? (
<ChevronRightIcon style={{ <ChevronRightIcon style={{
fontSize: '1.2rem', fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)', color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5 opacity: onCollapsedClick ? 1 : 0.5
}} /> }} />
) : ( ) : (
<ExpandMoreIcon style={{ <ExpandMoreIcon style={{
fontSize: '1.2rem', fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)', color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5 opacity: onCollapsedClick ? 1 : 0.5
}} /> }} />
@ -133,7 +136,7 @@ const ToolStep = ({
</Flex> </Flex>
{isCollapsed ? ( {isCollapsed ? (
<Box> <div>
{isCompleted && completedMessage && ( {isCompleted && completedMessage && (
<Text size="sm" c="green"> <Text size="sm" c="green">
{completedMessage} {completedMessage}
@ -144,9 +147,9 @@ const ToolStep = ({
)} )}
</Text> </Text>
)} )}
</Box> </div>
) : ( ) : (
<Stack gap="md"> <Stack gap="md" pl="md">
{helpText && ( {helpText && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{helpText} {helpText}
@ -155,33 +158,57 @@ const ToolStep = ({
{children} {children}
</Stack> </Stack>
)} )}
</Paper> </div>
<Divider style={{ marginLeft: '1rem', marginRight: '-1rem' }} />
</div>
); );
} }
export interface ToolStepContainerProps { // ToolStepFactory for creating numbered steps
children: React.ReactNode; export function createToolSteps() {
let stepNumber = 1;
const steps: React.ReactElement[] = [];
const create = (
title: string,
props: Omit<ToolStepProps, 'title' | '_stepNumber'> = {},
children?: React.ReactNode
): React.ReactElement => {
const isVisible = props.isVisible !== false;
const currentStepNumber = isVisible ? stepNumber++ : undefined;
const step = React.createElement(ToolStep, {
...props,
title,
_stepNumber: currentStepNumber,
children,
key: `step-${title.toLowerCase().replace(/\s+/g, '-')}`
});
steps.push(step);
return step;
};
const createFilesStep = (props: FilesToolStepProps): React.ReactElement => {
return createFilesToolStep(create, props);
};
const createResultsStep = <TParams = any>(props: ResultsToolStepProps<TParams>): React.ReactElement => {
return createResultsToolStep(create, props);
};
const getVisibleCount = () => {
return steps.filter(step =>
(step.props as ToolStepProps).isVisible !== false
).length;
};
return { create, createFilesStep, createResultsStep, getVisibleCount, steps };
} }
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => { // Context provider wrapper for tools using the factory
// Process children and inject step numbers for visible ToolSteps export function ToolStepProvider({ children }: { children: React.ReactNode }) {
const processedChildren = useMemo(() => { // Count visible steps from children that are ToolStep elements
let visibleStepNumber = 1;
return React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = (child.props as ToolStepProps).isVisible !== false;
if (isVisible) {
return React.cloneElement(child, {
...child.props,
_stepNumber: visibleStepNumber++
} as ToolStepProps);
}
}
return child;
});
}, [children]);
const visibleStepCount = useMemo(() => { const visibleStepCount = useMemo(() => {
let count = 0; let count = 0;
React.Children.forEach(children, (child) => { React.Children.forEach(children, (child) => {
@ -199,9 +226,11 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
return ( return (
<ToolStepContext.Provider value={contextValue}> <ToolStepContext.Provider value={contextValue}>
{processedChildren} {children}
</ToolStepContext.Provider> </ToolStepContext.Provider>
); );
} }
export type { FilesToolStepProps } from './createFilesToolStep';
export type { ResultsToolStepProps } from './createResultsToolStep';
export default ToolStep; export default ToolStep;

View File

@ -0,0 +1,31 @@
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();
const hasFiles = props.selectedFiles.length > 0;
return createStep("Files", {
isVisible: true,
isCollapsed: props.isCollapsed,
isCompleted: hasFiles,
onCollapsedClick: props.onCollapsedClick,
completedMessage: undefined
}, (
<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,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 { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
export interface ResultsToolStepProps<TParams = any> {
isVisible: boolean;
operation: ToolOperationHook<TParams>;
title?: string;
onFileClick?: (file: File) => void;
}
export function createResultsToolStep<TParams = any>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ResultsToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
const { operation } = props;
const previewFiles = operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index]
})) || [];
return createStep("Results", {
isVisible: props.isVisible
}, (
<Stack gap="sm">
{operation.status && (
<Text size="sm" c="dimmed">{operation.status}</Text>
)}
<ErrorNotification
error={operation.errorMessage}
onClose={operation.clearError}
/>
{operation.downloadUrl && (
<Button
component="a"
href={operation.downloadUrl}
download={operation.downloadFilename}
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={props.onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails}
title={props.title || "Results"}
/>
)}
</Stack>
));
}

View File

@ -1,16 +1,12 @@
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import CompressSettings from "../components/tools/compress/CompressSettings"; import CompressSettings from "../components/tools/compress/CompressSettings";
@ -67,103 +63,52 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults; const settingsCollapsed = !hasFiles || hasResults;
const previewResults = useMemo(() =>
compressOperation.files?.map((file, index) => ({ const steps = createToolSteps();
file,
thumbnail: compressOperation.thumbnails[index]
})) || [],
[compressOperation.files, compressOperation.thumbnails]
);
return ( return (
<ToolStepContainer> <Stack gap="md" h="100%" p="sm" style={{ overflow: 'auto' }}>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <ToolStepProvider>
{/* Files Step */} {/* Files Step */}
<ToolStep {steps.createFilesStep({
title="Files" selectedFiles,
isVisible={true} isCollapsed: filesCollapsed
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"
/>
</ToolStep>
{/* Settings Step */} {/* Settings Step */}
<ToolStep {steps.create("Settings", {
title="Settings" isCollapsed: settingsCollapsed,
isVisible={hasFiles} isCompleted: hasResults,
isCollapsed={settingsCollapsed} onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
isCompleted={settingsCollapsed} completedMessage: t("compress.header", "Compression completed"),
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} tooltip: compressTips
completedMessage={settingsCollapsed ? "Compression completed" : undefined} }, (
tooltip={compressTips} <Stack gap="md">
>
<Stack gap="sm">
<CompressSettings <CompressSettings
parameters={compressParams.parameters} parameters={compressParams.parameters}
onParameterChange={compressParams.updateParameter} onParameterChange={compressParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</Stack>
<OperationButton ))}
<OperationButton
onClick={handleCompress} onClick={handleCompress}
isLoading={compressOperation.isLoading} isLoading={compressOperation.isLoading}
disabled={!compressParams.validateParameters() || !hasFiles || !endpointEnabled} disabled={!compressParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")} loadingText={t("loading")}
submitText="Compress and Review" submitText={t("compress.submit", "Compress")}
/> />
</Stack>
</ToolStep>
{/* Results Step */} {/* Results Step */}
<ToolStep {steps.createResultsStep({
title="Results" isVisible: hasResults,
isVisible={hasResults} operation: compressOperation,
> title: t("compress.title", "Compression Results"),
<Stack gap="sm"> onFileClick: handleThumbnailClick
{compressOperation.status && ( })}
<Text size="sm" c="dimmed">{compressOperation.status}</Text> </ToolStepProvider>
)} </Stack>
<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>
); );
} }

View File

@ -1,16 +1,12 @@
import React, { useEffect, useMemo, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { Button, Stack, Text } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings";
@ -105,101 +101,55 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setCurrentMode('convert'); setCurrentMode('convert');
}; };
const previewResults = useMemo(() => const steps = createToolSteps();
convertOperation.files?.map((file, index) => ({
file,
thumbnail: convertOperation.thumbnails[index]
})) || [],
[convertOperation.files, convertOperation.thumbnails]
);
return ( return (
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}> <div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
<ToolStepContainer> <Stack gap="sm" p="sm">
<Stack gap="sm" p="sm"> <ToolStepProvider>
<ToolStep {/* Files Step */}
title={t("convert.files", "Files")} {steps.createFilesStep({
isVisible={true} selectedFiles,
isCollapsed={filesCollapsed} isCollapsed: filesCollapsed,
isCompleted={filesCollapsed} placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started")
completedMessage={hasFiles ? `${selectedFiles.length} ${t("filesSelected", "files selected")}` : undefined} })}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder={t("convert.selectFilesPlaceholder", "Select files in the main view to get started")}
/>
</ToolStep>
<ToolStep {/* Settings Step */}
title={t("convert.settings", "Settings")} {steps.create(t("convert.settings", "Settings"), {
isVisible={true} isCollapsed: settingsCollapsed,
isCollapsed={settingsCollapsed} isCompleted: settingsCollapsed,
isCompleted={settingsCollapsed} onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} }, (
completedMessage={settingsCollapsed ? t("convert.conversionCompleted", "Conversion completed") : undefined} <Stack gap="sm">
> <ConvertSettings
<Stack gap="sm"> parameters={convertParams.parameters}
<ConvertSettings onParameterChange={convertParams.updateParameter}
parameters={convertParams.parameters} getAvailableToExtensions={convertParams.getAvailableToExtensions}
onParameterChange={convertParams.updateParameter} selectedFiles={selectedFiles}
getAvailableToExtensions={convertParams.getAvailableToExtensions} disabled={endpointLoading}
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>
</Stack> ))}
</ToolStep> {!hasResults && (
<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"
/>
)}
<ToolStep {/* Results Step */}
title={t("convert.results", "Results")} {steps.createResultsStep({
isVisible={hasResults} isVisible: hasResults,
data-testid="conversion-results" operation: convertOperation,
> title: t("convert.conversionResults", "Conversion Results"),
<Stack gap="sm"> onFileClick: handleThumbnailClick
{convertOperation.status && ( })}
<Text size="sm" c="dimmed">{convertOperation.status}</Text> </ToolStepProvider>
)} </Stack>
<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> </div>
); );
}; };

View File

@ -1,16 +1,12 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Button, Stack, Text, Box } from "@mantine/core"; import { Stack, Box } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import OCRSettings from "../components/tools/ocr/OCRSettings"; import OCRSettings from "../components/tools/ocr/OCRSettings";
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings"; import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
@ -79,140 +75,80 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}; };
// Step visibility and collapse logic
const filesVisible = true;
const settingsVisible = true;
const resultsVisible = hasResults; const resultsVisible = hasResults;
const filesCollapsed = expandedStep !== 'files'; const filesCollapsed = expandedStep !== 'files';
const settingsCollapsed = expandedStep !== 'settings'; const settingsCollapsed = expandedStep !== 'settings';
const previewResults = useMemo(() =>
ocrOperation.files?.map((file: File, index: number) => ({ const steps = createToolSteps();
file,
thumbnail: ocrOperation.thumbnails[index]
})) || [],
[ocrOperation.files, ocrOperation.thumbnails]
);
return ( return (
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
<ToolStepContainer> <ToolStepProvider>
{/* Files Step */} {/* Files Step */}
<ToolStep {steps.createFilesStep({
title="Files" selectedFiles,
isVisible={filesVisible} isCollapsed: hasFiles && filesCollapsed,
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"
/>
</ToolStep>
{/* Settings Step */} {/* Settings Step */}
<ToolStep {steps.create("Settings", {
title="Settings" isCollapsed: !hasFiles || settingsCollapsed,
isVisible={settingsVisible} isCompleted: hasFiles && hasValidSettings,
isCollapsed={settingsCollapsed} onCollapsedClick: () => {
isCompleted={hasFiles && hasValidSettings}
onCollapsedClick={() => {
if (!hasFiles) return; // Only allow if files are selected if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === 'settings' ? null : 'settings'); setExpandedStep(expandedStep === 'settings' ? null : 'settings');
}} },
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined} tooltip: ocrTips
tooltip={ocrTips} }, (
>
<Stack gap="sm"> <Stack gap="sm">
<OCRSettings <OCRSettings
parameters={ocrParams.parameters} parameters={ocrParams.parameters}
onParameterChange={ocrParams.updateParameter} onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</Stack> </Stack>
</ToolStep> ))}
{/* Advanced Step */} {/* Advanced Step */}
<ToolStep {steps.create("Advanced", {
title="Advanced" isCollapsed: expandedStep !== 'advanced',
isVisible={true} isCompleted: hasFiles && hasResults,
isCollapsed={expandedStep !== 'advanced'} onCollapsedClick: () => {
isCompleted={hasFiles && hasResults}
onCollapsedClick={() => {
if (!hasFiles) return; // Only allow if files are selected if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === 'advanced' ? null : 'advanced'); setExpandedStep(expandedStep === 'advanced' ? null : 'advanced');
}} },
completedMessage={hasFiles && hasResults && expandedStep !== 'advanced' ? "OCR processing completed" : undefined} }, (
>
<AdvancedOCRSettings <AdvancedOCRSettings
advancedOptions={ocrParams.parameters.additionalOptions} advancedOptions={ocrParams.parameters.additionalOptions}
ocrRenderType={ocrParams.parameters.ocrRenderType} ocrRenderType={ocrParams.parameters.ocrRenderType}
onParameterChange={ocrParams.updateParameter} onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</ToolStep> ))}
{/* Process Button - Available after all configuration */} {/* Process Button - Available after all configuration */}
{hasValidSettings && !hasResults && ( {hasValidSettings && !hasResults && (
<Box mt="md">
<OperationButton <OperationButton
onClick={handleOCR} onClick={handleOCR}
isLoading={ocrOperation.isLoading} isLoading={ocrOperation.isLoading}
disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled} disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")} loadingText={t("loading")}
submitText="Process OCR and Review" submitText={t("ocr.operation.submit", "Process OCR and Review")}
/> />
</Box>
)} )}
{/* Results Step */} {/* Results Step */}
<ToolStep {steps.createResultsStep({
title="Results" isVisible: resultsVisible,
isVisible={resultsVisible} operation: ocrOperation,
> title: t("ocr.results.title", "OCR Results"),
<Stack gap="sm"> onFileClick: handleThumbnailClick
{ocrOperation.status && ( })}
<Text size="sm" c="dimmed">{ocrOperation.status}</Text> </ToolStepProvider>
)}
<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>
</ToolStepContainer>
</Stack> </Stack>
); );
} }
export default OCR; export default OCR;

View File

@ -1,16 +1,12 @@
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
@ -66,42 +62,26 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.downloadUrl !== null; const hasResults = splitOperation.downloadUrl !== null;
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults; const settingsCollapsed = !hasFiles || hasResults;
const previewResults = useMemo(() => const steps = createToolSteps();
splitOperation.files?.map((file, index) => ({
file,
thumbnail: splitOperation.thumbnails[index]
})) || [],
[splitOperation.files, splitOperation.thumbnails]
);
return ( return (
<ToolStepContainer> <Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <ToolStepProvider>
{/* Files Step */} {/* Files Step */}
<ToolStep {steps.createFilesStep({
title="Files" selectedFiles,
isVisible={true} isCollapsed: filesCollapsed,
isCollapsed={filesCollapsed} placeholder: "Select a PDF file in the main view to get started"
isCompleted={filesCollapsed} })}
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder="Select a PDF file in the main view to get started"
/>
</ToolStep>
{/* Settings Step */} {/* Settings Step */}
<ToolStep {steps.create("Settings", {
title="Settings" isCollapsed: settingsCollapsed,
isVisible={hasFiles} isCompleted: hasResults,
isCollapsed={settingsCollapsed} onCollapsedClick: hasResults ? handleSettingsReset : undefined,
isCompleted={settingsCollapsed} }, (
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Split completed" : undefined}
>
<Stack gap="sm"> <Stack gap="sm">
<SplitSettings <SplitSettings
parameters={splitParams.parameters} parameters={splitParams.parameters}
@ -109,57 +89,28 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
disabled={endpointLoading} 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> </Stack>
</ToolStep> ))}
{!hasResults && (
<OperationButton
onClick={handleSplit}
isLoading={splitOperation.isLoading}
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")}
submitText={t("split.submit", "Split PDF")}
/>
)}
{/* Results Step */} {/* Results Step */}
<ToolStep {steps.createResultsStep({
title="Results" isVisible: hasResults,
isVisible={hasResults} operation: splitOperation,
> title: "Split Results",
<Stack gap="sm"> onFileClick: handleThumbnailClick
{splitOperation.status && ( })}
<Text size="sm" c="dimmed">{splitOperation.status}</Text> </ToolStepProvider>
)} </Stack>
<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>
); );
} }