From 930e811232b9a7df53b6391db82a75f5807c4b82 Mon Sep 17 00:00:00 2001 From: smanylov Date: Wed, 11 Feb 2026 20:11:39 +0700 Subject: [PATCH] add upload multiple files --- package.json | 2 +- src/app/components/UploadSectionFile.tsx | 579 ++++++++++++++--------- src/i18n/messages/en.json | 9 +- src/i18n/messages/ru.json | 11 +- 4 files changed, 386 insertions(+), 215 deletions(-) diff --git a/package.json b/package.json index 0a905ac..5314cfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "no-copy-frontend", - "version": "0.37.0", + "version": "0.38.0", "private": true, "scripts": { "dev": "next dev -p 2999", diff --git a/src/app/components/UploadSectionFile.tsx b/src/app/components/UploadSectionFile.tsx index bbfae50..c6cbaae 100644 --- a/src/app/components/UploadSectionFile.tsx +++ b/src/app/components/UploadSectionFile.tsx @@ -6,6 +6,8 @@ import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus } from '@/app/a import { useTranslations } from 'next-intl'; import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker'; import { useQueryClient } from '@tanstack/react-query'; +import { resolve } from 'path'; + interface SelectedFile { file: File; preview: string | undefined; @@ -15,32 +17,41 @@ interface SelectedFile { width: number; height: number; }; + uploadId?: string; + progress: number; + status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled'; + error?: string; } interface FileUploadInitResponse { - upload_id: string, - file_name: string, - total_chunks: number, - chunk_size: number, - status: string + upload_id: string; + file_name: string; + total_chunks: number; + chunk_size: number; + status: string; } interface UploadSectionFile { - fileType: string - allowedExtensions: string[] - maxFileSize: number + fileType: string; + allowedExtensions: string[]; + maxFileSize: number; + maxParallelUploads?: number; } -export default function UploadSectionFile({ fileType, allowedExtensions, maxFileSize }: UploadSectionFile) { +export default function UploadSectionFile({ + fileType, + allowedExtensions, + maxFileSize, + maxParallelUploads = 3 +}: UploadSectionFile) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [error, setError] = useState(null); - const [uploadId, setUploadId] = useState(null); - const [isFileUploaded, setIsFileUploaded] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); + const [activeUploads, setActiveUploads] = useState>(new Set()); // ID активных загрузок const queryClient = useQueryClient(); - const isCancelledRef = useRef(false); + const isCancelledRef = useRef>(new Map()); // Для отслеживания отмен по файлам + const selectedFilesRef = useRef(selectedFiles); const t = useTranslations('Global'); @@ -74,11 +85,6 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile return { isValid: true }; }; - function setErrorHandler(message: string | null): void { - setError(message); - setUploadProgress(0); - } - const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve, reject) => { const img = new Image(); @@ -102,63 +108,61 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile }); }; - const handleFileSelect = useCallback(async (file: File | null): Promise => { - if (!file) { - setError(null); - return; - } - + const handleFilesSelect = useCallback(async (files: File[]): Promise => { setError(null); - setIsFileUploaded(false); - setUploadProgress(0); - const validation = validateFile(file); + const newFiles: SelectedFile[] = []; - if (!validation.isValid) { - setError(validation.errorMessage || t('unknown-validation-error')); - return; - } + for (const file of files) { + const validation = validateFile(file); - let dimensions = undefined; - if (fileType === "image") { - try { - dimensions = await getImageDimensions(file) as { width: number, height: number }; - if (dimensions.width < 100 || dimensions.height < 100 || - dimensions.width > 10000 || dimensions.height > 10000) { - setErrorHandler(`${t('image-size')}: ${dimensions.width}x${dimensions.height}. ${t('acceptable-range')}: 100x100 - 10000x10000 ${t('pixels')}.`); - return; - } - } catch (err) { - setErrorHandler(t('error-reading-image')); - return; + if (!validation.isValid) { + setError(prev => prev ? `${prev}, ${validation.errorMessage}` : validation.errorMessage || t('unknown-validation-error')); + continue; } + + let dimensions: { + width: number, + height: number, + } | undefined = undefined; + if (fileType === "image") { + try { + dimensions = await getImageDimensions(file) as { width: number, height: number }; + if (dimensions.width < 100 || dimensions.height < 100 || + dimensions.width > 10000 || dimensions.height > 10000) { + setError(prev => prev ? `${prev}, ${t('image-size')}: ${dimensions?.width}x${dimensions?.height}. ${t('acceptable-range')}: 100x100 - 10000x10000 ${t('pixels')}.` : + `${t('image-size')}: ${dimensions?.width}x${dimensions?.height}. ${t('acceptable-range')}: 100x100 - 10000x10000 ${t('pixels')}.`); + continue; + } + } catch (err) { + setError(prev => prev ? `${prev}, ${t('error-reading-image')}` : t('error-reading-image')); + continue; + } + } + + newFiles.push({ + file, + name: file.name, + size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, + dimensions, + preview: file.size < 10 * 1024 * 1024 ? URL.createObjectURL(file) : undefined, + progress: 0, + status: 'pending' + }); } - setSelectedFile({ - file, - name: file.name, - size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, - dimensions, - preview: file.size < 10 * 1024 * 1024 ? URL.createObjectURL(file) : undefined - }); - - console.log('Файл выбран:', file.name, file.size); + setSelectedFiles(prev => [...prev, ...newFiles]); }, [fileType, t]); const handleFileInputChange = (event: ChangeEvent): void => { - const file = event.target.files?.[0] || null; - handleFileSelect(file); + const files = Array.from(event.target.files || []); + handleFilesSelect(files); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; - const handleButtonClick = (): void => { - if (fileInputRef.current) { - fileInputRef.current.click(); - } - }; const handleDragOver = (event: DragEvent): void => { event.preventDefault(); @@ -169,45 +173,73 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile setIsDragging(false); }; + const handleDrop = (event: DragEvent): void => { event.preventDefault(); setIsDragging(false); - const file = event.dataTransfer.files[0]; - handleFileSelect(file); + const files = Array.from(event.dataTransfer.files); + handleFilesSelect(files); }; - const handleClearFile = async (): Promise => { - if (selectedFile && selectedFile.preview) { - URL.revokeObjectURL(selectedFile.preview); + const handleClearFile = async (fileId?: string): Promise => { + if (fileId) { + // Очистка конкретного файла + const fileToClear = selectedFiles.find(f => f.uploadId === fileId || f.name === fileId); + if (fileToClear?.preview) { + URL.revokeObjectURL(fileToClear.preview); + } + + if (fileToClear?.uploadId) { + await cancelUpload(fileToClear.uploadId); + isCancelledRef.current.set(fileToClear.uploadId, true); + setActiveUploads(prev => { + const newSet = new Set(prev); + newSet.delete(fileToClear.uploadId!); + return newSet; + }); + } + + setSelectedFiles(prev => prev.filter(f => + f.uploadId !== fileId && f.name !== fileId + )); + } else { + // Очистка всех файлов + selectedFiles.forEach(file => { + if (file.preview) { + URL.revokeObjectURL(file.preview); + } + }); + + // Отмена всех активных загрузок + const cancelPromises = Array.from(activeUploads).map(uploadId => + cancelUpload(uploadId) + ); + await Promise.allSettled(cancelPromises); + + isCancelledRef.current.clear(); + setActiveUploads(new Set()); + setSelectedFiles([]); } - if (uploadId) { - await cancelUpload(uploadId); - } - - isCancelledRef.current = true; - - setIsFileUploaded(false); - setSelectedFile(null); setError(null); - setUploadProgress(0); }; - const handlerFileUpload = useCallback(async (fileInfo: SelectedFile): Promise => { - if (uploadId) return; - - setError(null); - isCancelledRef.current = false; - const file = fileInfo.file; + const uploadSingleFile = async (fileInfo: SelectedFile): Promise => { + const fileId = `${fileInfo.name}-${Date.now()}`; + isCancelledRef.current.set(fileId, false); try { - const extension = file.name.split('.').pop() || ''; + setSelectedFiles(prev => prev.map(f => + f.name === fileInfo.name ? { ...f, status: 'uploading' } : f + )); + + const extension = fileInfo.file.name.split('.').pop() || ''; const initMessageBody = { - file_name: file.name, + file_name: fileInfo.file.name, file_type: fileType, extension: extension, - file_size: file.size + file_size: fileInfo.file.size }; const response = await fileUpload(initMessageBody) as FileUploadInitResponse; @@ -216,19 +248,23 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile throw new Error('Failed to get upload_id'); } + setSelectedFiles(prev => prev.map(f => + f.name === fileInfo.name ? { ...f, uploadId: response.upload_id } : f + )); + + setActiveUploads(prev => new Set(prev).add(response.upload_id)); + const CHUNK_SIZE = response.chunk_size; const totalChunks = response.total_chunks; - setUploadId(response.upload_id); - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { - if (isCancelledRef.current) { + if (isCancelledRef.current.get(fileId)) { return; } const start = chunkIndex * CHUNK_SIZE; - const end = Math.min(start + CHUNK_SIZE, file.size); - const chunk = file.slice(start, end); + const end = Math.min(start + CHUNK_SIZE, fileInfo.file.size); + const chunk = fileInfo.file.slice(start, end); const formData = new FormData(); formData.append('upload_id', response.upload_id); @@ -241,12 +277,24 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile throw chunkResponse.errorMesage; } - setUploadProgress(Math.floor((chunkIndex + 1) / totalChunks * 100)); + const progress = Math.floor((chunkIndex + 1) / totalChunks * 100); + setSelectedFiles(prev => prev.map(f => + f.name === fileInfo.name ? { ...f, progress } : f + )); } const chunkStatus = await checkChunkStatus(response.upload_id); if (chunkStatus.message_body.missing_chunks === 0) { - setIsFileUploaded(true); + setSelectedFiles(prev => prev.map(f => + f.name === fileInfo.name ? { ...f, status: 'completed', progress: 100 } : f + )); + + setActiveUploads(prev => { + const newSet = new Set(prev); + newSet.delete(response.upload_id); + return newSet; + }); + await queryClient.invalidateQueries({ queryKey: ['userFilesData'] }); await queryClient.invalidateQueries({ queryKey: ['userFilesInfo'] }); await queryClient.invalidateQueries({ queryKey: ['userData'] }); @@ -255,53 +303,103 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile } } catch (error) { - if (!isCancelledRef.current) { - switch (error) { - case 'duplicate-file': - setErrorHandler(t('error-duplicate-file')); - break; - case 'user-not-have-tokens-for-protect': - setErrorHandler(t('error-user-not-have-tokens-for-protect')); - break; - default: - setErrorHandler(t('error-uploading-file')); - break; - } + if (!isCancelledRef.current.get(fileId)) { + const errorMessage = (() => { + switch (error) { + case 'duplicate-file': + return t('error-duplicate-file'); + case 'user-not-have-tokens-for-protect': + return t('error-user-not-have-tokens-for-protect'); + default: + return t('error-uploading-file'); + } + })(); + + setSelectedFiles(prev => prev.map(f => + f.name === fileInfo.name ? { ...f, status: 'error', error: errorMessage } : f + )); } } finally { - setUploadId(null); + /* if (fileInfo.uploadId) { + setActiveUploads(prev => { + const newSet = new Set(prev); + newSet.delete(fileInfo.uploadId!); + return newSet; + }); + } */ } - }, [uploadId, fileType, t]); + }; useEffect(() => { - // Обработка закрытия вкладки - // Тут можно попробовать изучить вопрос с navigator.sendBeacon('', blob) - // он нужен для того что бы послать запрос на бек при закрытии вкладки, - // но у него есть свои особенности и стандартные запросы не работают. - const handleUnload = () => { - if (uploadId) { - //const data = JSON.stringify({ uploadId, reason: 'tab_closed' }); - //const blob = new Blob([data], { type: 'application/json' }); - //navigator.sendBeacon('/api/v1/data/cancel', blob); - console.log('sendBeacon'); + selectedFilesRef.current = selectedFiles; + }, [selectedFiles]); + + const handleStartAllUploads = useCallback(async (): Promise => { + const uploadNextBatch = async () => { + const currentPending = selectedFilesRef.current.filter(f => f.status === 'pending'); + const batch = currentPending.slice(0, maxParallelUploads); + + if (batch.length === 0) return; + + console.log('Текущие pending файлы:', currentPending); + console.log('Загружаемая партия:', batch); + + await Promise.all(batch.map(file => uploadSingleFile(file))); + + if (selectedFilesRef.current.some(f => f.status === 'pending')) { + await uploadNextBatch(); } }; - if (uploadId) { - window.addEventListener('unload', handleUnload); - } - return () => { - window.removeEventListener('unload', handleUnload); - }; - }, [uploadId]); + await uploadNextBatch(); + }, [maxParallelUploads]); + + const getTotalProgress = useMemo(() => { + if (selectedFiles.length === 0) return 0; + const total = selectedFiles.reduce((sum, file) => sum + file.progress, 0); + return Math.floor(total / selectedFiles.length); + }, [selectedFiles]); + + const completedCount = useMemo(() => + selectedFiles.filter(f => f.status === 'completed').length, + [selectedFiles] + ); + + const uploadingCount = useMemo(() => + selectedFiles.filter(f => f.status === 'uploading').length, + [selectedFiles] + ); + + + /* useEffect(() => { + // Обработка закрытия вкладки + // Тут можно попробовать изучить вопрос с navigator.sendBeacon('', blob) + // он нужен для того что бы послать запрос на бек при закрытии вкладки, + // но у него есть свои особенности и стандартные запросы не работают. + const handleUnload = () => { + if (activeUploads.size) { + //const data = JSON.stringify({ uploadId, reason: 'tab_closed' }); + //const blob = new Blob([data], { type: 'application/json' }); + //navigator.sendBeacon('/api/v1/data/cancel', blob); + console.log('sendBeacon'); + } + }; + if (activeUploads.size) { + window.addEventListener('unload', handleUnload); + } + + return () => { + window.removeEventListener('unload', handleUnload); + }; + }, [activeUploads.size]); */ useNavigationBlocker({ - shouldBlock: !!uploadId, + shouldBlock: !!activeUploads.size, message: t('have-unsaved-changes'), onConfirm: async () => { console.log('User confirmed navigation'); - if (uploadId) { - await cancelUpload(uploadId); + if (activeUploads.size) { + await handleClearFile(); } }, onCancel: () => { @@ -309,13 +407,16 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile } }); + return ( -
+

- {t('upload-for-protection', { fileType: t(fileType.toLocaleLowerCase()) })} + {t('upload-for-protection', { + fileType: t(fileType.toLocaleLowerCase()) + })}

-
{t('drag-the-file-here')} - {/*

- {t('or')} -

*/} - {fileType === "image" && ( -

- {t('image-resolution')}: 100x100 - 10000x10000 {t('pixels')} -

- )} -

+ { + fileType === "image" && ( +

+ {t('image-resolution')}: 100x100 - 10000x10000 {t('pixels')} +

+ ) + } +

{t('file-format')}: {acceptString}

-

+ < p className="description" > {t('file-size')}: {t('to')} {(maxFileSize / 1024 / 1024)} {t('mb')}

- - {error && ( -
-

{error}

-
- )} + { + error && ( +
+

{error}

+
+ ) + } - {selectedFile && ( -
-
-
-

- - {t('file')}: - {selectedFile.name} -

-

- - {t('size')}: - {selectedFile.size} -

-

- {t('cost-of-protection')}: 0 -

-

- {t('current-balance')}: 0 -

-
-
- {isFileUploaded && ( -
- {t('file-successfully-uploaded')} + { + selectedFiles.length > 0 && ( +
+
+
+

+ {t('total-files')}: {selectedFiles.length} | + {t('uploading')}: {uploadingCount} | + {t('completed')}: {completedCount} +

+ < div className="w-full bg-gray-200 rounded-full h-2.5 mt-2" > +
- )} +
+ + < div className="flex gap-2" > + + < button + className="btn btn-primary" + onClick={() => handleClearFile()} + type="button" + > + {t('clear-all')} + +
+
+ + < div className="space-y-4 max-h-96 overflow-y-auto" > + { + selectedFiles.map((file, index) => ( +
+
+
+

+ {t('file')}: {file.name} +

+ < p className="text-gray-700" > + {t('size')}: {file.size} +

+ < p className="text-gray-700" > + {t('status')}: + + { + file.status === 'completed' ? t('completed') : + file.status === 'uploading' ? t('uploading') : + file.status === 'error' ? t('error') : + t('pending') + } + +

+ { + file.error && ( +

{file.error}

+ ) + } +
+ < div className="flex items-center" > + + {file.progress} % + + < button + className="btn btn-cancel text-sm py-1 px-3" + onClick={() => handleClearFile(file.uploadId || file.name)} + type="button" + > + {t('remove')} + +
+
+ + { + (fileType === 'image' && file.preview) && ( + Предпросмотр загруженного изображения setError('Не удалось загрузить превью изображения') + } + /> + )} + + { + file.status === 'pending' && ( +
+ +
+ )} +
+ ))}
- - {(fileType === 'image' && selectedFile.preview) && ( - Предпросмотр загруженного изображения setErrorHandler('Не удалось загрузить превью изображения')} - /> - )} - -
- - - {uploadProgress !== 0 && ( -
- {uploadProgress}% -
- )} -
-
- )} + )}
); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b4f1b0d..d72f45c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -213,7 +213,14 @@ "error-reading-image": "Error reading image", "error-user-not-have-tokens-for-protect": "User not have tokens for protect", "registration-date": "Registration date", - "copied": "Copied" + "copied": "Copied", + "uploading": "uploading", + "clear-all": "clear-all", + "upload-all": "upload-all", + "remove": "remove", + "start-upload": "Start upload", + "completed": "completed", + "pending": "pending" }, "Login-register-form": { "and": "and", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index dd3b658..f310260 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -213,7 +213,14 @@ "error-reading-image": "Ошибка чтения изображения", "error-user-not-have-tokens-for-protect": "У пользователя нет токенов для защиты.", "registration-date": "Дата регистрации", - "copied": "Скопировано" + "copied": "Скопировано", + "uploading": "загрузка", + "clear-all": "очистить всё", + "upload-all": "загрузить всё", + "remove": "удалить", + "start-upload": "Начать загрузку", + "completed": "завершено", + "pending": "в ожидании" }, "Login-register-form": { "and": "и", @@ -288,6 +295,6 @@ "recovery-code": "Код восстановления", "referal-code": "Реферальный код", "email-invalid": "Неверный адрес электронной почты", - "referral-link-is-not-exist" : "Такой реферальной ссылки не существует." + "referral-link-is-not-exist": "Такой реферальной ссылки не существует." } } \ No newline at end of file