mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
V2 results flow (#4196)
Better tool flow for reusability Pinning Styling of tool flow consumption of files after tooling --------- Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Connor Yoh
James Brunton
parent
1468df3e21
commit
4c17c520d7
@@ -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
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, ActionIcon, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { FileWithUrl } from '../../types/file';
|
||||
|
||||
interface FilePreviewProps {
|
||||
currentFile: FileWithUrl | null;
|
||||
thumbnail: string | null;
|
||||
numberOfFiles: number;
|
||||
isAnimating: boolean;
|
||||
modalHeight: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||
currentFile,
|
||||
thumbnail,
|
||||
numberOfFiles,
|
||||
isAnimating,
|
||||
modalHeight,
|
||||
onPrevious,
|
||||
onNext
|
||||
}) => {
|
||||
const hasMultipleFiles = numberOfFiles > 1;
|
||||
// Common style objects
|
||||
const navigationArrowStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10
|
||||
};
|
||||
|
||||
const stackDocumentBaseStyle = {
|
||||
position: 'absolute' as const,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
|
||||
const animationStyle = {
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)',
|
||||
opacity: isAnimating ? 0.7 : 1
|
||||
};
|
||||
|
||||
const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)';
|
||||
const stackDocumentShadows = {
|
||||
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
|
||||
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Left Navigation Arrow */}
|
||||
{hasMultipleFiles && (
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
color="blue"
|
||||
disabled={isAnimating}
|
||||
style={{
|
||||
...navigationArrowStyle,
|
||||
left: '0'
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{/* Document Stack Container */}
|
||||
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Background documents (stack effect) */}
|
||||
{/* Show 2 shadow pages for 3+ files */}
|
||||
{numberOfFiles >= 3 && (
|
||||
<Box
|
||||
style={{
|
||||
...stackDocumentBaseStyle,
|
||||
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||
boxShadow: stackDocumentShadows.back,
|
||||
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show 1 shadow page for 2+ files */}
|
||||
{numberOfFiles >= 2 && (
|
||||
<Box
|
||||
style={{
|
||||
...stackDocumentBaseStyle,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
boxShadow: stackDocumentShadows.middle,
|
||||
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main document */}
|
||||
{currentFile && thumbnail ? (
|
||||
<Image
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
fit="contain"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
boxShadow: mainDocumentShadow,
|
||||
position: 'relative',
|
||||
zIndex: 3,
|
||||
...animationStyle
|
||||
}}
|
||||
/>
|
||||
) : currentFile ? (
|
||||
<Center style={{
|
||||
width: '80%',
|
||||
height: '80%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
boxShadow: mainDocumentShadow,
|
||||
position: 'relative',
|
||||
zIndex: 3,
|
||||
...animationStyle
|
||||
}}>
|
||||
<PictureAsPdfIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-6)' }} />
|
||||
</Center>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* Right Navigation Arrow */}
|
||||
{hasMultipleFiles && (
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
color="blue"
|
||||
disabled={isAnimating}
|
||||
style={{
|
||||
...navigationArrowStyle,
|
||||
right: '0'
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import 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"
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
export interface DocumentStackProps {
|
||||
totalFiles: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DocumentStack: React.FC<DocumentStackProps> = ({
|
||||
totalFiles,
|
||||
children
|
||||
}) => {
|
||||
const stackDocumentBaseStyle = {
|
||||
position: 'absolute' as const,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
|
||||
const stackDocumentShadows = {
|
||||
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
{/* Background documents (stack effect) */}
|
||||
{totalFiles >= 3 && (
|
||||
<Box
|
||||
style={{
|
||||
...stackDocumentBaseStyle,
|
||||
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||
boxShadow: stackDocumentShadows.back,
|
||||
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalFiles >= 2 && (
|
||||
<Box
|
||||
style={{
|
||||
...stackDocumentBaseStyle,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
boxShadow: stackDocumentShadows.middle,
|
||||
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main document container */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 3,
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)'
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentStack;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | FileWithUrl | null;
|
||||
thumbnail?: string | null;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
file,
|
||||
thumbnail,
|
||||
style = {},
|
||||
onClick,
|
||||
children
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'opacity 0.2s ease',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...style
|
||||
};
|
||||
|
||||
if (thumbnail) {
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Image
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentThumbnail;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
|
||||
export interface HoverOverlayProps {
|
||||
onMouseEnter?: (e: React.MouseEvent) => void;
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const HoverOverlay: React.FC<HoverOverlayProps> = ({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
children
|
||||
}) => {
|
||||
const defaultMouseEnter = (e: React.MouseEvent) => {
|
||||
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||
if (overlay) overlay.style.opacity = '1';
|
||||
};
|
||||
|
||||
const defaultMouseLeave = (e: React.MouseEvent) => {
|
||||
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||
if (overlay) overlay.style.opacity = '0';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
onMouseEnter={onMouseEnter || defaultMouseEnter}
|
||||
onMouseLeave={onMouseLeave || defaultMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<Box
|
||||
className="hover-overlay"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '0.25rem',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverOverlay;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Box, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
|
||||
export interface NavigationArrowsProps {
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavigationArrows: React.FC<NavigationArrowsProps> = ({
|
||||
onPrevious,
|
||||
onNext,
|
||||
disabled = false,
|
||||
children
|
||||
}) => {
|
||||
const navigationArrowStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
{/* Left Navigation Arrow */}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
color="blue"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...navigationArrowStyle,
|
||||
left: '0'
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Content */}
|
||||
<Box style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* Right Navigation Arrow */}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
color="blue"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...navigationArrowStyle,
|
||||
right: '0'
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationArrows;
|
||||
@@ -39,7 +39,7 @@ export default function ToolPanel() {
|
||||
}`}
|
||||
style={{
|
||||
width: isPanelVisible ? '20rem' : '0',
|
||||
padding: isPanelVisible ? '0.5rem' : '0'
|
||||
padding: '0'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text } from '@mantine/core';
|
||||
import { formatFileSize, getFileDate } from '../../../utils/fileUtils';
|
||||
|
||||
export interface FileMetadataProps {
|
||||
file: File;
|
||||
}
|
||||
|
||||
const FileMetadata = ({ file }: FileMetadataProps) => {
|
||||
return (
|
||||
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack gap="0.125rem">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatFileSize(file.size)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{file.type || 'Unknown'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getFileDate(file)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileMetadata;
|
||||
@@ -3,17 +3,15 @@ import { Text } from '@mantine/core';
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
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;
|
||||
@@ -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")}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Stack, Group, ActionIcon, Text } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
|
||||
export interface NavigationControlsProps {
|
||||
currentIndex: number;
|
||||
totalFiles: number;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const NavigationControls = ({
|
||||
currentIndex,
|
||||
totalFiles,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: NavigationControlsProps) => {
|
||||
if (totalFiles <= 1) return null;
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="xs" mt="xs">
|
||||
<Group justify="center" gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={totalFiles <= 1}
|
||||
data-testid="review-panel-prev"
|
||||
>
|
||||
<ChevronLeftIcon style={{ fontSize: '1rem' }} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentIndex + 1} of {totalFiles}
|
||||
</Text>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={totalFiles <= 1}
|
||||
data-testid="review-panel-next"
|
||||
>
|
||||
<ChevronRightIcon style={{ fontSize: '1rem' }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationControls;
|
||||
@@ -24,7 +24,7 @@ const OperationButton = ({
|
||||
submitText,
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ErrorNotification from './ErrorNotification';
|
||||
import ResultsPreview from './ResultsPreview';
|
||||
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
|
||||
export interface ReviewToolStepProps<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
operation: ToolOperationHook<TParams>;
|
||||
title?: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
}
|
||||
|
||||
export function createReviewToolStep<TParams = unknown>(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { operation } = props;
|
||||
|
||||
const previewFiles = operation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: operation.thumbnails[index]
|
||||
})) || [];
|
||||
|
||||
return createStep(t("review", "Review"), {
|
||||
isVisible: props.isVisible,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true
|
||||
}, (
|
||||
<Stack gap="sm" >
|
||||
<ErrorNotification
|
||||
error={operation.errorMessage}
|
||||
onClose={operation.clearError}
|
||||
/>
|
||||
|
||||
{previewFiles.length > 0 && (
|
||||
<ResultsPreview
|
||||
files={previewFiles}
|
||||
onFileClick={props.onFileClick}
|
||||
isGeneratingThumbnails={operation.isGeneratingThumbnails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={operation.downloadUrl}
|
||||
download={operation.downloadFilename}
|
||||
leftSection={<DownloadIcon />}
|
||||
color="blue"
|
||||
fullWidth
|
||||
mb="md"
|
||||
>
|
||||
{t("download", "Download")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<SuggestedToolsSection />
|
||||
</Stack>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const suggestedTools = useSuggestedTools();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Divider />
|
||||
|
||||
<Text size="lg" fw={600}>
|
||||
{t('editYourNewFiles', 'Edit your new File(s)')}
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
{suggestedTools.map((tool) => {
|
||||
const IconComponent = tool.icon;
|
||||
return (
|
||||
<Card
|
||||
key={tool.name}
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={tool.navigate}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||
import { 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user