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