From ee6f7a2939e7d38f6aaf1bfd4605e0f6afd2e749 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 13 Aug 2025 14:11:32 +0100 Subject: [PATCH] Review step --- .../tools/shared/ResultsPreview.tsx | 113 -------- .../components/tools/shared/ReviewPanel.tsx | 245 ++++++++++++++++++ .../src/components/tools/shared/ToolStep.tsx | 30 ++- ...sToolStep.tsx => createReviewToolStep.tsx} | 42 +-- .../tools/shared/createToolFlow.tsx | 18 +- frontend/src/tools/Compress.tsx | 6 +- frontend/src/tools/Convert.tsx | 6 +- frontend/src/tools/OCR.tsx | 6 +- frontend/src/tools/Sanitize.tsx | 5 +- frontend/src/tools/Split.tsx | 2 +- 10 files changed, 307 insertions(+), 166 deletions(-) delete mode 100644 frontend/src/components/tools/shared/ResultsPreview.tsx create mode 100644 frontend/src/components/tools/shared/ReviewPanel.tsx rename frontend/src/components/tools/shared/{createResultsToolStep.tsx => createReviewToolStep.tsx} (76%) diff --git a/frontend/src/components/tools/shared/ResultsPreview.tsx b/frontend/src/components/tools/shared/ResultsPreview.tsx deleted file mode 100644 index 9bddb5dc9..000000000 --- a/frontend/src/components/tools/shared/ResultsPreview.tsx +++ /dev/null @@ -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 ( - - {emptyMessage} - - ); - } - - return ( - - {title && ( - - {title} ({files.length} files) - - )} - - {isGeneratingThumbnails ? ( -
- - - {loadingMessage} - -
- ) : ( - - {files.map((result, index) => ( - - 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' - }} - > - - {result.thumbnail ? ( - {`Preview - ) : ( - No preview - )} - - - {result.file.name} - - - {formatSize(result.file.size)} - - - - ))} - - )} -
- ); -} - -export default ResultsPreview; diff --git a/frontend/src/components/tools/shared/ReviewPanel.tsx b/frontend/src/components/tools/shared/ReviewPanel.tsx new file mode 100644 index 000000000..451ef4e9b --- /dev/null +++ b/frontend/src/components/tools/shared/ReviewPanel.tsx @@ -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 ( + + {emptyMessage} + + ); + } + + if (isGeneratingThumbnails) { + return ( +
+ + + {loadingMessage} + +
+ ); + } + + const currentFile = files[currentIndex]; + if (!currentFile) return null; + + return ( + + + {/* File name at the top */} + + + {currentFile.file.name} + + + + + {/* Preview on the left */} + + 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 ? ( + {`Preview + ) : ( + + No preview + + )} + + {/* Hover overlay with eye icon */} + {onFileClick && ( + + + + )} + + + + {/* Metadata on the right */} + + + + {formatSize(currentFile.file.size)} + + + {currentFile.file.type || 'Unknown'} + + + {formatDate(new Date(currentFile.file.lastModified))} + + + + + + {/* Navigation controls */} + {files.length > 1 && ( + + + + + + + + {files.map((_, index) => ( + setCurrentIndex(index)} + data-testid={`review-panel-dot-${index}`} + /> + ))} + + + + + + + + + {currentIndex + 1} of {files.length} + + + )} + + ); +}; + +export default ReviewPanel; diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 053392c2a..314f534ce 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { Tooltip } from '../../shared/Tooltip'; import { TooltipTip } from '../../shared/tooltip/TooltipContent'; import { createFilesToolStep, FilesToolStepProps } from './createFilesToolStep'; -import { createResultsToolStep, ResultsToolStepProps } from './createResultsToolStep'; +import { createReviewToolStep, ReviewToolStepProps } from './createReviewToolStep'; interface ToolStepContextType { visibleStepCount: number; @@ -22,6 +22,8 @@ export interface ToolStepProps { 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[]; @@ -73,6 +75,7 @@ const ToolStep = ({ helpText, showNumber, _stepNumber, + _noPadding, tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -132,7 +135,7 @@ const ToolStep = ({ {!isCollapsed && ( - + {helpText && ( {helpText} @@ -176,17 +179,20 @@ export function createToolSteps() { return createFilesToolStep(create, props); }; - const createResultsStep = (props: ResultsToolStepProps): React.ReactElement => { - return createResultsToolStep(create, props); + const createReviewStep = (props: ReviewToolStepProps): React.ReactElement => { + return createReviewToolStep(create, props); }; const getVisibleCount = () => { - return steps.filter(step => - (step.props as ToolStepProps).isVisible !== false - ).length; + 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, createResultsStep, getVisibleCount, steps }; + return { create, createFilesStep, createReviewStep, getVisibleCount, steps }; } // Context provider wrapper for tools using the factory @@ -196,8 +202,10 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) { 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; @@ -215,5 +223,5 @@ export function ToolStepProvider({ children }: { children: React.ReactNode }) { } export type { FilesToolStepProps } from './createFilesToolStep'; -export type { ResultsToolStepProps } from './createResultsToolStep'; +export type { ReviewToolStepProps } from './createReviewToolStep'; export default ToolStep; diff --git a/frontend/src/components/tools/shared/createResultsToolStep.tsx b/frontend/src/components/tools/shared/createReviewToolStep.tsx similarity index 76% rename from frontend/src/components/tools/shared/createResultsToolStep.tsx rename to frontend/src/components/tools/shared/createReviewToolStep.tsx index fe774cba6..faea34023 100644 --- a/frontend/src/components/tools/shared/createResultsToolStep.tsx +++ b/frontend/src/components/tools/shared/createReviewToolStep.tsx @@ -3,32 +3,34 @@ 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 ReviewPanel from './ReviewPanel'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; -export interface ResultsToolStepProps { +export interface ReviewToolStepProps { isVisible: boolean; operation: ToolOperationHook; title?: string; onFileClick?: (file: File) => void; } -export function createResultsToolStep( +export function createReviewToolStep( createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement, - props: ResultsToolStepProps + props: ReviewToolStepProps ): 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 + return createStep("Review", { + isVisible: props.isVisible, + _excludeFromCount: true, + _noPadding: true }, ( - + {operation.status && ( {operation.status} )} @@ -38,28 +40,28 @@ export function createResultsToolStep( onClose={operation.clearError} /> - {operation.downloadUrl && ( + {previewFiles.length > 0 && ( + + )} + + {operation.downloadUrl && ( )} - - {previewFiles.length > 0 && ( - - )} )); -} \ No newline at end of file +} diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index fd9eabff1..27496b441 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -35,7 +35,7 @@ export interface ExecuteButtonConfig { testId?: string; } -export interface ResultsStepConfig { +export interface ReviewStepConfig { isVisible: boolean; operation: ToolOperationHook; title: string; @@ -47,7 +47,7 @@ export interface ToolFlowConfig { files: FilesStepConfig; steps: MiddleStepConfig[]; executeButton?: ExecuteButtonConfig; - results: ResultsStepConfig; + review: ReviewStepConfig; } /** @@ -81,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) { {config.executeButton && config.executeButton.isVisible !== false && ( )} - {/* Results Step */} - {steps.createResultsStep({ - isVisible: config.results.isVisible, - operation: config.results.operation, - title: config.results.title, - onFileClick: config.results.onFileClick + {/* Review Step */} + {steps.createReviewStep({ + isVisible: config.review.isVisible, + operation: config.review.operation, + title: config.review.title, + onFileClick: config.review.onFileClick })} ); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index b727dbc87..893f7485d 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -64,11 +64,11 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const settingsCollapsed = !hasFiles || hasResults; return ( - + {createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles + isCollapsed: hasFiles && !hasResults, }, steps: [{ title: "Settings", @@ -90,7 +90,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { onClick: handleCompress, disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled }, - results: { + review: { isVisible: hasResults, operation: compressOperation, title: t("compress.title", "Compression Results"), diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 669643566..2b9c9fe8d 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -101,8 +101,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }; return ( -
- + {createToolFlow({ files: { selectedFiles, @@ -131,7 +130,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled, testId: "convert-button" }, - results: { + review: { isVisible: hasResults, operation: convertOperation, title: t("convert.conversionResults", "Conversion Results"), @@ -140,7 +139,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { } })} -
); }; diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index b5e80d8bf..7fc2cd457 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -84,11 +84,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const settingsCollapsed = expandedStep !== 'settings'; return ( - + {createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles && filesCollapsed, + isCollapsed: hasFiles && !hasResults && filesCollapsed, }, steps: [ { @@ -131,7 +131,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible: hasValidSettings && !hasResults, disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled }, - results: { + review: { isVisible: hasResults, operation: ocrOperation, title: t("ocr.results.title", "OCR Results"), diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 19ec669c0..6e2e34559 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -61,7 +61,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const hasFiles = selectedFiles.length > 0; const hasResults = sanitizeOperation.files.length > 0; - const filesCollapsed = hasFiles; + const filesCollapsed = hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults; return ( @@ -86,11 +86,12 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }], executeButton: { text: t("sanitize.submit", "Sanitize PDF"), + isVisible: !hasResults, loadingText: t("loading"), onClick: handleSanitize, disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled }, - results: { + review: { isVisible: hasResults, operation: sanitizeOperation, title: t("sanitize.sanitizationResults", "Sanitization Results"), diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index f480451d9..2b4ff1fe9 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -90,7 +90,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible: !hasResults, disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled }, - results: { + review: { isVisible: hasResults, operation: splitOperation, title: "Split Results",