itcloud/frontend/src/components/UploadDialog.tsx

258 lines
7.6 KiB
TypeScript
Raw Normal View History

2025-12-30 13:35:19 +01:00
import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
LinearProgress,
List,
ListItem,
ListItemText,
IconButton,
Alert,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Close as CloseIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import api from '../services/api';
interface UploadFile {
file: File;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
assetId?: string;
}
interface UploadDialogProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
export default function UploadDialog({ open, onClose, onComplete }: UploadDialogProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles: UploadFile[] = acceptedFiles.map((file) => ({
file,
progress: 0,
status: 'pending',
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
'video/*': ['.mp4', '.mov', '.avi', '.mkv', '.webm'],
},
});
const updateFileProgress = (index: number, progress: number, status: UploadFile['status'], error?: string, assetId?: string) => {
setFiles((prev) =>
prev.map((f, i) =>
i === index ? { ...f, progress, status, error, assetId } : f
)
);
};
const uploadFile = async (file: File, index: number) => {
try {
updateFileProgress(index, 0, 'uploading');
// Step 1: Create upload
const uploadData = await api.createUpload({
original_filename: file.name,
content_type: file.type,
size_bytes: file.size,
});
updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id);
// Step 2: Upload to S3
await api.uploadToS3(uploadData.upload_url, file, uploadData.fields);
updateFileProgress(index, 66, 'uploading', undefined, uploadData.asset_id);
// Step 3: Finalize upload
await api.finalizeUpload(uploadData.asset_id);
updateFileProgress(index, 100, 'success', undefined, uploadData.asset_id);
} catch (error: any) {
console.error('Upload failed:', error);
updateFileProgress(
index,
0,
'error',
error.response?.data?.detail || 'Ошибка загрузки'
);
}
};
const handleUpload = async () => {
setUploading(true);
// Upload files in parallel (max 3 at a time)
const batchSize = 3;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(
batch.map((file, batchIndex) => {
const fileIndex = i + batchIndex;
if (files[fileIndex].status === 'pending') {
return uploadFile(files[fileIndex].file, fileIndex);
}
return Promise.resolve();
})
);
}
setUploading(false);
if (onComplete) {
onComplete();
}
};
const handleClose = () => {
if (!uploading) {
setFiles([]);
onClose();
}
};
const canUpload = files.length > 0 && files.some((f) => f.status === 'pending');
const allComplete = files.length > 0 && files.every((f) => f.status === 'success' || f.status === 'error');
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>
Загрузка файлов
<IconButton
onClick={handleClose}
disabled={uploading}
sx={{ position: 'absolute', right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
{files.length === 0 && (
<Box
{...getRootProps()}
sx={{
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.400',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragActive ? 'action.hover' : 'transparent',
transition: 'all 0.3s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
},
}}
>
<input {...getInputProps()} />
<UploadIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{isDragActive
? 'Отпустите файлы для загрузки'
: 'Перетащите файлы сюда'}
</Typography>
<Typography variant="body2" color="text.secondary">
или нажмите для выбора файлов
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 2 }}>
Поддерживаются фото (JPG, PNG, GIF, WebP) и видео (MP4, MOV, AVI, MKV, WebM)
</Typography>
</Box>
)}
{files.length > 0 && (
<List sx={{ maxHeight: 400, overflow: 'auto' }}>
{files.map((uploadFile, index) => (
<ListItem key={index} sx={{ flexDirection: 'column', alignItems: 'stretch' }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', mb: 1 }}>
<ListItemText
primary={uploadFile.file.name}
secondary={`${(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB`}
/>
{uploadFile.status === 'success' && (
<SuccessIcon color="success" />
)}
{uploadFile.status === 'error' && (
<ErrorIcon color="error" />
)}
</Box>
{uploadFile.status === 'uploading' && (
<LinearProgress
variant="determinate"
value={uploadFile.progress}
sx={{ mb: 1 }}
/>
)}
{uploadFile.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{uploadFile.error}
</Alert>
)}
</ListItem>
))}
</List>
)}
{files.length > 0 && !allComplete && (
<Box
{...getRootProps()}
sx={{
mt: 2,
p: 2,
border: '1px dashed',
borderColor: 'grey.400',
borderRadius: 1,
textAlign: 'center',
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<input {...getInputProps()} />
<Typography variant="body2" color="text.secondary">
Добавить еще файлы
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={uploading}>
{allComplete ? 'Закрыть' : 'Отмена'}
</Button>
{canUpload && (
<Button
onClick={handleUpload}
variant="contained"
disabled={uploading}
>
Загрузить ({files.filter((f) => f.status === 'pending').length})
</Button>
)}
</DialogActions>
</Dialog>
);
}