feat(client): improve document details page

This commit is contained in:
perf3ct 2025-07-07 22:01:06 +00:00
parent 3a784d4ddf
commit c5827a3d20
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
8 changed files with 2232 additions and 411 deletions

View File

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

View File

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

View File

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

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

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

View File

@ -1,23 +1,153 @@
import { createTheme } from '@mui/material/styles';
import { createTheme, alpha } from '@mui/material/styles';
// Modern 2026 design tokens
export const modernTokens = {
colors: {
primary: {
50: '#f0f4ff',
100: '#e0eaff',
200: '#c7d7fe',
300: '#a5b8fc',
400: '#8b93f8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
secondary: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
},
neutral: {
0: '#ffffff',
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
success: {
50: '#f0fdf4',
500: '#22c55e',
600: '#16a34a',
},
warning: {
50: '#fffbeb',
500: '#f59e0b',
600: '#d97706',
},
error: {
50: '#fef2f2',
500: '#ef4444',
600: '#dc2626',
},
info: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
},
},
shadows: {
xs: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
sm: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
glass: '0 8px 32px 0 rgba(31, 38, 135, 0.37)',
},
};
// Glassmorphism effect helper
export const glassEffect = (alphaValue: number = 0.1) => ({
background: `rgba(255, 255, 255, ${alphaValue})`,
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: modernTokens.shadows.glass,
});
// Modern card style
export const modernCard = {
borderRadius: 16,
boxShadow: modernTokens.shadows.md,
border: `1px solid ${modernTokens.colors.neutral[200]}`,
background: modernTokens.colors.neutral[0],
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: modernTokens.shadows.lg,
transform: 'translateY(-1px)',
},
};
const theme = createTheme({
palette: {
primary: {
main: '#667eea',
light: '#9bb5ff',
dark: '#304ffe',
main: modernTokens.colors.primary[500],
light: modernTokens.colors.primary[300],
dark: modernTokens.colors.primary[700],
50: modernTokens.colors.primary[50],
100: modernTokens.colors.primary[100],
200: modernTokens.colors.primary[200],
300: modernTokens.colors.primary[300],
400: modernTokens.colors.primary[400],
500: modernTokens.colors.primary[500],
600: modernTokens.colors.primary[600],
700: modernTokens.colors.primary[700],
800: modernTokens.colors.primary[800],
900: modernTokens.colors.primary[900],
},
secondary: {
main: '#764ba2',
light: '#a777d9',
dark: '#4c1e74',
main: modernTokens.colors.secondary[500],
light: modernTokens.colors.secondary[300],
dark: modernTokens.colors.secondary[700],
},
background: {
default: '#fafafa',
default: modernTokens.colors.neutral[50],
paper: modernTokens.colors.neutral[0],
},
text: {
primary: modernTokens.colors.neutral[900],
secondary: modernTokens.colors.neutral[600],
},
divider: modernTokens.colors.neutral[200],
success: {
main: modernTokens.colors.success[500],
light: modernTokens.colors.success[50],
dark: modernTokens.colors.success[600],
},
warning: {
main: modernTokens.colors.warning[500],
light: modernTokens.colors.warning[50],
dark: modernTokens.colors.warning[600],
},
error: {
main: modernTokens.colors.error[500],
light: modernTokens.colors.error[50],
dark: modernTokens.colors.error[600],
},
info: {
main: modernTokens.colors.info[500],
light: modernTokens.colors.info[50],
dark: modernTokens.colors.info[600],
},
},
typography: {
fontFamily: [
'"Inter"',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
@ -26,15 +156,54 @@ const theme = createTheme({
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.25rem',
fontWeight: 800,
lineHeight: 1.2,
},
h2: {
fontSize: '1.875rem',
fontWeight: 700,
lineHeight: 1.2,
},
h3: {
fontSize: '1.5rem',
fontWeight: 700,
lineHeight: 1.2,
},
h4: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: '1.125rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.5,
},
body1: {
fontSize: '1rem',
fontWeight: 400,
lineHeight: 1.75,
},
body2: {
fontSize: '0.875rem',
fontWeight: 400,
lineHeight: 1.5,
},
caption: {
fontSize: '0.75rem',
fontWeight: 400,
lineHeight: 1.5,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiButton: {
@ -42,21 +211,53 @@ const theme = createTheme({
root: {
textTransform: 'none',
borderRadius: 8,
fontWeight: 500,
boxShadow: 'none',
'&:hover': {
boxShadow: modernTokens.shadows.sm,
},
},
contained: {
background: `linear-gradient(135deg, ${modernTokens.colors.primary[500]} 0%, ${modernTokens.colors.primary[600]} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${modernTokens.colors.primary[600]} 0%, ${modernTokens.colors.primary[700]} 100%)`,
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
root: modernCard,
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: modernTokens.shadows.sm,
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 500,
},
},
},
MuiAccordion: {
styleOverrides: {
root: {
boxShadow: 'none',
border: `1px solid ${modernTokens.colors.neutral[200]}`,
borderRadius: 8,
'&:before': {
display: 'none',
},
'&.Mui-expanded': {
margin: 0,
},
},
},
},