258 lines
7.6 KiB
TypeScript
258 lines
7.6 KiB
TypeScript
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 file to backend
|
||
await api.uploadFileToBackend(uploadData.asset_id, file);
|
||
|
||
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>
|
||
);
|
||
}
|