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,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
Container,
|
||||||
|
Fade,
|
||||||
|
Skeleton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import Grid from '@mui/material/GridLegacy';
|
import Grid from '@mui/material/GridLegacy';
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,13 +44,19 @@ import {
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
|
Speed as SpeedIcon,
|
||||||
|
MoreVert as MoreIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { documentService, OcrResponse } from '../services/api';
|
import { documentService, OcrResponse } from '../services/api';
|
||||||
import DocumentViewer from '../components/DocumentViewer';
|
import DocumentViewer from '../components/DocumentViewer';
|
||||||
import LabelSelector from '../components/Labels/LabelSelector';
|
import LabelSelector from '../components/Labels/LabelSelector';
|
||||||
import { type LabelData } from '../components/Labels/Label';
|
import { type LabelData } from '../components/Labels/Label';
|
||||||
import MetadataDisplay from '../components/MetadataDisplay';
|
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 { RetryHistoryModal } from '../components/RetryHistoryModal';
|
||||||
|
import { modernTokens, glassEffect } from '../theme';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
|
|
@ -57,6 +66,9 @@ interface Document {
|
||||||
file_size: number;
|
file_size: number;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
user_id: string;
|
||||||
|
file_hash?: string;
|
||||||
has_ocr_text?: boolean;
|
has_ocr_text?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
original_created_at?: string;
|
original_created_at?: string;
|
||||||
|
|
@ -337,50 +349,141 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box
|
||||||
{/* Header */}
|
sx={{
|
||||||
<Box sx={{ mb: 4 }}>
|
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
|
<Button
|
||||||
startIcon={<BackIcon />}
|
startIcon={<BackIcon />}
|
||||||
onClick={() => navigate('/documents')}
|
onClick={() => navigate('/documents')}
|
||||||
sx={{ mb: 2 }}
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
color: modernTokens.colors.neutral[600],
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: modernTokens.colors.neutral[100],
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Back to Documents
|
Back to Documents
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h4"
|
variant="h2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 800,
|
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',
|
backgroundClip: 'text',
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: 'text',
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
mb: 1,
|
letterSpacing: '-0.02em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Document Details
|
{document?.original_filename || 'Document Details'}
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
View and manage document information
|
|
||||||
</Typography>
|
</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>
|
</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 */}
|
{/* Document Preview */}
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<Card sx={{ height: 'fit-content' }}>
|
|
||||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
mb: 3,
|
mb: 4,
|
||||||
p: 3,
|
p: 4,
|
||||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
|
background: `linear-gradient(135deg, ${modernTokens.colors.primary[100]} 0%, ${modernTokens.colors.secondary[100]} 100%)`,
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
minHeight: 200,
|
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 ? (
|
{thumbnailUrl ? (
|
||||||
|
|
@ -390,19 +493,20 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
onClick={handleViewDocument}
|
onClick={handleViewDocument}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '200px',
|
maxHeight: '250px',
|
||||||
borderRadius: '8px',
|
borderRadius: '12px',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
cursor: 'pointer',
|
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) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1.02)';
|
e.currentTarget.style.transform = 'scale(1.05) rotateY(5deg)';
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
|
e.currentTarget.style.boxShadow = modernTokens.shadows.xl;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
e.currentTarget.style.transform = 'scale(1) rotateY(0deg)';
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
e.currentTarget.style.boxShadow = modernTokens.shadows.lg;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -410,246 +514,235 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
onClick={handleViewDocument}
|
onClick={handleViewDocument}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'transform 0.2s ease-in-out',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
'&:hover': {
|
'&: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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
{/* File Type Badge */}
|
||||||
{document.original_filename}
|
<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>
|
||||||
|
<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 && (
|
{document.has_ocr_text && (
|
||||||
<Button
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
variant="outlined"
|
<Typography variant="body2" color="text.secondary">
|
||||||
startIcon={<SearchIcon />}
|
OCR Status
|
||||||
onClick={handleViewOcr}
|
</Typography>
|
||||||
sx={{ borderRadius: 2 }}
|
<Chip
|
||||||
>
|
label="Text Extracted"
|
||||||
OCR Text
|
color="success"
|
||||||
</Button>
|
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>
|
</Stack>
|
||||||
|
|
||||||
{document.has_ocr_text && (
|
{/* Action Buttons */}
|
||||||
<Chip
|
<Stack direction="row" spacing={1} sx={{ mt: 4 }} justifyContent="center">
|
||||||
label="OCR Processed"
|
{document.mime_type?.includes('image') && (
|
||||||
color="success"
|
<Tooltip title="View Processed Image">
|
||||||
variant="outlined"
|
<IconButton
|
||||||
icon={<TextIcon />}
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Document Information */}
|
{/* Main Content Area */}
|
||||||
<Grid item xs={12} md={8}>
|
<Grid item xs={12} lg={7}>
|
||||||
<Card>
|
<Stack spacing={4}>
|
||||||
<CardContent>
|
{/* File Integrity Display */}
|
||||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
|
<FileIntegrityDisplay
|
||||||
Document Information
|
fileHash={document.file_hash}
|
||||||
</Typography>
|
fileName={document.original_filename}
|
||||||
|
fileSize={document.file_size}
|
||||||
<Grid container spacing={3}>
|
mimeType={document.mime_type}
|
||||||
<Grid item xs={12} sm={6}>
|
createdAt={document.created_at}
|
||||||
<Paper sx={{ p: 2, height: '100%' }}>
|
updatedAt={document.updated_at}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
userId={document.user_id}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</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 && (
|
{document.tags && document.tags.length > 0 && (
|
||||||
<Grid item xs={12}>
|
<Box sx={{ mb: 3 }}>
|
||||||
<Paper sx={{ p: 2 }}>
|
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<TagIcon color="primary" sx={{ mr: 1 }} />
|
|
||||||
<Typography variant="subtitle2" color="text.secondary">
|
|
||||||
Tags
|
Tags
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||||
{document.tags.map((tag, index) => (
|
{document.tags.map((tag, index) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={index}
|
key={index}
|
||||||
label={tag}
|
label={tag}
|
||||||
color="primary"
|
sx={{
|
||||||
variant="outlined"
|
backgroundColor: modernTokens.colors.primary[100],
|
||||||
|
color: modernTokens.colors.primary[700],
|
||||||
|
border: `1px solid ${modernTokens.colors.primary[300]}`,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Box>
|
||||||
</Grid>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Labels Section */}
|
{/* Labels */}
|
||||||
<Grid item xs={12}>
|
<Box>
|
||||||
<Paper sx={{ p: 2 }}>
|
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
<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
|
Labels
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<EditIcon />}
|
|
||||||
onClick={() => setShowLabelDialog(true)}
|
|
||||||
sx={{ borderRadius: 2 }}
|
|
||||||
>
|
|
||||||
Edit Labels
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{documentLabels.length > 0 ? (
|
{documentLabels.length > 0 ? (
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||||
{documentLabels.map((label) => (
|
{documentLabels.map((label) => (
|
||||||
|
|
@ -657,10 +750,10 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
key={label.id}
|
key={label.id}
|
||||||
label={label.name}
|
label={label.name}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: label.background_color || label.color + '20',
|
backgroundColor: label.background_color || `${label.color}20`,
|
||||||
color: label.color,
|
color: label.color,
|
||||||
borderColor: label.color,
|
border: `1px solid ${label.color}`,
|
||||||
border: '1px solid',
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -670,154 +763,221 @@ const DocumentDetailsPage: React.FC = () => {
|
||||||
No labels assigned to this document
|
No labels assigned to this document
|
||||||
</Typography>
|
</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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Fade>
|
||||||
|
|
||||||
{/* OCR Text Section */}
|
{/* OCR Text Section */}
|
||||||
{document.has_ocr_text && (
|
{document.has_ocr_text && (
|
||||||
<Grid item xs={12}>
|
<Fade in timeout={1000}>
|
||||||
<Card>
|
<Card
|
||||||
<CardContent>
|
sx={{
|
||||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
|
mt: 4,
|
||||||
Extracted Text (OCR)
|
...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>
|
</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 ? (
|
{ocrLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<CircularProgress size={24} sx={{ mr: 2 }} />
|
<CircularProgress size={32} sx={{ mr: 2 }} />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="h6" color="text.secondary">
|
||||||
Loading OCR text...
|
Loading OCR analysis...
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : ocrData ? (
|
) : ocrData ? (
|
||||||
<>
|
<>
|
||||||
{/* OCR Stats */}
|
{/* Enhanced OCR Stats */}
|
||||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
{ocrData.ocr_confidence && (
|
{ocrData.ocr_confidence && (
|
||||||
<Chip
|
<Box
|
||||||
label={`${Math.round(ocrData.ocr_confidence)}% confidence`}
|
sx={{
|
||||||
color="primary"
|
p: 2,
|
||||||
size="small"
|
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 && (
|
{ocrData.ocr_word_count && (
|
||||||
<Chip
|
<Box
|
||||||
label={`${ocrData.ocr_word_count} words`}
|
sx={{
|
||||||
color="secondary"
|
p: 2,
|
||||||
size="small"
|
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 && (
|
{ocrData.ocr_processing_time_ms && (
|
||||||
<Chip
|
<Box
|
||||||
label={`${ocrData.ocr_processing_time_ms}ms processing`}
|
sx={{
|
||||||
color="info"
|
p: 2,
|
||||||
size="small"
|
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>
|
</Box>
|
||||||
|
|
||||||
{/* OCR Error Display */}
|
{/* OCR Error Display */}
|
||||||
{ocrData.ocr_error && (
|
{ocrData.ocr_error && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }}>
|
<Alert
|
||||||
OCR Error: {ocrData.ocr_error}
|
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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OCR Text Content */}
|
{/* OCR Text Preview */}
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
p: 3,
|
p: 4,
|
||||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.900',
|
background: `linear-gradient(135deg, ${modernTokens.colors.neutral[50]} 0%, ${modernTokens.colors.neutral[100]} 100%)`,
|
||||||
border: '1px solid',
|
border: `1px solid ${modernTokens.colors.neutral[300]}`,
|
||||||
borderColor: 'divider',
|
borderRadius: 3,
|
||||||
maxHeight: 400,
|
maxHeight: 300,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ocrData.ocr_text ? (
|
{ocrData.ocr_text ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body1"
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: 'monospace',
|
fontFamily: '"Inter", monospace',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.8,
|
||||||
color: 'text.primary',
|
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>
|
||||||
) : (
|
) : (
|
||||||
<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.
|
No OCR text available for this document.
|
||||||
</Typography>
|
</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>
|
</Paper>
|
||||||
|
|
||||||
{/* Processing Info */}
|
{/* Processing Info */}
|
||||||
{ocrData.ocr_completed_at && (
|
{ocrData.ocr_completed_at && (
|
||||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
<Box sx={{ mt: 3, pt: 3, borderTop: `1px solid ${modernTokens.colors.neutral[200]}` }}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
|
✅ Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info">
|
<Alert
|
||||||
OCR text is available but failed to load. Try clicking the "View OCR" button above.
|
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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Container>
|
||||||
|
|
||||||
{/* OCR Text Dialog */}
|
{/* OCR Text Dialog */}
|
||||||
<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({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
primary: {
|
primary: {
|
||||||
main: '#667eea',
|
main: modernTokens.colors.primary[500],
|
||||||
light: '#9bb5ff',
|
light: modernTokens.colors.primary[300],
|
||||||
dark: '#304ffe',
|
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: {
|
secondary: {
|
||||||
main: '#764ba2',
|
main: modernTokens.colors.secondary[500],
|
||||||
light: '#a777d9',
|
light: modernTokens.colors.secondary[300],
|
||||||
dark: '#4c1e74',
|
dark: modernTokens.colors.secondary[700],
|
||||||
},
|
},
|
||||||
background: {
|
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: {
|
typography: {
|
||||||
fontFamily: [
|
fontFamily: [
|
||||||
|
'"Inter"',
|
||||||
'-apple-system',
|
'-apple-system',
|
||||||
'BlinkMacSystemFont',
|
'BlinkMacSystemFont',
|
||||||
'"Segoe UI"',
|
'"Segoe UI"',
|
||||||
|
|
@ -26,15 +156,54 @@ const theme = createTheme({
|
||||||
'Arial',
|
'Arial',
|
||||||
'sans-serif',
|
'sans-serif',
|
||||||
].join(','),
|
].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: {
|
h4: {
|
||||||
|
fontSize: '1.25rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
h5: {
|
h5: {
|
||||||
|
fontSize: '1.125rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
h6: {
|
h6: {
|
||||||
|
fontSize: '1rem',
|
||||||
fontWeight: 600,
|
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: {
|
components: {
|
||||||
MuiButton: {
|
MuiButton: {
|
||||||
|
|
@ -42,21 +211,53 @@ const theme = createTheme({
|
||||||
root: {
|
root: {
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
borderRadius: 8,
|
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: {
|
MuiCard: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: modernCard,
|
||||||
borderRadius: 12,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiPaper: {
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: modernTokens.shadows.sm,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
borderRadius: 8,
|
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