shared file preview

This commit is contained in:
Connor Yoh 2025-08-13 15:54:39 +01:00
parent fa4098504a
commit c2235a1def
10 changed files with 490 additions and 330 deletions

View File

@ -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

View File

@ -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;

View 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;

View 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;

View File

@ -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;

View 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;

View File

@ -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;

View 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;

View 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;

View File

@ -1,8 +1,8 @@
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';
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 ReviewFile {
file: File;
@ -28,22 +28,6 @@ const ReviewPanel = ({
}: 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));
};
@ -75,7 +59,7 @@ const ReviewPanel = ({
if (!currentFile) return null;
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 */}
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
@ -92,152 +76,26 @@ const ReviewPanel = ({
</Text>
</Box>
<Flex gap="md" align="flex-start" style={{ minHeight: '120px', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
{/* Preview on the left */}
<Box style={{
flex: '0 0 100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '120px',
position: 'relative'
}}>
<Box
style={{
position: 'relative',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'opacity 0.2s ease',
}}
onClick={() => onFileClick?.(currentFile.file)}
onMouseEnter={(e) => {
if (onFileClick) {
const overlay = e.currentTarget.querySelector('.hover-overlay');
if (overlay) overlay.style.opacity = '1';
}
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('.hover-overlay');
if (overlay) overlay.style.opacity = '0';
}}
>
{currentFile.thumbnail ? (
<Image
src={currentFile.thumbnail}
alt={`Preview of ${currentFile.file.name}`}
style={{
maxWidth: '100px',
maxHeight: '120px',
objectFit: 'contain'
}}
/>
) : (
<Box
style={{
width: '100px',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}
>
<Text size="xs" c="dimmed">No preview</Text>
</Box>
)}
{/* Hover overlay with eye icon */}
{onFileClick && (
<Box
className="hover-overlay"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 4,
opacity: 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none'
}}
>
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
</Box>
)}
</Box>
<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>
{/* 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>
<FileMetadata file={currentFile.file} />
</Flex>
{/* Navigation controls */}
{files.length > 1 && (
<Stack align="center" gap="xs" mt="xs">
<Group justify="center" gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={handlePrevious}
disabled={files.length <= 1}
data-testid="review-panel-prev"
>
<ChevronLeftIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
<Group gap="xs">
{files.map((_, index) => (
<Box
key={index}
style={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: index === currentIndex
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-4)',
cursor: 'pointer',
transition: 'background-color 0.2s ease'
}}
onClick={() => setCurrentIndex(index)}
data-testid={`review-panel-dot-${index}`}
/>
))}
</Group>
<ActionIcon
variant="light"
size="sm"
onClick={handleNext}
disabled={files.length <= 1}
data-testid="review-panel-next"
>
<ChevronRightIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Group>
<Text size="xs" c="dimmed">
{currentIndex + 1} of {files.length}
</Text>
</Stack>
)}
<NavigationControls
currentIndex={currentIndex}
totalFiles={files.length}
onPrevious={handlePrevious}
onNext={handleNext}
onIndexChange={setCurrentIndex}
/>
</Box>
);
};