'use client' import { useRef, useState, useCallback, ChangeEvent, DragEvent, useEffect, useMemo } from 'react'; import { IconShieldAdd } from '@/app/ui/icons/icons'; import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus } from '@/app/actions/fileUpload'; import { useTranslations } from 'next-intl'; import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker'; import { useQueryClient } from '@tanstack/react-query'; import DropDownListForImageConvert from '@/app/components/DropDownListForImageConvert'; export interface SelectedFile { file: File; preview: string | undefined; name: string; size: string; dimensions?: { width: number; height: number; }; uploadId?: string; progress: number; status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled'; error?: string; convertTo?: string | null; } interface FileUploadInitResponse { upload_id: string; file_name: string; total_chunks: number; chunk_size: number; status: string; } interface UploadSectionFile { fileType: string; allowedExtensions: string[]; maxFileSize: number; maxParallelUploads?: number; } export default function UploadSectionFile({ fileType, allowedExtensions, maxFileSize, maxParallelUploads = 3 }: UploadSectionFile) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); const [error, setError] = useState(null); const [activeUploads, setActiveUploads] = useState>(new Set()); // ID активных загрузок const queryClient = useQueryClient(); const isCancelledRef = useRef>(new Map()); // Для отслеживания отмен по файлам const selectedFilesRef = useRef(selectedFiles); const t = useTranslations('Global'); const acceptString = useMemo(() => { if (!allowedExtensions || !Array.isArray(allowedExtensions)) { return ''; } return allowedExtensions.map(e => `.${e}`).join(', '); }, [allowedExtensions]); const validateFile = (file: File): { isValid: boolean; errorMessage?: string } => { if (!allowedExtensions.includes(file.type as string)) { const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension as string)) { return { isValid: false, errorMessage: t('unsupported-file-format') }; } } const MAX_SIZE = maxFileSize; if (file.size > MAX_SIZE) { return { isValid: false, errorMessage: t('the-file-is-too-large') }; } return { isValid: true }; }; const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve, reject) => { const img = new Image(); const objectUrl = URL.createObjectURL(file); img.onload = () => { const dimensions = { width: img.width, height: img.height }; URL.revokeObjectURL(objectUrl); resolve(dimensions); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error(t('error-uploading-file'))); }; img.src = objectUrl; }); }; const handleFilesSelect = useCallback(async (files: File[]): Promise => { setError(null); const newFiles: SelectedFile[] = []; for (const file of files) { const validation = validateFile(file); 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' }); } setSelectedFiles(prev => [...prev, ...newFiles]); }, [fileType, t]); const handleFileInputChange = (event: ChangeEvent): void => { const files = Array.from(event.target.files || []); handleFilesSelect(files); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleDragOver = (event: DragEvent): void => { event.preventDefault(); setIsDragging(true); }; const handleDragLeave = (): void => { setIsDragging(false); }; const handleDrop = (event: DragEvent): void => { event.preventDefault(); setIsDragging(false); const files = Array.from(event.dataTransfer.files); handleFilesSelect(files); }; 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([]); } setError(null); }; const uploadSingleFile = async (fileInfo: SelectedFile): Promise => { const fileId = `${fileInfo.name}-${Date.now()}`; isCancelledRef.current.set(fileId, false); try { setSelectedFiles(prev => prev.map(f => f.name === fileInfo.name ? { ...f, status: 'uploading' } : f )); const extension = fileInfo.file.name.split('.').pop() || ''; const initMessageBody = { file_name: fileInfo.file.name, file_type: fileType, extension: extension, file_size: fileInfo.file.size }; const response = await fileUpload(initMessageBody, fileInfo.convertTo) as FileUploadInitResponse; if (!response?.upload_id) { 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; for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { if (isCancelledRef.current.get(fileId)) { return; } const start = chunkIndex * CHUNK_SIZE; 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); formData.append('chunk_number', chunkIndex.toString()); formData.append('chunk', chunk); const chunkResponse = await chunkUpload(formData); if (chunkResponse.errorMesage !== null) { throw chunkResponse.errorMesage; } 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) { 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'] }); } else { throw new Error('Not all chunks were uploaded'); } } catch (error) { 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 )); } } }; useEffect(() => { 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; await Promise.all(batch.map(file => uploadSingleFile(file))); if (selectedFilesRef.current.some(f => f.status === 'pending')) { await uploadNextBatch(); } }; 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: !!activeUploads.size, message: t('have-unsaved-changes'), onConfirm: async () => { console.log('User confirmed navigation'); if (activeUploads.size) { await handleClearFile(); } }, onCancel: () => { console.log('User cancelled navigation'); } }); function converButtonHandler(value: { convertTo: string; file: SelectedFile }): void { setSelectedFiles(selectedFiles.map(selectedFile => { if (selectedFile.name === value.file.name) { return { ...selectedFile, convertTo: value.convertTo }; } return selectedFile; })); } return (

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

< div className={`drag-drop-zone ${isDragging ? 'dragging' : ''}`} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} >

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

{ 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')}

< input ref={fileInputRef} type="file" accept={acceptString} style={{ display: 'none' }} onChange={handleFileInputChange} aria-label="Выбор файла для защиты" multiple // Добавляем multiple для выбора нескольких файлов /> { error && (

{error}

) } { selectedFiles.length > 0 && (

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

< button className="btn btn-primary" onClick={() => handleClearFile()} type="button" > {t('clear-all')}
{ 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}

) }
{ (fileType === 'image' && file.status === 'pending') && ( <>
  • {t('do-not-convert')}
  • { allowedExtensions && allowedExtensions.map(extension => { if (extension === 'gif') { return null } return (
  • {t('convert-to')} {extension}
  • ) }) }
    ) }
    {file.progress} % < button className="btn btn-cancel" onClick={() => handleClearFile(file.uploadId || file.name)} type="button" > {t('remove')}
    { (fileType === 'image' && file.preview) && (
    Предпросмотр загруженного изображения setError('Не удалось загрузить превью изображения') } />
    ) } { file.status === 'pending' && (
    )}
    ))}
    )}
    ); }