Review step

This commit is contained in:
Connor Yoh 2025-08-13 14:11:32 +01:00
parent 1b14717257
commit ee6f7a2939
10 changed files with 307 additions and 166 deletions

View File

@ -1,113 +0,0 @@
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
export interface ResultFile {
file: File;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
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`;
};
if (files.length === 0 && !isGeneratingThumbnails) {
return (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
);
}
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>
)}
{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>
)}
</Box>
);
}
export default ResultsPreview;

View File

@ -0,0 +1,245 @@
import React, { useState } from 'react';
import { Paper, Box, Image, Text, Loader, Stack, Center, Group, ActionIcon, Flex } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import VisibilityIcon from '@mui/icons-material/Visibility';
export interface ReviewFile {
file: File;
thumbnail?: string;
}
export interface ReviewPanelProps {
files: ReviewFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
const ReviewPanel = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ReviewPanelProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
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 formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
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) {
return (
<Text size="sm" c="dimmed">
{emptyMessage}
</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>
);
}
const currentFile = files[currentIndex];
if (!currentFile) return null;
return (
<Box p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: 8, 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: '120px', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
{/* Preview on the left */}
<Box style={{
flex: '0 0 100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '120px',
position: 'relative'
}}>
<Box
style={{
position: 'relative',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'opacity 0.2s ease',
}}
onClick={() => onFileClick?.(currentFile.file)}
onMouseEnter={(e) => {
if (onFileClick) {
const overlay = e.currentTarget.querySelector('.hover-overlay');
if (overlay) overlay.style.opacity = '1';
}
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('.hover-overlay');
if (overlay) overlay.style.opacity = '0';
}}
>
{currentFile.thumbnail ? (
<Image
src={currentFile.thumbnail}
alt={`Preview of ${currentFile.file.name}`}
style={{
maxWidth: '100px',
maxHeight: '120px',
objectFit: 'contain'
}}
/>
) : (
<Box
style={{
width: '100px',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}
>
<Text size="xs" c="dimmed">No preview</Text>
</Box>
)}
{/* Hover overlay with eye icon */}
{onFileClick && (
<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: 4,
opacity: 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none'
}}
>
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
</Box>
)}
</Box>
</Box>
{/* Metadata on the right */}
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Stack gap="2px">
<Text size="xs" c="dimmed">
{formatSize(currentFile.file.size)}
</Text>
<Text size="xs" c="dimmed">
{currentFile.file.type || 'Unknown'}
</Text>
<Text size="xs" c="dimmed">
{formatDate(new Date(currentFile.file.lastModified))}
</Text>
</Stack>
</Stack>
</Flex>
{/* Navigation controls */}
{files.length > 1 && (
<Stack align="center" gap="xs" mt="xs">
<Group justify="center" gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={handlePrevious}
disabled={files.length <= 1}
data-testid="review-panel-prev"
>
<ChevronLeftIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
<Group gap="xs">
{files.map((_, index) => (
<Box
key={index}
style={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: index === currentIndex
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-4)',
cursor: 'pointer',
transition: 'background-color 0.2s ease'
}}
onClick={() => setCurrentIndex(index)}
data-testid={`review-panel-dot-${index}`}
/>
))}
</Group>
<ActionIcon
variant="light"
size="sm"
onClick={handleNext}
disabled={files.length <= 1}
data-testid="review-panel-next"
>
<ChevronRightIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Group>
<Text size="xs" c="dimmed">
{currentIndex + 1} of {files.length}
</Text>
</Stack>
)}
</Box>
);
};
export default ReviewPanel;

View File

@ -5,7 +5,7 @@ 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 { createFilesToolStep, FilesToolStepProps } from './createFilesToolStep';
import { createResultsToolStep, ResultsToolStepProps } from './createResultsToolStep'; import { createReviewToolStep, ReviewToolStepProps } from './createReviewToolStep';
interface ToolStepContextType { interface ToolStepContextType {
visibleStepCount: number; visibleStepCount: number;
@ -22,6 +22,8 @@ export interface ToolStepProps {
helpText?: string; helpText?: string;
showNumber?: boolean; showNumber?: boolean;
_stepNumber?: number; // Internal prop set by ToolStepContainer _stepNumber?: number; // Internal prop set by ToolStepContainer
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
_noPadding?: boolean; // Internal prop to exclude from default left padding
tooltip?: { tooltip?: {
content?: React.ReactNode; content?: React.ReactNode;
tips?: TooltipTip[]; tips?: TooltipTip[];
@ -73,6 +75,7 @@ const ToolStep = ({
helpText, helpText,
showNumber, showNumber,
_stepNumber, _stepNumber,
_noPadding,
tooltip tooltip
}: ToolStepProps) => { }: ToolStepProps) => {
if (!isVisible) return null; if (!isVisible) return null;
@ -132,7 +135,7 @@ const ToolStep = ({
</Flex> </Flex>
{!isCollapsed && ( {!isCollapsed && (
<Stack gap="md" pl="md"> <Stack gap="md" pl={_noPadding ? 0 : "md"}>
{helpText && ( {helpText && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{helpText} {helpText}
@ -176,17 +179,20 @@ export function createToolSteps() {
return createFilesToolStep(create, props); return createFilesToolStep(create, props);
}; };
const createResultsStep = <TParams = any>(props: ResultsToolStepProps<TParams>): React.ReactElement => { const createReviewStep = <TParams = any>(props: ReviewToolStepProps<TParams>): React.ReactElement => {
return createResultsToolStep(create, props); return createReviewToolStep(create, props);
}; };
const getVisibleCount = () => { const getVisibleCount = () => {
return steps.filter(step => return steps.filter(step => {
(step.props as ToolStepProps).isVisible !== false const props = step.props as ToolStepProps;
).length; const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
return isVisible && !excludeFromCount;
}).length;
}; };
return { create, createFilesStep, createResultsStep, getVisibleCount, steps }; return { create, createFilesStep, createReviewStep, getVisibleCount, steps };
} }
// Context provider wrapper for tools using the factory // Context provider wrapper for tools using the factory
@ -196,8 +202,10 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) {
let count = 0; let count = 0;
React.Children.forEach(children, (child) => { React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) { if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = (child.props as ToolStepProps).isVisible !== false; const props = child.props as ToolStepProps;
if (isVisible) count++; const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
if (isVisible && !excludeFromCount) count++;
} }
}); });
return count; return count;
@ -215,5 +223,5 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) {
} }
export type { FilesToolStepProps } from './createFilesToolStep'; export type { FilesToolStepProps } from './createFilesToolStep';
export type { ResultsToolStepProps } from './createResultsToolStep'; export type { ReviewToolStepProps } from './createReviewToolStep';
export default ToolStep; export default ToolStep;

View File

@ -3,32 +3,34 @@ import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import ErrorNotification from './ErrorNotification'; import ErrorNotification from './ErrorNotification';
import ResultsPreview from './ResultsPreview'; import ReviewPanel from './ReviewPanel';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
export interface ResultsToolStepProps<TParams = any> { export interface ReviewToolStepProps<TParams = any> {
isVisible: boolean; isVisible: boolean;
operation: ToolOperationHook<TParams>; operation: ToolOperationHook<TParams>;
title?: string; title?: string;
onFileClick?: (file: File) => void; onFileClick?: (file: File) => void;
} }
export function createResultsToolStep<TParams = any>( export function createReviewToolStep<TParams = any>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement, createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ResultsToolStepProps<TParams> props: ReviewToolStepProps<TParams>
): React.ReactElement { ): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { operation } = props; const { operation } = props;
const previewFiles = operation.files?.map((file, index) => ({ const previewFiles = operation.files?.map((file, index) => ({
file, file,
thumbnail: operation.thumbnails[index] thumbnail: operation.thumbnails[index]
})) || []; })) || [];
return createStep("Results", { return createStep("Review", {
isVisible: props.isVisible isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true
}, ( }, (
<Stack gap="sm"> <Stack gap="sm" >
{operation.status && ( {operation.status && (
<Text size="sm" c="dimmed">{operation.status}</Text> <Text size="sm" c="dimmed">{operation.status}</Text>
)} )}
@ -38,28 +40,28 @@ export function createResultsToolStep<TParams = any>(
onClose={operation.clearError} onClose={operation.clearError}
/> />
{operation.downloadUrl && ( {previewFiles.length > 0 && (
<ReviewPanel
files={previewFiles}
onFileClick={props.onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails}
title={props.title || "Review"}
/>
)}
{operation.downloadUrl && (
<Button <Button
component="a" component="a"
href={operation.downloadUrl} href={operation.downloadUrl}
download={operation.downloadFilename} download={operation.downloadFilename}
leftSection={<DownloadIcon />} leftSection={<DownloadIcon />}
color="green" color="blue"
fullWidth fullWidth
mb="md" mb="md"
> >
{t("download", "Download")} {t("download", "Download")}
</Button> </Button>
)} )}
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={props.onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails}
title={props.title || "Results"}
/>
)}
</Stack> </Stack>
)); ));
} }

View File

@ -35,7 +35,7 @@ export interface ExecuteButtonConfig {
testId?: string; testId?: string;
} }
export interface ResultsStepConfig { export interface ReviewStepConfig {
isVisible: boolean; isVisible: boolean;
operation: ToolOperationHook<any>; operation: ToolOperationHook<any>;
title: string; title: string;
@ -47,7 +47,7 @@ export interface ToolFlowConfig {
files: FilesStepConfig; files: FilesStepConfig;
steps: MiddleStepConfig[]; steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig; executeButton?: ExecuteButtonConfig;
results: ResultsStepConfig; review: ReviewStepConfig;
} }
/** /**
@ -81,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.executeButton && config.executeButton.isVisible !== false && ( {config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton <OperationButton
onClick={config.executeButton.onClick} onClick={config.executeButton.onClick}
isLoading={config.results.operation.isLoading} isLoading={config.review.operation.isLoading}
disabled={config.executeButton.disabled} disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText} loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text} submitText={config.executeButton.text}
@ -89,12 +89,12 @@ export function createToolFlow(config: ToolFlowConfig) {
/> />
)} )}
{/* Results Step */} {/* Review Step */}
{steps.createResultsStep({ {steps.createReviewStep({
isVisible: config.results.isVisible, isVisible: config.review.isVisible,
operation: config.results.operation, operation: config.review.operation,
title: config.results.title, title: config.review.title,
onFileClick: config.results.onFileClick onFileClick: config.review.onFileClick
})} })}
</ToolStepProvider> </ToolStepProvider>
); );

View File

@ -64,11 +64,11 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
return ( return (
<Stack gap="md" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
{createToolFlow({ {createToolFlow({
files: { files: {
selectedFiles, selectedFiles,
isCollapsed: hasFiles isCollapsed: hasFiles && !hasResults,
}, },
steps: [{ steps: [{
title: "Settings", title: "Settings",
@ -90,7 +90,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onClick: handleCompress, onClick: handleCompress,
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled
}, },
results: { review: {
isVisible: hasResults, isVisible: hasResults,
operation: compressOperation, operation: compressOperation,
title: t("compress.title", "Compression Results"), title: t("compress.title", "Compression Results"),

View File

@ -101,8 +101,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}; };
return ( return (
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}> <Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
<Stack gap="sm" p="sm">
{createToolFlow({ {createToolFlow({
files: { files: {
selectedFiles, selectedFiles,
@ -131,7 +130,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled,
testId: "convert-button" testId: "convert-button"
}, },
results: { review: {
isVisible: hasResults, isVisible: hasResults,
operation: convertOperation, operation: convertOperation,
title: t("convert.conversionResults", "Conversion Results"), title: t("convert.conversionResults", "Conversion Results"),
@ -140,7 +139,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
} }
})} })}
</Stack> </Stack>
</div>
); );
}; };

View File

@ -84,11 +84,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const settingsCollapsed = expandedStep !== 'settings'; const settingsCollapsed = expandedStep !== 'settings';
return ( return (
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
{createToolFlow({ {createToolFlow({
files: { files: {
selectedFiles, selectedFiles,
isCollapsed: hasFiles && filesCollapsed, isCollapsed: hasFiles && !hasResults && filesCollapsed,
}, },
steps: [ steps: [
{ {
@ -131,7 +131,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isVisible: hasValidSettings && !hasResults, isVisible: hasValidSettings && !hasResults,
disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled
}, },
results: { review: {
isVisible: hasResults, isVisible: hasResults,
operation: ocrOperation, operation: ocrOperation,
title: t("ocr.results.title", "OCR Results"), title: t("ocr.results.title", "OCR Results"),

View File

@ -61,7 +61,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = sanitizeOperation.files.length > 0; const hasResults = sanitizeOperation.files.length > 0;
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles || hasResults;
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
return ( return (
@ -86,11 +86,12 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}], }],
executeButton: { executeButton: {
text: t("sanitize.submit", "Sanitize PDF"), text: t("sanitize.submit", "Sanitize PDF"),
isVisible: !hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleSanitize, onClick: handleSanitize,
disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled
}, },
results: { review: {
isVisible: hasResults, isVisible: hasResults,
operation: sanitizeOperation, operation: sanitizeOperation,
title: t("sanitize.sanitizationResults", "Sanitization Results"), title: t("sanitize.sanitizationResults", "Sanitization Results"),

View File

@ -90,7 +90,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isVisible: !hasResults, isVisible: !hasResults,
disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled
}, },
results: { review: {
isVisible: hasResults, isVisible: hasResults,
operation: splitOperation, operation: splitOperation,
title: "Split Results", title: "Split Results",