mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
shared file preview
This commit is contained in:
parent
fa4098504a
commit
c2235a1def
@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
import FilePreview from './FilePreview';
|
import FilePreview from '../shared/FilePreview';
|
||||||
import FileInfoCard from './FileInfoCard';
|
import FileInfoCard from './FileInfoCard';
|
||||||
import CompactFileDetails from './CompactFileDetails';
|
import CompactFileDetails from './CompactFileDetails';
|
||||||
|
|
||||||
@ -76,15 +76,18 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<FilePreview
|
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
|
||||||
currentFile={currentFile}
|
<FilePreview
|
||||||
thumbnail={getCurrentThumbnail()}
|
file={currentFile}
|
||||||
numberOfFiles={selectedFiles.length}
|
thumbnail={getCurrentThumbnail()}
|
||||||
isAnimating={isAnimating}
|
showStacking={true}
|
||||||
modalHeight={modalHeight}
|
showNavigation={true}
|
||||||
onPrevious={handlePrevious}
|
totalFiles={selectedFiles.length}
|
||||||
onNext={handleNext}
|
isAnimating={isAnimating}
|
||||||
/>
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: File Details */}
|
{/* Section 2: File Details */}
|
||||||
<FileInfoCard
|
<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;
|
|
96
frontend/src/components/shared/FilePreview.tsx
Normal file
96
frontend/src/components/shared/FilePreview.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import { FileWithUrl } from '../../types/file';
|
||||||
|
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||||
|
import DocumentStack from './filePreview/DocumentStack';
|
||||||
|
import HoverOverlay from './filePreview/HoverOverlay';
|
||||||
|
import NavigationArrows from './filePreview/NavigationArrows';
|
||||||
|
|
||||||
|
export interface FilePreviewProps {
|
||||||
|
// Core file data
|
||||||
|
file: File | FileWithUrl | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
|
||||||
|
// Optional features
|
||||||
|
showStacking?: boolean;
|
||||||
|
showHoverOverlay?: boolean;
|
||||||
|
showNavigation?: boolean;
|
||||||
|
|
||||||
|
// State
|
||||||
|
totalFiles?: number;
|
||||||
|
isAnimating?: boolean;
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
onFileClick?: (file: File | FileWithUrl | null) => void;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
|
file,
|
||||||
|
thumbnail,
|
||||||
|
showStacking = false,
|
||||||
|
showHoverOverlay = false,
|
||||||
|
showNavigation = false,
|
||||||
|
totalFiles = 1,
|
||||||
|
isAnimating = false,
|
||||||
|
onFileClick,
|
||||||
|
onPrevious,
|
||||||
|
onNext
|
||||||
|
}) => {
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const hasMultipleFiles = totalFiles > 1;
|
||||||
|
|
||||||
|
// Animation styles
|
||||||
|
const animationStyle = isAnimating ? {
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: 'scale(0.95) translateX(1.25rem)',
|
||||||
|
opacity: 0.7
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
// Build the component composition
|
||||||
|
let content = (
|
||||||
|
<DocumentThumbnail
|
||||||
|
file={file}
|
||||||
|
thumbnail={thumbnail}
|
||||||
|
style={animationStyle}
|
||||||
|
onClick={() => onFileClick?.(file)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with hover overlay if needed
|
||||||
|
if (showHoverOverlay && onFileClick) {
|
||||||
|
content = <HoverOverlay>{content}</HoverOverlay>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with document stack if needed
|
||||||
|
if (showStacking) {
|
||||||
|
content = (
|
||||||
|
<DocumentStack totalFiles={totalFiles}>
|
||||||
|
{content}
|
||||||
|
</DocumentStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with navigation if needed
|
||||||
|
if (showNavigation && hasMultipleFiles && onPrevious && onNext) {
|
||||||
|
content = (
|
||||||
|
<NavigationArrows
|
||||||
|
onPrevious={onPrevious}
|
||||||
|
onNext={onNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</NavigationArrows>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%' }}>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
65
frontend/src/components/shared/filePreview/DocumentStack.tsx
Normal file
65
frontend/src/components/shared/filePreview/DocumentStack.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface DocumentStackProps {
|
||||||
|
totalFiles: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentStack: React.FC<DocumentStackProps> = ({
|
||||||
|
totalFiles,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const stackDocumentBaseStyle = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackDocumentShadows = {
|
||||||
|
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
|
{/* Background documents (stack effect) */}
|
||||||
|
{totalFiles >= 3 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-3)',
|
||||||
|
boxShadow: stackDocumentShadows.back,
|
||||||
|
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalFiles >= 2 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...stackDocumentBaseStyle,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
|
boxShadow: stackDocumentShadows.middle,
|
||||||
|
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main document container */}
|
||||||
|
<Box style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 3,
|
||||||
|
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentStack;
|
@ -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;
|
63
frontend/src/components/shared/filePreview/HoverOverlay.tsx
Normal file
63
frontend/src/components/shared/filePreview/HoverOverlay.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
|
||||||
|
export interface HoverOverlayProps {
|
||||||
|
onMouseEnter?: (e: React.MouseEvent) => void;
|
||||||
|
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HoverOverlay: React.FC<HoverOverlayProps> = ({
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const defaultMouseEnter = (e: React.MouseEvent) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '1';
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMouseLeave = (e: React.MouseEvent) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onMouseEnter={onMouseEnter || defaultMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave || defaultMouseLeave}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<Box
|
||||||
|
className="hover-overlay"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HoverOverlay;
|
@ -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;
|
27
frontend/src/components/tools/shared/FileMetadata.tsx
Normal file
27
frontend/src/components/tools/shared/FileMetadata.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text } from '@mantine/core';
|
||||||
|
import { formatFileSize, getFileDate } from '../../../utils/fileUtils';
|
||||||
|
|
||||||
|
export interface FileMetadataProps {
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileMetadata = ({ file }: FileMetadataProps) => {
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Stack gap="0.125rem">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{file.type || 'Unknown'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{getFileDate(file)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileMetadata;
|
74
frontend/src/components/tools/shared/NavigationControls.tsx
Normal file
74
frontend/src/components/tools/shared/NavigationControls.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Group, ActionIcon, Box, 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;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationControls = ({
|
||||||
|
currentIndex,
|
||||||
|
totalFiles,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onIndexChange
|
||||||
|
}: 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>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
{Array.from({ length: totalFiles }, (_, index) => (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: '0.375rem',
|
||||||
|
height: '0.375rem',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: index === currentIndex
|
||||||
|
? 'var(--mantine-color-blue-6)'
|
||||||
|
: 'var(--mantine-color-gray-4)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => onIndexChange(index)}
|
||||||
|
data-testid={`review-panel-dot-${index}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={totalFiles <= 1}
|
||||||
|
data-testid="review-panel-next"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon style={{ fontSize: '1rem' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentIndex + 1} of {totalFiles}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationControls;
|
@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Paper, Box, Image, Text, Loader, Stack, Center, Group, ActionIcon, Flex } from '@mantine/core';
|
import { Box, Text, Loader, Stack, Center, Flex } from '@mantine/core';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import FilePreview from '../../shared/FilePreview';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import FileMetadata from './FileMetadata';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import NavigationControls from './NavigationControls';
|
||||||
|
|
||||||
export interface ReviewFile {
|
export interface ReviewFile {
|
||||||
file: File;
|
file: File;
|
||||||
@ -28,22 +28,6 @@ const ReviewPanel = ({
|
|||||||
}: ReviewPanelProps) => {
|
}: ReviewPanelProps) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
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 = () => {
|
const handlePrevious = () => {
|
||||||
setCurrentIndex((prev) => (prev === 0 ? files.length - 1 : prev - 1));
|
setCurrentIndex((prev) => (prev === 0 ? files.length - 1 : prev - 1));
|
||||||
};
|
};
|
||||||
@ -75,7 +59,7 @@ const ReviewPanel = ({
|
|||||||
if (!currentFile) return null;
|
if (!currentFile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: 8, maxWidth: '100%' }} data-testid="review-panel-container">
|
<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 */}
|
{/* File name at the top */}
|
||||||
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
|
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
|
||||||
@ -92,152 +76,26 @@ const ReviewPanel = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Flex gap="md" align="flex-start" style={{ minHeight: '120px', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
|
<Flex gap="md" align="flex-start" style={{ minHeight: '7.5rem', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
|
||||||
{/* Preview on the left */}
|
<Box style={{ width: '6.25rem', height: '7.5rem', flexShrink: 0 }}>
|
||||||
<Box style={{
|
<FilePreview
|
||||||
flex: '0 0 100px',
|
file={currentFile.file}
|
||||||
display: 'flex',
|
thumbnail={currentFile.thumbnail}
|
||||||
alignItems: 'center',
|
showHoverOverlay={true}
|
||||||
justifyContent: 'center',
|
onFileClick={onFileClick ? (file) => file && onFileClick(file as File) : undefined}
|
||||||
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>
|
</Box>
|
||||||
|
<FileMetadata file={currentFile.file} />
|
||||||
{/* 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>
|
</Flex>
|
||||||
|
|
||||||
{/* Navigation controls */}
|
{/* Navigation controls */}
|
||||||
{files.length > 1 && (
|
<NavigationControls
|
||||||
<Stack align="center" gap="xs" mt="xs">
|
currentIndex={currentIndex}
|
||||||
<Group justify="center" gap="xs">
|
totalFiles={files.length}
|
||||||
<ActionIcon
|
onPrevious={handlePrevious}
|
||||||
variant="light"
|
onNext={handleNext}
|
||||||
size="sm"
|
onIndexChange={setCurrentIndex}
|
||||||
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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user