'use client' import { useRef, useState, useCallback, ChangeEvent, DragEvent, useEffect, useMemo } from 'react'; import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus, checkOperationPrice } from '@/app/actions/fileUpload'; import { useTranslations } from 'next-intl'; import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker'; import { useQueryClient } from '@tanstack/react-query'; 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 VerificationUploadSection({ 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[] = []; const selectedFileNames = new Set(selectedFiles.map(f => f.name)); const currentFileNames = new Set(); const duplicateNames: string[] = []; for (const file of files) { if (selectedFileNames.has(file.name)) { duplicateNames.push(file.name); continue; } 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; } } currentFileNames.add(file.name); 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' }); } if (duplicateNames.length > 0) { const uniqueDuplicates = [...new Set(duplicateNames)]; setError(`${t('duplicate-files')}: ${uniqueDuplicates.join(', ')}`); } setSelectedFiles(prev => [...prev, ...newFiles]); }, [fileType, selectedFiles]); 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 { console.log('clear all') 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, uploadType: 'single' | 'multi'): Promise => { const fileId = `${fileInfo.name}-${Date.now()}`; isCancelledRef.current.set(fileId, false); if (uploadType === 'single') { setError(null); const priceCheck = await checkOperationPrice('checkTokensForProtect', 1, fileType); if (!priceCheck.success) { const errorMessage = `${t('there-are-not-enough-funds-to-protect-file', { count: priceCheck?.tokens_count })}`; setError(errorMessage); return; } } 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) 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 = (typeof error === 'string') ? t(error) : 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 => { setError(null); if (selectedFilesRef.current.length === 0) { return; } const priceCheck = await checkOperationPrice('checkTokensForProtect', selectedFilesRef.current.length, fileType); if (priceCheck.success) { 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, 'multi'))); if (selectedFilesRef.current.some(f => f.status === 'pending')) { await uploadNextBatch(); } }; await uploadNextBatch(); } else { const errorMessage = `${t('there-are-not-enough-funds-to-protect-files', { count: priceCheck?.tokens_count })}`; setError(errorMessage); } }, [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] ); 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'); } }); return (

Загрузка изображений удостоверения личности

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

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