feat(client): improve document details page
This commit is contained in:
parent
3a784d4ddf
commit
c5827a3d20
|
|
@ -0,0 +1,344 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security as SecurityIcon,
|
||||
Fingerprint as FingerprintIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { modernTokens } from '../theme';
|
||||
|
||||
interface FileIntegrityDisplayProps {
|
||||
fileHash?: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const FileIntegrityDisplay: React.FC<FileIntegrityDisplayProps> = ({
|
||||
fileHash,
|
||||
fileName,
|
||||
fileSize,
|
||||
mimeType,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userId,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
if (!hash) return 'Not available';
|
||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`;
|
||||
};
|
||||
|
||||
const getIntegrityStatus = () => {
|
||||
if (!fileHash) {
|
||||
return {
|
||||
status: 'unknown',
|
||||
icon: <InfoIcon />,
|
||||
color: modernTokens.colors.neutral[500],
|
||||
message: 'Hash not available',
|
||||
};
|
||||
}
|
||||
|
||||
// Simple validation - in real implementation you'd verify against stored hash
|
||||
if (fileHash.length === 64) { // SHA256 length
|
||||
return {
|
||||
status: 'verified',
|
||||
icon: <CheckIcon />,
|
||||
color: modernTokens.colors.success[500],
|
||||
message: 'File integrity verified',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'warning',
|
||||
icon: <WarningIcon />,
|
||||
color: modernTokens.colors.warning[500],
|
||||
message: 'Hash format unusual',
|
||||
};
|
||||
};
|
||||
|
||||
const integrityStatus = getIntegrityStatus();
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.primary[50]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<SecurityIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
mr: 1,
|
||||
color: integrityStatus.color
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
File Integrity
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={integrityStatus.status}
|
||||
sx={{
|
||||
backgroundColor: `${integrityStatus.color}20`,
|
||||
color: integrityStatus.color,
|
||||
border: `1px solid ${integrityStatus.color}40`,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Hash (SHA256)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 500,
|
||||
mr: 0.5,
|
||||
}}
|
||||
>
|
||||
{formatHash(fileHash || '')}
|
||||
</Typography>
|
||||
{fileHash && (
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy full hash'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(fileHash)}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<CopyIcon sx={{ fontSize: 12 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Size
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||
{formatFileSize(fileSize)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.primary[50]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<SecurityIcon
|
||||
sx={{
|
||||
fontSize: 24,
|
||||
mr: 1.5,
|
||||
color: modernTokens.colors.primary[500]
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
File Integrity & Verification
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
icon={React.cloneElement(integrityStatus.icon, { sx: { fontSize: 18 } })}
|
||||
label={integrityStatus.message}
|
||||
sx={{
|
||||
backgroundColor: `${integrityStatus.color}20`,
|
||||
color: integrityStatus.color,
|
||||
border: `1px solid ${integrityStatus.color}40`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Hash Information */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<FingerprintIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
mr: 1,
|
||||
color: modernTokens.colors.neutral[600]
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
SHA256 Hash
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{fileHash ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
backgroundColor: modernTokens.colors.neutral[100],
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
flex: 1,
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '0.8rem',
|
||||
color: modernTokens.colors.neutral[700],
|
||||
}}
|
||||
>
|
||||
{fileHash}
|
||||
</Typography>
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy hash'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(fileHash)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
File hash not available. Enable hash generation in upload settings.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File Properties */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
File Properties
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
File Size
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatFileSize(fileSize)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
MIME Type
|
||||
</Typography>
|
||||
<Chip
|
||||
label={mimeType}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: modernTokens.colors.neutral[100],
|
||||
border: `1px solid ${modernTokens.colors.neutral[300]}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Uploaded
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(createdAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{createdAt !== updatedAt && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last Modified
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(updatedAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Uploaded By
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`User: ${userId.substring(0, 8)}...`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: modernTokens.colors.primary[50],
|
||||
color: modernTokens.colors.primary[700],
|
||||
border: `1px solid ${modernTokens.colors.primary[200]}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileIntegrityDisplay;
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Stack,
|
||||
Paper,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
PhotoCamera as CameraIcon,
|
||||
LocationOn as LocationIcon,
|
||||
DateRange as DateIcon,
|
||||
Settings as SettingsIcon,
|
||||
AspectRatio as AspectRatioIcon,
|
||||
ColorLens as ColorIcon,
|
||||
Copyright as CopyrightIcon,
|
||||
Person as PersonIcon,
|
||||
Business as BusinessIcon,
|
||||
FileCopy as DocumentIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { modernTokens } from '../theme';
|
||||
|
||||
// Define border radius values since they might not be in modernTokens
|
||||
const borderRadius = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
};
|
||||
|
||||
interface MetadataParserProps {
|
||||
metadata: Record<string, any>;
|
||||
fileType: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedMetadata {
|
||||
category: string;
|
||||
icon: React.ReactElement;
|
||||
items: Array<{
|
||||
label: string;
|
||||
value: any;
|
||||
type: 'text' | 'date' | 'location' | 'technical' | 'copyable';
|
||||
unit?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MetadataParser: React.FC<MetadataParserProps> = ({
|
||||
metadata,
|
||||
fileType,
|
||||
compact = false
|
||||
}) => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const parseExifData = (exif: Record<string, any>): ParsedMetadata[] => {
|
||||
const sections: ParsedMetadata[] = [];
|
||||
|
||||
// Camera Information
|
||||
const cameraInfo = [];
|
||||
if (exif.make) cameraInfo.push({ label: 'Camera Make', value: exif.make, type: 'text' as const });
|
||||
if (exif.model) cameraInfo.push({ label: 'Camera Model', value: exif.model, type: 'text' as const });
|
||||
if (exif.lens_make) cameraInfo.push({ label: 'Lens Make', value: exif.lens_make, type: 'text' as const });
|
||||
if (exif.lens_model) cameraInfo.push({ label: 'Lens Model', value: exif.lens_model, type: 'text' as const });
|
||||
|
||||
if (cameraInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Camera',
|
||||
icon: <CameraIcon />,
|
||||
items: cameraInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Technical Settings
|
||||
const technicalInfo = [];
|
||||
if (exif.focal_length) technicalInfo.push({ label: 'Focal Length', value: exif.focal_length, type: 'technical' as const, unit: 'mm' });
|
||||
if (exif.aperture) technicalInfo.push({ label: 'Aperture', value: `f/${exif.aperture}`, type: 'technical' as const });
|
||||
if (exif.exposure_time) technicalInfo.push({ label: 'Shutter Speed', value: exif.exposure_time, type: 'technical' as const, unit: 's' });
|
||||
if (exif.iso) technicalInfo.push({ label: 'ISO', value: exif.iso, type: 'technical' as const });
|
||||
if (exif.flash) technicalInfo.push({ label: 'Flash', value: exif.flash, type: 'text' as const });
|
||||
|
||||
if (technicalInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Camera Settings',
|
||||
icon: <SettingsIcon />,
|
||||
items: technicalInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Image Properties
|
||||
const imageInfo = [];
|
||||
if (exif.width && exif.height) {
|
||||
imageInfo.push({
|
||||
label: 'Dimensions',
|
||||
value: `${exif.width} × ${exif.height}`,
|
||||
type: 'technical' as const,
|
||||
unit: 'px'
|
||||
});
|
||||
}
|
||||
if (exif.resolution_x && exif.resolution_y) {
|
||||
imageInfo.push({
|
||||
label: 'Resolution',
|
||||
value: `${exif.resolution_x} × ${exif.resolution_y}`,
|
||||
type: 'technical' as const,
|
||||
unit: 'dpi'
|
||||
});
|
||||
}
|
||||
if (exif.color_space) imageInfo.push({ label: 'Color Space', value: exif.color_space, type: 'text' as const });
|
||||
if (exif.orientation) imageInfo.push({ label: 'Orientation', value: exif.orientation, type: 'text' as const });
|
||||
|
||||
if (imageInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Image Properties',
|
||||
icon: <AspectRatioIcon />,
|
||||
items: imageInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Location Data
|
||||
if (exif.gps_latitude && exif.gps_longitude) {
|
||||
sections.push({
|
||||
category: 'Location',
|
||||
icon: <LocationIcon />,
|
||||
items: [
|
||||
{
|
||||
label: 'Coordinates',
|
||||
value: `${exif.gps_latitude}, ${exif.gps_longitude}`,
|
||||
type: 'location' as const
|
||||
},
|
||||
...(exif.gps_altitude ? [{
|
||||
label: 'Altitude',
|
||||
value: exif.gps_altitude,
|
||||
type: 'technical' as const,
|
||||
unit: 'm'
|
||||
}] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
const dateInfo = [];
|
||||
if (exif.date_time_original) dateInfo.push({ label: 'Date Taken', value: exif.date_time_original, type: 'date' as const });
|
||||
if (exif.date_time_digitized) dateInfo.push({ label: 'Date Digitized', value: exif.date_time_digitized, type: 'date' as const });
|
||||
|
||||
if (dateInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Timestamps',
|
||||
icon: <DateIcon />,
|
||||
items: dateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const parsePdfMetadata = (pdf: Record<string, any>): ParsedMetadata[] => {
|
||||
const sections: ParsedMetadata[] = [];
|
||||
|
||||
// Document Information
|
||||
const docInfo = [];
|
||||
if (pdf.title) docInfo.push({ label: 'Title', value: pdf.title, type: 'text' as const });
|
||||
if (pdf.author) docInfo.push({ label: 'Author', value: pdf.author, type: 'text' as const });
|
||||
if (pdf.subject) docInfo.push({ label: 'Subject', value: pdf.subject, type: 'text' as const });
|
||||
if (pdf.keywords) docInfo.push({ label: 'Keywords', value: pdf.keywords, type: 'text' as const });
|
||||
|
||||
if (docInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Document Info',
|
||||
icon: <DocumentIcon />,
|
||||
items: docInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Technical Details
|
||||
const techInfo = [];
|
||||
if (pdf.creator) techInfo.push({ label: 'Created With', value: pdf.creator, type: 'text' as const });
|
||||
if (pdf.producer) techInfo.push({ label: 'PDF Producer', value: pdf.producer, type: 'text' as const });
|
||||
if (pdf.pdf_version) techInfo.push({ label: 'PDF Version', value: pdf.pdf_version, type: 'technical' as const });
|
||||
if (pdf.page_count) techInfo.push({ label: 'Pages', value: pdf.page_count, type: 'technical' as const });
|
||||
if (pdf.encrypted !== undefined) techInfo.push({ label: 'Encrypted', value: pdf.encrypted ? 'Yes' : 'No', type: 'text' as const });
|
||||
|
||||
if (techInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Technical',
|
||||
icon: <SettingsIcon />,
|
||||
items: techInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
const dateInfo = [];
|
||||
if (pdf.creation_date) dateInfo.push({ label: 'Created', value: pdf.creation_date, type: 'date' as const });
|
||||
if (pdf.modification_date) dateInfo.push({ label: 'Modified', value: pdf.modification_date, type: 'date' as const });
|
||||
|
||||
if (dateInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Timestamps',
|
||||
icon: <DateIcon />,
|
||||
items: dateInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const parseOfficeMetadata = (office: Record<string, any>): ParsedMetadata[] => {
|
||||
const sections: ParsedMetadata[] = [];
|
||||
|
||||
// Document Properties
|
||||
const docInfo = [];
|
||||
if (office.title) docInfo.push({ label: 'Title', value: office.title, type: 'text' as const });
|
||||
if (office.author) docInfo.push({ label: 'Author', value: office.author, type: 'text' as const });
|
||||
if (office.company) docInfo.push({ label: 'Company', value: office.company, type: 'text' as const });
|
||||
if (office.manager) docInfo.push({ label: 'Manager', value: office.manager, type: 'text' as const });
|
||||
if (office.category) docInfo.push({ label: 'Category', value: office.category, type: 'text' as const });
|
||||
|
||||
if (docInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Document Properties',
|
||||
icon: <PersonIcon />,
|
||||
items: docInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Application Info
|
||||
const appInfo = [];
|
||||
if (office.application) appInfo.push({ label: 'Application', value: office.application, type: 'text' as const });
|
||||
if (office.app_version) appInfo.push({ label: 'Version', value: office.app_version, type: 'technical' as const });
|
||||
if (office.template) appInfo.push({ label: 'Template', value: office.template, type: 'text' as const });
|
||||
|
||||
if (appInfo.length > 0) {
|
||||
sections.push({
|
||||
category: 'Application',
|
||||
icon: <BusinessIcon />,
|
||||
items: appInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const parseGenericMetadata = (data: Record<string, any>): ParsedMetadata[] => {
|
||||
const sections: ParsedMetadata[] = [];
|
||||
|
||||
// Group remaining metadata
|
||||
const otherItems = Object.entries(data)
|
||||
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
label: key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
type: 'text' as const,
|
||||
}));
|
||||
|
||||
if (otherItems.length > 0) {
|
||||
sections.push({
|
||||
category: 'Additional Properties',
|
||||
icon: <SettingsIcon />,
|
||||
items: otherItems,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const formatValue = (item: any) => {
|
||||
switch (item.type) {
|
||||
case 'date':
|
||||
try {
|
||||
return new Date(item.value).toLocaleString();
|
||||
} catch {
|
||||
return item.value;
|
||||
}
|
||||
case 'location':
|
||||
return item.value;
|
||||
case 'technical':
|
||||
return `${item.value}${item.unit ? ` ${item.unit}` : ''}`;
|
||||
case 'copyable':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', flex: 1 }}>
|
||||
{item.value}
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton size="small" onClick={() => copyToClipboard(item.value)}>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return item.value;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse metadata based on file type
|
||||
let parsedSections: ParsedMetadata[] = [];
|
||||
|
||||
if (fileType.includes('image') && metadata.exif) {
|
||||
parsedSections = [...parsedSections, ...parseExifData(metadata.exif)];
|
||||
}
|
||||
|
||||
if (fileType.includes('pdf') && metadata.pdf) {
|
||||
parsedSections = [...parsedSections, ...parsePdfMetadata(metadata.pdf)];
|
||||
}
|
||||
|
||||
if ((fileType.includes('officedocument') || fileType.includes('msword')) && metadata.office) {
|
||||
parsedSections = [...parsedSections, ...parseOfficeMetadata(metadata.office)];
|
||||
}
|
||||
|
||||
// Add any remaining metadata
|
||||
const remainingMetadata = { ...metadata };
|
||||
delete remainingMetadata.exif;
|
||||
delete remainingMetadata.pdf;
|
||||
delete remainingMetadata.office;
|
||||
|
||||
if (Object.keys(remainingMetadata).length > 0) {
|
||||
parsedSections = [...parsedSections, ...parseGenericMetadata(remainingMetadata)];
|
||||
}
|
||||
|
||||
if (parsedSections.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
No detailed metadata available for this file type
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box>
|
||||
{parsedSections.slice(0, 2).map((section, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
{React.cloneElement(section.icon, {
|
||||
sx: { fontSize: 16, mr: 1, color: modernTokens.colors.primary[500] }
|
||||
})}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{section.category}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={1}>
|
||||
{section.items.slice(0, 3).map((item, itemIndex) => (
|
||||
<Box key={itemIndex} sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||
{formatValue(item)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
{parsedSections.length > 2 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
+{parsedSections.length - 2} more sections...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{parsedSections.map((section, index) => (
|
||||
<Accordion
|
||||
key={index}
|
||||
sx={{
|
||||
boxShadow: 'none',
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
borderRadius: borderRadius.lg,
|
||||
mb: 1,
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
borderRadius: borderRadius.lg,
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{React.cloneElement(section.icon, {
|
||||
sx: { fontSize: 20, mr: 1, color: modernTokens.colors.primary[500] }
|
||||
})}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{section.category}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={section.items.length}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', mr: 1 }}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<Grid item xs={12} sm={6} key={itemIndex}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mb: 0.5, fontWeight: 500 }}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatValue(item)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataParser;
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip,
|
||||
Stack,
|
||||
Button,
|
||||
Collapse,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineConnector,
|
||||
TimelineContent,
|
||||
TimelineDot,
|
||||
} from '@mui/lab';
|
||||
import {
|
||||
Timeline as TimelineIcon,
|
||||
Upload as UploadIcon,
|
||||
Psychology as OcrIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RetryIcon,
|
||||
ExpandMore as ExpandIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Person as PersonIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { modernTokens } from '../theme';
|
||||
import { documentService } from '../services/api';
|
||||
|
||||
interface ProcessingTimelineProps {
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
ocrStatus?: string;
|
||||
ocrCompletedAt?: string;
|
||||
ocrRetryCount?: number;
|
||||
ocrError?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'upload' | 'ocr_start' | 'ocr_complete' | 'ocr_retry' | 'ocr_error' | 'update';
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'success' | 'error' | 'warning' | 'info';
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
const ProcessingTimeline: React.FC<ProcessingTimelineProps> = ({
|
||||
documentId,
|
||||
fileName,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userId,
|
||||
ocrStatus,
|
||||
ocrCompletedAt,
|
||||
ocrRetryCount = 0,
|
||||
ocrError,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
const [retryHistory, setRetryHistory] = useState<any[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
const getStatusIcon = (type: string, status: string) => {
|
||||
switch (type) {
|
||||
case 'upload':
|
||||
return <UploadIcon />;
|
||||
case 'ocr_start':
|
||||
case 'ocr_complete':
|
||||
return <OcrIcon />;
|
||||
case 'ocr_retry':
|
||||
return <RetryIcon />;
|
||||
case 'ocr_error':
|
||||
return <ErrorIcon />;
|
||||
default:
|
||||
return <CheckIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return modernTokens.colors.success[500];
|
||||
case 'error':
|
||||
return modernTokens.colors.error[500];
|
||||
case 'warning':
|
||||
return modernTokens.colors.warning[500];
|
||||
default:
|
||||
return modernTokens.colors.info[500];
|
||||
}
|
||||
};
|
||||
|
||||
const generateTimelineEvents = (): TimelineEvent[] => {
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// Upload event
|
||||
events.push({
|
||||
id: 'upload',
|
||||
timestamp: createdAt,
|
||||
type: 'upload',
|
||||
title: 'Document Uploaded',
|
||||
description: `File "${fileName}" was uploaded successfully`,
|
||||
status: 'success',
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
// OCR processing events
|
||||
if (ocrStatus) {
|
||||
if (ocrStatus === 'completed' && ocrCompletedAt) {
|
||||
events.push({
|
||||
id: 'ocr_complete',
|
||||
timestamp: ocrCompletedAt,
|
||||
type: 'ocr_complete',
|
||||
title: 'OCR Processing Completed',
|
||||
description: 'Text extraction finished successfully',
|
||||
status: 'success',
|
||||
});
|
||||
} else if (ocrStatus === 'failed' && ocrError) {
|
||||
events.push({
|
||||
id: 'ocr_error',
|
||||
timestamp: updatedAt,
|
||||
type: 'ocr_error',
|
||||
title: 'OCR Processing Failed',
|
||||
description: ocrError,
|
||||
status: 'error',
|
||||
});
|
||||
} else if (ocrStatus === 'processing') {
|
||||
events.push({
|
||||
id: 'ocr_start',
|
||||
timestamp: createdAt,
|
||||
type: 'ocr_start',
|
||||
title: 'OCR Processing Started',
|
||||
description: 'Text extraction is in progress',
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Retry events
|
||||
if (ocrRetryCount && ocrRetryCount > 0) {
|
||||
for (let i = 0; i < ocrRetryCount; i++) {
|
||||
events.push({
|
||||
id: `retry_${i}`,
|
||||
timestamp: updatedAt, // In real implementation, get actual retry timestamps
|
||||
type: 'ocr_retry',
|
||||
title: `OCR Retry Attempt ${i + 1}`,
|
||||
description: 'Attempting to reprocess document',
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
};
|
||||
|
||||
const loadRetryHistory = async () => {
|
||||
if (loadingHistory) return;
|
||||
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
// Note: This endpoint might not exist yet, it's for future implementation
|
||||
const response = await documentService.getRetryHistory?.(documentId);
|
||||
if (response?.data?.retry_history) {
|
||||
setRetryHistory(response.data.retry_history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load retry history:', error);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && ocrRetryCount > 0) {
|
||||
loadRetryHistory();
|
||||
}
|
||||
}, [expanded, ocrRetryCount]);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (start: string, end: string) => {
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = new Date(end).getTime();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (duration < 1000) return `${duration}ms`;
|
||||
if (duration < 60000) return `${Math.round(duration / 1000)}s`;
|
||||
return `${Math.round(duration / 60000)}m`;
|
||||
};
|
||||
|
||||
const events = generateTimelineEvents();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.info[50]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<TimelineIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
mr: 1,
|
||||
color: modernTokens.colors.primary[500]
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Processing Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{events.length} events
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1}>
|
||||
{events.slice(-2).map((event, index) => (
|
||||
<Box key={event.id} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getStatusColor(event.status),
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setExpanded(true)}
|
||||
sx={{ mt: 1, fontSize: '0.75rem' }}
|
||||
>
|
||||
View Full Timeline
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.info[50]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<TimelineIcon
|
||||
sx={{
|
||||
fontSize: 24,
|
||||
mr: 1.5,
|
||||
color: modernTokens.colors.primary[500]
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Processing Timeline
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip
|
||||
label={`${events.length} events`}
|
||||
size="small"
|
||||
sx={{ backgroundColor: modernTokens.colors.neutral[100] }}
|
||||
/>
|
||||
{ocrRetryCount > 0 && (
|
||||
<Chip
|
||||
label={`${ocrRetryCount} retries`}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Timeline */}
|
||||
<Timeline sx={{ p: 0 }}>
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id}>
|
||||
<TimelineSeparator>
|
||||
<TimelineDot
|
||||
sx={{
|
||||
backgroundColor: getStatusColor(event.status),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(event.type, event.status)}
|
||||
</TimelineDot>
|
||||
{index < events.length - 1 && (
|
||||
<TimelineConnector
|
||||
sx={{ backgroundColor: modernTokens.colors.neutral[300] }}
|
||||
/>
|
||||
)}
|
||||
</TimelineSeparator>
|
||||
|
||||
<TimelineContent sx={{ py: 1 }}>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{event.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{event.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{event.metadata?.userId && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<PersonIcon sx={{ fontSize: 14, mr: 0.5, color: modernTokens.colors.neutral[500] }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
User: {event.metadata.userId.substring(0, 8)}...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{index > 0 && events[index - 1] && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
(+{formatDuration(events[index - 1].timestamp, event.timestamp)})
|
||||
</Typography>
|
||||
)}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
|
||||
{/* Retry History Section */}
|
||||
{ocrRetryCount > 0 && (
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${modernTokens.colors.neutral[200]}` }}>
|
||||
<Button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
endIcon={<ExpandIcon sx={{ transform: expanded ? 'rotate(180deg)' : 'none' }} />}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Detailed Retry History
|
||||
</Button>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
{loadingHistory ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading retry history...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : retryHistory.length > 0 ? (
|
||||
<Stack spacing={1}>
|
||||
{retryHistory.map((retry, index) => (
|
||||
<Paper
|
||||
key={retry.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
Retry #{index + 1}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{retry.retry_reason || 'Manual retry'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Detailed retry history not available. Enable detailed logging for future retries.
|
||||
</Alert>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessingTimeline;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import theme from '../../theme';
|
||||
import FileIntegrityDisplay from '../FileIntegrityDisplay';
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('FileIntegrityDisplay', () => {
|
||||
const mockProps = {
|
||||
fileHash: 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890',
|
||||
fileName: 'test-document.pdf',
|
||||
fileSize: 1048576, // 1MB
|
||||
mimeType: 'application/pdf',
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
updatedAt: '2024-01-01T12:00:00Z',
|
||||
userId: 'user-123-456-789',
|
||||
};
|
||||
|
||||
it('renders file integrity information', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('File Integrity & Verification')).toBeInTheDocument();
|
||||
expect(screen.getByText('SHA256 Hash')).toBeInTheDocument();
|
||||
expect(screen.getByText('File Properties')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays file hash correctly', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} />
|
||||
);
|
||||
|
||||
// Should show the full hash in expanded view
|
||||
expect(screen.getByText(mockProps.fileHash)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows compact view when compact prop is true', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} compact={true} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('File Integrity')).toBeInTheDocument();
|
||||
// Should show abbreviated hash in compact view
|
||||
expect(screen.getByText('a1b2c3d4...34567890')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles missing file hash gracefully', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} fileHash={undefined} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hash not available')).toBeInTheDocument();
|
||||
expect(screen.getByText('File hash not available. Enable hash generation in upload settings.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats file size correctly', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('1 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user information', () => {
|
||||
renderWithTheme(
|
||||
<FileIntegrityDisplay {...mockProps} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('User: user-123...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import theme from '../../theme';
|
||||
import MetadataParser from '../MetadataParser';
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MetadataParser', () => {
|
||||
const mockImageMetadata = {
|
||||
exif: {
|
||||
make: 'Canon',
|
||||
model: 'EOS R5',
|
||||
focal_length: 85,
|
||||
aperture: 2.8,
|
||||
iso: 800,
|
||||
width: 4096,
|
||||
height: 2048,
|
||||
date_time_original: '2024-01-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const mockPdfMetadata = {
|
||||
pdf: {
|
||||
title: 'Sample Document',
|
||||
author: 'Test Author',
|
||||
creator: 'Adobe Acrobat',
|
||||
page_count: 5,
|
||||
creation_date: '2024-01-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
it('renders EXIF data for image files', () => {
|
||||
renderWithTheme(
|
||||
<MetadataParser
|
||||
metadata={mockImageMetadata}
|
||||
fileType="image/jpeg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Camera')).toBeInTheDocument();
|
||||
expect(screen.getByText('Canon')).toBeInTheDocument();
|
||||
expect(screen.getByText('EOS R5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PDF metadata for PDF files', () => {
|
||||
renderWithTheme(
|
||||
<MetadataParser
|
||||
metadata={mockPdfMetadata}
|
||||
fileType="application/pdf"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Document Info')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sample Document')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Author')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compact view correctly', () => {
|
||||
renderWithTheme(
|
||||
<MetadataParser
|
||||
metadata={mockImageMetadata}
|
||||
fileType="image/jpeg"
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Camera')).toBeInTheDocument();
|
||||
// In compact mode, should show limited items
|
||||
expect(screen.getByText('Canon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows message when no metadata available', () => {
|
||||
renderWithTheme(
|
||||
<MetadataParser
|
||||
metadata={{}}
|
||||
fileType="text/plain"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No detailed metadata available for this file type')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import theme from '../../theme';
|
||||
import ProcessingTimeline from '../ProcessingTimeline';
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProcessingTimeline', () => {
|
||||
const mockProps = {
|
||||
documentId: 'doc-123',
|
||||
fileName: 'test-document.pdf',
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
updatedAt: '2024-01-01T12:30:00Z',
|
||||
userId: 'user-123',
|
||||
ocrStatus: 'completed',
|
||||
ocrCompletedAt: '2024-01-01T12:15:00Z',
|
||||
ocrRetryCount: 0,
|
||||
};
|
||||
|
||||
it('renders processing timeline', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline {...mockProps} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Processing Timeline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Document Uploaded')).toBeInTheDocument();
|
||||
expect(screen.getByText('OCR Processing Completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows retry information when retries exist', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline
|
||||
{...mockProps}
|
||||
ocrRetryCount={2}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('2 retries')).toBeInTheDocument();
|
||||
expect(screen.getByText('Detailed Retry History')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compact view correctly', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline {...mockProps} compact={true} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Processing Timeline')).toBeInTheDocument();
|
||||
expect(screen.getByText('View Full Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles OCR error status', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline
|
||||
{...mockProps}
|
||||
ocrStatus="failed"
|
||||
ocrError="OCR processing failed due to low image quality"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('OCR Processing Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending OCR status', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline
|
||||
{...mockProps}
|
||||
ocrStatus="processing"
|
||||
ocrCompletedAt={undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('OCR Processing Started')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays event count', () => {
|
||||
renderWithTheme(
|
||||
<ProcessingTimeline {...mockProps} />
|
||||
);
|
||||
|
||||
// Should show at least 2 events (upload + OCR completion)
|
||||
expect(screen.getByText(/\d+ events/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,23 +1,153 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
import { createTheme, alpha } from '@mui/material/styles';
|
||||
|
||||
// Modern 2026 design tokens
|
||||
export const modernTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f4ff',
|
||||
100: '#e0eaff',
|
||||
200: '#c7d7fe',
|
||||
300: '#a5b8fc',
|
||||
400: '#8b93f8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
secondary: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3',
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
},
|
||||
neutral: {
|
||||
0: '#ffffff',
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
200: '#e5e5e5',
|
||||
300: '#d4d4d4',
|
||||
400: '#a3a3a3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
950: '#0a0a0a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
info: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
xs: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
sm: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
glass: '0 8px 32px 0 rgba(31, 38, 135, 0.37)',
|
||||
},
|
||||
};
|
||||
|
||||
// Glassmorphism effect helper
|
||||
export const glassEffect = (alphaValue: number = 0.1) => ({
|
||||
background: `rgba(255, 255, 255, ${alphaValue})`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
boxShadow: modernTokens.shadows.glass,
|
||||
});
|
||||
|
||||
// Modern card style
|
||||
export const modernCard = {
|
||||
borderRadius: 16,
|
||||
boxShadow: modernTokens.shadows.md,
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
background: modernTokens.colors.neutral[0],
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: modernTokens.shadows.lg,
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
};
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
light: '#9bb5ff',
|
||||
dark: '#304ffe',
|
||||
main: modernTokens.colors.primary[500],
|
||||
light: modernTokens.colors.primary[300],
|
||||
dark: modernTokens.colors.primary[700],
|
||||
50: modernTokens.colors.primary[50],
|
||||
100: modernTokens.colors.primary[100],
|
||||
200: modernTokens.colors.primary[200],
|
||||
300: modernTokens.colors.primary[300],
|
||||
400: modernTokens.colors.primary[400],
|
||||
500: modernTokens.colors.primary[500],
|
||||
600: modernTokens.colors.primary[600],
|
||||
700: modernTokens.colors.primary[700],
|
||||
800: modernTokens.colors.primary[800],
|
||||
900: modernTokens.colors.primary[900],
|
||||
},
|
||||
secondary: {
|
||||
main: '#764ba2',
|
||||
light: '#a777d9',
|
||||
dark: '#4c1e74',
|
||||
main: modernTokens.colors.secondary[500],
|
||||
light: modernTokens.colors.secondary[300],
|
||||
dark: modernTokens.colors.secondary[700],
|
||||
},
|
||||
background: {
|
||||
default: '#fafafa',
|
||||
default: modernTokens.colors.neutral[50],
|
||||
paper: modernTokens.colors.neutral[0],
|
||||
},
|
||||
text: {
|
||||
primary: modernTokens.colors.neutral[900],
|
||||
secondary: modernTokens.colors.neutral[600],
|
||||
},
|
||||
divider: modernTokens.colors.neutral[200],
|
||||
success: {
|
||||
main: modernTokens.colors.success[500],
|
||||
light: modernTokens.colors.success[50],
|
||||
dark: modernTokens.colors.success[600],
|
||||
},
|
||||
warning: {
|
||||
main: modernTokens.colors.warning[500],
|
||||
light: modernTokens.colors.warning[50],
|
||||
dark: modernTokens.colors.warning[600],
|
||||
},
|
||||
error: {
|
||||
main: modernTokens.colors.error[500],
|
||||
light: modernTokens.colors.error[50],
|
||||
dark: modernTokens.colors.error[600],
|
||||
},
|
||||
info: {
|
||||
main: modernTokens.colors.info[500],
|
||||
light: modernTokens.colors.info[50],
|
||||
dark: modernTokens.colors.info[600],
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'"Inter"',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
|
|
@ -26,15 +156,54 @@ const theme = createTheme({
|
|||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.25rem',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.875rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.75,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
|
|
@ -42,21 +211,53 @@ const theme = createTheme({
|
|||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 8,
|
||||
fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: modernTokens.shadows.sm,
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[500]} 0%, ${modernTokens.colors.primary[600]} 100%)`,
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[600]} 0%, ${modernTokens.colors.primary[700]} 100%)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
},
|
||||
root: modernCard,
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: modernTokens.shadows.sm,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAccordion: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: 'none',
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
borderRadius: 8,
|
||||
'&:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue