Readur/frontend/src/components/Upload/UploadZone.tsx

546 lines
18 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
LinearProgress,
Chip,
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Paper,
alpha,
useTheme,
Divider,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
InsertDriveFile as FileIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useNotifications } from '../../contexts/NotificationContext';
import LabelSelector from '../Labels/LabelSelector';
import { type LabelData } from '../Labels/Label';
import LanguageSelector from '../LanguageSelector';
interface UploadedDocument {
id: string;
original_filename: string;
filename: string;
file_size: number;
mime_type: string;
created_at: string;
}
interface FileItem {
file: File;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
error: string | null;
documentId?: string;
}
interface UploadZoneProps {
onUploadComplete?: (document: UploadedDocument) => void;
}
type FileStatus = 'pending' | 'uploading' | 'success' | 'error';
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
const theme = useTheme();
const navigate = useNavigate();
const { addBatchNotification } = useNotifications();
const [files, setFiles] = useState<FileItem[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [selectedLabels, setSelectedLabels] = useState<LabelData[]>([]);
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
const [selectedLanguages, setSelectedLanguages] = useState<string[]>(['eng']);
const [primaryLanguage, setPrimaryLanguage] = useState<string>('eng');
useEffect(() => {
fetchLabels();
}, []);
const fetchLabels = async () => {
try {
setLabelsLoading(true);
const response = await api.get('/labels?include_counts=false');
if (response.status === 200 && Array.isArray(response.data)) {
setAvailableLabels(response.data);
} else {
console.error('Failed to fetch labels:', response);
}
} catch (error) {
console.error('Failed to fetch labels:', error);
} finally {
setLabelsLoading(false);
}
};
const handleCreateLabel = async (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => {
try {
const response = await api.post('/labels', labelData);
const newLabel = response.data;
setAvailableLabels(prev => [...prev, newLabel]);
return newLabel;
} catch (error) {
console.error('Failed to create label:', error);
throw error;
}
};
const handleLanguagesChange = (languages: string[], primary?: string) => {
setSelectedLanguages(languages);
if (primary) {
setPrimaryLanguage(primary);
} else if (languages.length > 0) {
setPrimaryLanguage(languages[0]);
}
};
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
setError('');
// Handle rejected files
if (rejectedFiles.length > 0) {
const errors = rejectedFiles.map(file =>
`${file.file.name}: ${file.errors.map(e => e.message).join(', ')}`
);
setError(`Some files were rejected: ${errors.join('; ')}`);
}
// Add accepted files to the list
const newFiles: FileItem[] = acceptedFiles.map(file => ({
file,
id: Math.random().toString(36).substr(2, 9),
status: 'pending' as FileStatus,
progress: 0,
error: null,
}));
setFiles(prev => [...prev, ...newFiles]);
}, []);
const dropzoneOptions: DropzoneOptions = {
onDrop,
accept: {
'application/pdf': ['.pdf'],
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff'],
'text/*': ['.txt', '.rtf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
},
maxSize: 50 * 1024 * 1024, // 50MB
multiple: true,
};
const { getRootProps, getInputProps, isDragActive } = useDropzone(dropzoneOptions);
const removeFile = (fileId: string): void => {
setFiles(prev => prev.filter(f => f.id !== fileId));
};
const uploadFile = async (fileItem: FileItem): Promise<void> => {
const formData = new FormData();
formData.append('file', fileItem.file);
// Add selected labels to the form data
if (selectedLabels.length > 0) {
const labelIds = selectedLabels.map(label => label.id);
formData.append('label_ids', JSON.stringify(labelIds));
}
// Add selected languages to the form data
if (selectedLanguages.length > 0) {
selectedLanguages.forEach((lang, index) => {
formData.append(`ocr_languages[${index}]`, lang);
});
}
try {
setFiles(prev => prev.map(f =>
f.id === fileItem.id
? { ...f, status: 'uploading' as FileStatus, progress: 0 }
: f
));
const response = await api.post<UploadedDocument>('/documents', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setFiles(prev => prev.map(f =>
f.id === fileItem.id
? { ...f, progress }
: f
));
}
},
});
setFiles(prev => prev.map(f =>
f.id === fileItem.id
? { ...f, status: 'success' as FileStatus, progress: 100, documentId: response.data.id }
: f
));
if (onUploadComplete) {
onUploadComplete(response.data);
}
} catch (error: any) {
setFiles(prev => prev.map(f =>
f.id === fileItem.id
? {
...f,
status: 'error' as FileStatus,
error: error.response?.data?.message || 'Upload failed',
progress: 0,
}
: f
));
}
};
const uploadAllFiles = async (): Promise<void> => {
setUploading(true);
setError('');
const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error');
const results: { name: string; success: boolean }[] = [];
try {
await Promise.allSettled(pendingFiles.map(async (file) => {
try {
await uploadFile(file);
results.push({ name: file.file.name, success: true });
} catch (error) {
results.push({ name: file.file.name, success: false });
}
}));
// Trigger notification based on results
const hasFailures = results.some(r => !r.success);
const hasSuccesses = results.some(r => r.success);
if (!hasFailures) {
addBatchNotification('success', 'upload', results);
} else if (!hasSuccesses) {
addBatchNotification('error', 'upload', results);
} else {
addBatchNotification('warning', 'upload', results);
}
} catch (error) {
setError('Upload failed. Please try again.');
} finally {
setUploading(false);
}
};
const retryUpload = (fileItem: FileItem): void => {
uploadFile(fileItem);
};
const clearCompleted = (): void => {
setFiles(prev => prev.filter(f => f.status !== 'success'));
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getStatusColor = (status: FileStatus): string => {
switch (status) {
case 'success': return theme.palette.success.main;
case 'error': return theme.palette.error.main;
case 'uploading': return theme.palette.primary.main;
default: return theme.palette.text.secondary;
}
};
const getStatusIcon = (status: FileStatus): React.ReactElement => {
switch (status) {
case 'success': return <CheckIcon />;
case 'error': return <ErrorIcon />;
case 'uploading': return <UploadIcon />;
default: return <FileIcon />;
}
};
const handleFileClick = (fileItem: FileItem) => {
if (fileItem.status === 'success' && fileItem.documentId) {
navigate(`/documents/${fileItem.documentId}`);
}
};
return (
<Box>
{/* Upload Drop Zone */}
<Card
elevation={0}
sx={{
mb: 3,
border: `2px dashed ${isDragActive ? theme.palette.primary.main : theme.palette.divider}`,
backgroundColor: isDragActive ? alpha(theme.palette.primary.main, 0.04) : 'transparent',
transition: 'all 0.2s ease-in-out',
}}
>
<CardContent>
<Box
{...getRootProps()}
sx={{
textAlign: 'center',
py: 6,
cursor: 'pointer',
outline: 'none',
}}
>
<input {...getInputProps()} />
<UploadIcon
sx={{
fontSize: 64,
color: isDragActive ? 'primary.main' : 'text.secondary',
mb: 2,
}}
/>
<Typography variant="h6" sx={{ mb: 1, fontWeight: 600 }}>
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
or click to browse your computer
</Typography>
<Button
variant="contained"
sx={{
mb: 2,
borderRadius: 2,
px: 3,
}}
>
Choose Files
</Button>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip label="PDF" size="small" variant="outlined" />
<Chip label="Images" size="small" variant="outlined" />
<Chip label="Text" size="small" variant="outlined" />
<Chip label="Word" size="small" variant="outlined" />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
Maximum file size: 50MB per file
</Typography>
</Box>
</CardContent>
</Card>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
{/* Language Selection */}
<Card elevation={0} sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
🌐 OCR Language Settings
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Select languages for optimal OCR text recognition
</Typography>
<Box sx={{ '& > div': { width: '100%' } }}>
<LanguageSelector
selectedLanguages={selectedLanguages}
primaryLanguage={primaryLanguage}
onLanguagesChange={handleLanguagesChange}
disabled={uploading}
/>
</Box>
</CardContent>
</Card>
{/* Label Selection */}
<Card elevation={0} sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
📋 Label Assignment
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Select labels to automatically assign to all uploaded documents
</Typography>
<LabelSelector
selectedLabels={selectedLabels}
availableLabels={availableLabels}
onLabelsChange={setSelectedLabels}
onCreateLabel={handleCreateLabel}
placeholder="Choose labels for your documents..."
size="medium"
disabled={labelsLoading}
/>
{selectedLabels.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
These labels will be applied to all uploaded documents
</Typography>
)}
</CardContent>
</Card>
{/* File List */}
{files.length > 0 && (
<Card elevation={0}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Files ({files.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
onClick={clearCompleted}
disabled={!files.some(f => f.status === 'success')}
>
Clear Completed
</Button>
<Button
variant="contained"
size="small"
onClick={uploadAllFiles}
disabled={uploading || !files.some(f => f.status === 'pending' || f.status === 'error')}
sx={{ borderRadius: 2 }}
>
{uploading ? 'Uploading...' : 'Upload All'}
</Button>
</Box>
</Box>
<List sx={{ p: 0 }}>
{files.map((fileItem, index) => (
<ListItem
key={fileItem.id}
sx={{
px: 0,
py: 2,
borderBottom: index < files.length - 1 ? 1 : 0,
borderColor: 'divider',
cursor: fileItem.status === 'success' && fileItem.documentId ? 'pointer' : 'default',
'&:hover': fileItem.status === 'success' && fileItem.documentId ? {
backgroundColor: alpha(theme.palette.primary.main, 0.04),
} : {},
}}
onClick={() => handleFileClick(fileItem)}
>
<ListItemIcon>
<Box sx={{ color: getStatusColor(fileItem.status) }}>
{getStatusIcon(fileItem.status)}
</Box>
</ListItemIcon>
<ListItemText
sx={{
pr: 6, // Add padding-right to prevent overlap with secondary action
}}
primary={
<Typography
variant="subtitle2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
title={fileItem.file.name}
>
{fileItem.file.name}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" color="text.secondary">
{formatFileSize(fileItem.file.size)}
</Typography>
{fileItem.status === 'uploading' && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={fileItem.progress}
sx={{ height: 4, borderRadius: 2 }}
/>
<Typography variant="caption" color="text.secondary">
{fileItem.progress}%
</Typography>
</Box>
)}
{fileItem.error && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
{fileItem.error}
</Typography>
)}
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{fileItem.status === 'error' && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
retryUpload(fileItem);
}}
sx={{ color: 'primary.main' }}
>
<RefreshIcon fontSize="small" />
</IconButton>
)}
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
removeFile(fileItem.id);
}}
disabled={fileItem.status === 'uploading'}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
</Box>
);
};
export default UploadZone;