diff --git a/src/app/[locale]/pages/settings/page.tsx b/src/app/[locale]/pages/settings/page.tsx index b87ced8..4aa5c22 100644 --- a/src/app/[locale]/pages/settings/page.tsx +++ b/src/app/[locale]/pages/settings/page.tsx @@ -8,6 +8,7 @@ import DangerZone from '@/app/ui/settings/danger-zone'; import { useTranslations } from 'next-intl'; import PageTitle from '@/app/ui/page-title'; import { CompanyUsersSettingPanel } from '@/app/ui/settings/company-users-setting-panel'; +import SettingUserVerification from '@/app/ui/settings/setting-user-verification'; export default function Page() { const t = useTranslations('Global'); @@ -24,6 +25,7 @@ export default function Page() { {/* LastActivitySettings возможно нужно будет вернуть */} {/* */} + diff --git a/src/app/styles/settings.scss b/src/app/styles/settings.scss index e4b4086..df7a39b 100644 --- a/src/app/styles/settings.scss +++ b/src/app/styles/settings.scss @@ -219,6 +219,10 @@ padding: 20px; } } + + .verefication-section { + + } } .danger-zone { diff --git a/src/app/ui/settings/component/verification-upload-section.tsx b/src/app/ui/settings/component/verification-upload-section.tsx new file mode 100644 index 0000000..b08c65d --- /dev/null +++ b/src/app/ui/settings/component/verification-upload-section.tsx @@ -0,0 +1,592 @@ +'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' && ( +
+ +
+ )} +
+ ))} +
+
+ )} +
+ + ); +} \ No newline at end of file diff --git a/src/app/ui/settings/setting-user-verification.tsx b/src/app/ui/settings/setting-user-verification.tsx new file mode 100644 index 0000000..3d09b9b --- /dev/null +++ b/src/app/ui/settings/setting-user-verification.tsx @@ -0,0 +1,31 @@ +'use client' + +import { useTranslations } from 'next-intl'; +import { useAllFilesExtensions } from '@/app/hooks/react-query/useAllFilesExtensions'; +import VerificationUploadSection from '@/app/ui/settings/component/verification-upload-section'; + +export default function SettingUserVerification() { + const t = useTranslations('Global'); + + const { data: allFilesExtensions } = useAllFilesExtensions(); + + return ( +
+
+

+ Верификация юзера +

+
+ {/* */} +{/* */} + +
+
+ ) +} \ No newline at end of file