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();
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,9 @@ import {
|
|||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Container,
|
||||
Fade,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/GridLegacy';
|
||||
import {
|
||||
|
|
@ -41,13 +44,19 @@ import {
|
|||
Info as InfoIcon,
|
||||
Refresh as RefreshIcon,
|
||||
History as HistoryIcon,
|
||||
Speed as SpeedIcon,
|
||||
MoreVert as MoreIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { documentService, OcrResponse } from '../services/api';
|
||||
import DocumentViewer from '../components/DocumentViewer';
|
||||
import LabelSelector from '../components/Labels/LabelSelector';
|
||||
import { type LabelData } from '../components/Labels/Label';
|
||||
import MetadataDisplay from '../components/MetadataDisplay';
|
||||
import MetadataParser from '../components/MetadataParser';
|
||||
import FileIntegrityDisplay from '../components/FileIntegrityDisplay';
|
||||
import ProcessingTimeline from '../components/ProcessingTimeline';
|
||||
import { RetryHistoryModal } from '../components/RetryHistoryModal';
|
||||
import { modernTokens, glassEffect } from '../theme';
|
||||
import api from '../services/api';
|
||||
|
||||
interface Document {
|
||||
|
|
@ -57,6 +66,9 @@ interface Document {
|
|||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
file_hash?: string;
|
||||
has_ocr_text?: boolean;
|
||||
tags?: string[];
|
||||
original_created_at?: string;
|
||||
|
|
@ -337,50 +349,141 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[50]} 0%, ${modernTokens.colors.secondary[50]} 100%)`,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{/* Modern Header */}
|
||||
<Fade in timeout={600}>
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate('/documents')}
|
||||
sx={{ mb: 2 }}
|
||||
sx={{
|
||||
mb: 3,
|
||||
color: modernTokens.colors.neutral[600],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.neutral[100],
|
||||
},
|
||||
}}
|
||||
>
|
||||
Back to Documents
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[600]} 0%, ${modernTokens.colors.secondary[600]} 100%)`,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Document Details
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
View and manage document information
|
||||
{document?.original_filename || 'Document Details'}
|
||||
</Typography>
|
||||
|
||||
{/* Floating Action Menu */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Download">
|
||||
<IconButton
|
||||
onClick={handleDownload}
|
||||
sx={{
|
||||
...glassEffect(0.2),
|
||||
color: modernTokens.colors.primary[600],
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="View Document">
|
||||
<IconButton
|
||||
onClick={handleViewDocument}
|
||||
sx={{
|
||||
...glassEffect(0.2),
|
||||
color: modernTokens.colors.primary[600],
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{document?.has_ocr_text && (
|
||||
<Tooltip title="View OCR Text">
|
||||
<IconButton
|
||||
onClick={handleViewOcr}
|
||||
sx={{
|
||||
...glassEffect(0.2),
|
||||
color: modernTokens.colors.secondary[600],
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
backgroundColor: modernTokens.colors.secondary[100],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.1rem' }}>
|
||||
Comprehensive document analysis and metadata viewer
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
|
||||
{/* Modern Content Layout */}
|
||||
<Fade in timeout={800}>
|
||||
<Grid container spacing={4}>
|
||||
{/* Hero Document Preview */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Card
|
||||
sx={{
|
||||
...glassEffect(0.3),
|
||||
height: 'fit-content',
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[0]} 0%, ${modernTokens.colors.primary[50]} 100%)`,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{/* Document Preview */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card sx={{ height: 'fit-content' }}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 3,
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
|
||||
borderRadius: 2,
|
||||
minHeight: 200,
|
||||
mb: 4,
|
||||
p: 4,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[100]} 0%, ${modernTokens.colors.secondary[100]} 100%)`,
|
||||
borderRadius: 3,
|
||||
minHeight: 280,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.3) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
|
|
@ -390,19 +493,20 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
onClick={handleViewDocument}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '200px',
|
||||
borderRadius: '8px',
|
||||
maxHeight: '250px',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: modernTokens.shadows.lg,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.02)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
|
||||
e.currentTarget.style.transform = 'scale(1.05) rotateY(5deg)';
|
||||
e.currentTarget.style.boxShadow = modernTokens.shadows.xl;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'scale(1) rotateY(0deg)';
|
||||
e.currentTarget.style.boxShadow = modernTokens.shadows.lg;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -410,246 +514,235 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
onClick={handleViewDocument}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
transform: 'scale(1.1) rotateY(10deg)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getFileIcon(document.mime_type)}
|
||||
{React.cloneElement(getFileIcon(document.mime_type), {
|
||||
sx: { fontSize: 120, color: modernTokens.colors.primary[400] }
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{document.original_filename}
|
||||
{/* File Type Badge */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
<Chip
|
||||
label={document.mime_type}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
color: modernTokens.colors.primary[700],
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${modernTokens.colors.primary[300]}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<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: 600 }}>
|
||||
{formatFileSize(document.file_size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload Date
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatDate(document.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mb: 3, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ViewIcon />}
|
||||
onClick={handleViewDocument}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownload}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
{document.has_ocr_text && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<SearchIcon />}
|
||||
onClick={handleViewOcr}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
OCR Text
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
OCR Status
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Text Extracted"
|
||||
color="success"
|
||||
size="small"
|
||||
icon={<TextIcon sx={{ fontSize: 16 }} />}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{document.mime_type?.includes('image') && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ProcessedImageIcon />}
|
||||
onClick={handleViewProcessedImage}
|
||||
disabled={processedImageLoading}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{processedImageLoading ? 'Loading...' : 'Processed Image'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={retryingOcr ? <CircularProgress size={16} /> : <RefreshIcon />}
|
||||
onClick={handleRetryOcr}
|
||||
disabled={retryingOcr}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{retryingOcr ? 'Retrying...' : 'Retry OCR'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={handleShowRetryHistory}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Retry History
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{document.has_ocr_text && (
|
||||
<Chip
|
||||
label="OCR Processed"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
icon={<TextIcon />}
|
||||
/>
|
||||
{/* Action Buttons */}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 4 }} justifyContent="center">
|
||||
{document.mime_type?.includes('image') && (
|
||||
<Tooltip title="View Processed Image">
|
||||
<IconButton
|
||||
onClick={handleViewProcessedImage}
|
||||
disabled={processedImageLoading}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.secondary[100],
|
||||
color: modernTokens.colors.secondary[600],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.secondary[200],
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{processedImageLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<ProcessedImageIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Retry OCR">
|
||||
<IconButton
|
||||
onClick={handleRetryOcr}
|
||||
disabled={retryingOcr}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.warning[100],
|
||||
color: modernTokens.colors.warning[600],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.warning[200],
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{retryingOcr ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<RefreshIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Retry History">
|
||||
<IconButton
|
||||
onClick={handleShowRetryHistory}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.info[100],
|
||||
color: modernTokens.colors.info[600],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.info[200],
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HistoryIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Document Information */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Document Information
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<DocIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Filename
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{document.original_filename}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<SizeIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
File Size
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{formatFileSize(document.file_size)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<DateIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Upload Date
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(document.created_at)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<ViewIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
File Type
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{document.mime_type}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Source Metadata Section */}
|
||||
{(document.original_created_at || document.original_modified_at || document.source_metadata) && (
|
||||
<>
|
||||
{document.original_created_at && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<CreateIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Original Created
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(document.original_created_at)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{document.original_modified_at && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<AccessTimeIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Original Modified
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{formatDate(document.original_modified_at)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{document.source_metadata && Object.keys(document.source_metadata).length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<MetadataDisplay
|
||||
metadata={document.source_metadata}
|
||||
title="Source Metadata"
|
||||
compact={false}
|
||||
{/* Main Content Area */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={4}>
|
||||
{/* File Integrity Display */}
|
||||
<FileIntegrityDisplay
|
||||
fileHash={document.file_hash}
|
||||
fileName={document.original_filename}
|
||||
fileSize={document.file_size}
|
||||
mimeType={document.mime_type}
|
||||
createdAt={document.created_at}
|
||||
updatedAt={document.updated_at}
|
||||
userId={document.user_id}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Processing Timeline */}
|
||||
<ProcessingTimeline
|
||||
documentId={document.id}
|
||||
fileName={document.original_filename}
|
||||
createdAt={document.created_at}
|
||||
updatedAt={document.updated_at}
|
||||
userId={document.user_id}
|
||||
ocrStatus={document.has_ocr_text ? 'completed' : 'pending'}
|
||||
ocrCompletedAt={ocrData?.ocr_completed_at}
|
||||
ocrRetryCount={ocrData?.ocr_retry_count}
|
||||
ocrError={ocrData?.ocr_error}
|
||||
/>
|
||||
|
||||
{/* Enhanced Metadata Display */}
|
||||
{document.source_metadata && Object.keys(document.source_metadata).length > 0 && (
|
||||
<Card
|
||||
sx={{
|
||||
...glassEffect(0.2),
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[0]} 0%, ${modernTokens.colors.info[50]} 100%)`,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 700 }}>
|
||||
📊 Rich Metadata Analysis
|
||||
</Typography>
|
||||
<MetadataParser
|
||||
metadata={document.source_metadata}
|
||||
fileType={document.mime_type}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags and Labels */}
|
||||
<Card
|
||||
sx={{
|
||||
...glassEffect(0.2),
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[0]} 0%, ${modernTokens.colors.secondary[50]} 100%)`,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
🏷️ Tags & Labels
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => setShowLabelDialog(true)}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.secondary[100],
|
||||
color: modernTokens.colors.secondary[700],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.secondary[200],
|
||||
},
|
||||
}}
|
||||
>
|
||||
Edit Labels
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Tags */}
|
||||
{document.tags && document.tags.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<TagIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Tags
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
{document.tags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
color: modernTokens.colors.primary[700],
|
||||
border: `1px solid ${modernTokens.colors.primary[300]}`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Labels Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<LabelIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{/* Labels */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Labels
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => setShowLabelDialog(true)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Edit Labels
|
||||
</Button>
|
||||
</Box>
|
||||
{documentLabels.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
{documentLabels.map((label) => (
|
||||
|
|
@ -657,10 +750,10 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
key={label.id}
|
||||
label={label.name}
|
||||
sx={{
|
||||
backgroundColor: label.background_color || label.color + '20',
|
||||
backgroundColor: label.background_color || `${label.color}20`,
|
||||
color: label.color,
|
||||
borderColor: label.color,
|
||||
border: '1px solid',
|
||||
border: `1px solid ${label.color}`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -670,154 +763,221 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
No labels assigned to this document
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Processing Status
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'success.main',
|
||||
mr: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
Document uploaded successfully
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: document.has_ocr_text ? 'success.main' : 'warning.main',
|
||||
mr: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{document.has_ocr_text ? 'OCR processing completed' : 'OCR processing pending'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fade>
|
||||
|
||||
{/* OCR Text Section */}
|
||||
{document.has_ocr_text && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Extracted Text (OCR)
|
||||
<Fade in timeout={1000}>
|
||||
<Card
|
||||
sx={{
|
||||
mt: 4,
|
||||
...glassEffect(0.2),
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[0]} 0%, ${modernTokens.colors.success[50]} 100%)`,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
🔍 Extracted Text (OCR)
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<SpeedIcon />}
|
||||
onClick={handleViewOcr}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.success[100],
|
||||
color: modernTokens.colors.success[700],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.success[200],
|
||||
},
|
||||
}}
|
||||
>
|
||||
View Full Text
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{ocrLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} sx={{ mr: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading OCR text...
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<CircularProgress size={32} sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Loading OCR analysis...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : ocrData ? (
|
||||
<>
|
||||
{/* OCR Stats */}
|
||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{/* Enhanced OCR Stats */}
|
||||
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{ocrData.ocr_confidence && (
|
||||
<Chip
|
||||
label={`${Math.round(ocrData.ocr_confidence)}% confidence`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
border: `1px solid ${modernTokens.colors.primary[300]}`,
|
||||
textAlign: 'center',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: modernTokens.colors.primary[700] }}>
|
||||
{Math.round(ocrData.ocr_confidence)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{ocrData.ocr_word_count && (
|
||||
<Chip
|
||||
label={`${ocrData.ocr_word_count} words`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: modernTokens.colors.secondary[100],
|
||||
border: `1px solid ${modernTokens.colors.secondary[300]}`,
|
||||
textAlign: 'center',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: modernTokens.colors.secondary[700] }}>
|
||||
{ocrData.ocr_word_count.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{ocrData.ocr_processing_time_ms && (
|
||||
<Chip
|
||||
label={`${ocrData.ocr_processing_time_ms}ms processing`}
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: modernTokens.colors.info[100],
|
||||
border: `1px solid ${modernTokens.colors.info[300]}`,
|
||||
textAlign: 'center',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: modernTokens.colors.info[700] }}>
|
||||
{ocrData.ocr_processing_time_ms}ms
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Processing Time
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* OCR Error Display */}
|
||||
{ocrData.ocr_error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
OCR Error: {ocrData.ocr_error}
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: modernTokens.colors.error[50],
|
||||
border: `1px solid ${modernTokens.colors.error[200]}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
OCR Processing Error
|
||||
</Typography>
|
||||
<Typography variant="body2">{ocrData.ocr_error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* OCR Text Content */}
|
||||
{/* OCR Text Preview */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.900',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxHeight: 400,
|
||||
p: 4,
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.neutral[100]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.neutral[300]}`,
|
||||
borderRadius: 3,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{ocrData.ocr_text ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: '"Inter", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.8,
|
||||
color: modernTokens.colors.neutral[800],
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
>
|
||||
{ocrData.ocr_text}
|
||||
{ocrData.ocr_text.length > 500
|
||||
? `${ocrData.ocr_text.substring(0, 500)}...`
|
||||
: ocrData.ocr_text
|
||||
}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 4 }}>
|
||||
No OCR text available for this document.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{ocrData.ocr_text && ocrData.ocr_text.length > 500 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 60,
|
||||
background: `linear-gradient(transparent, ${modernTokens.colors.neutral[100]})`,
|
||||
display: 'flex',
|
||||
alignItems: 'end',
|
||||
justifyContent: 'center',
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleViewOcr}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.neutral[0],
|
||||
boxShadow: modernTokens.shadows.sm,
|
||||
}}
|
||||
>
|
||||
View Full Text
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Processing Info */}
|
||||
{ocrData.ocr_completed_at && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: `1px solid ${modernTokens.colors.neutral[200]}` }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
✅ Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
OCR text is available but failed to load. Try clicking the "View OCR" button above.
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: modernTokens.colors.info[50],
|
||||
border: `1px solid ${modernTokens.colors.info[200]}`,
|
||||
}}
|
||||
>
|
||||
OCR text is available but failed to load. Try clicking the "View Full Text" button above.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
{/* OCR Text Dialog */}
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -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