@@ -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 < HTMLInputElement > ( null ) ;
const [ isDragging , setIsDragging ] = useState < boolean > ( false ) ;
const [ selectedFiles , setSelectedFiles ] = useState < SelectedFile [ ] > ( [ ] ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ activeUploads , setActiveUploads ] = useState < Set < string > > ( new Set ( ) ) ; // ID активных загрузок
const queryClient = useQueryClient ( ) ;
const isCancelledRef = useRef < Map < string , boolean > > ( new Map ( ) ) ; // Для отслеживания отмен по файлам
const selectedFilesRef = useRef < SelectedFile [ ] > ( 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 < void > = > {
setError ( null ) ;
const newFiles : SelectedFile [ ] = [ ] ;
const selectedFileNames = new Set ( selectedFiles . map ( f = > f . name ) ) ;
const currentFileNames = new Set < string > ( ) ;
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 < HTMLInputElement > ) : void = > {
const files = Array . from ( event . target . files || [ ] ) ;
handleFilesSelect ( files ) ;
if ( fileInputRef . current ) {
fileInputRef . current . value = '' ;
}
} ;
const handleDragOver = ( event : DragEvent < HTMLDivElement > ) : void = > {
event . preventDefault ( ) ;
setIsDragging ( true ) ;
} ;
const handleDragLeave = ( ) : void = > {
setIsDragging ( false ) ;
} ;
const handleDrop = ( event : DragEvent < HTMLDivElement > ) : void = > {
event . preventDefault ( ) ;
setIsDragging ( false ) ;
const files = Array . from ( event . dataTransfer . files ) ;
handleFilesSelect ( files ) ;
} ;
const handleClearFile = async ( fileId? : string ) : Promise < void > = > {
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 < void > = > {
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 < void > = > {
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 = "" >
< h3 >
З а г р у з к а и з о б р а ж е н и й у д о с т о в е р е н и я л и ч н о с т и
< / h3 >
< div
className = { ` drag-drop-zone ${ isDragging ? 'dragging' : '' } ` }
onDragOver = { handleDragOver }
onDragLeave = { handleDragLeave }
onDrop = { handleDrop }
>
< h4 >
{ t ( 'drag-the-file-here' ) }
< / h4 >
{
fileType === "image" && (
< p className = "description" >
{ t ( 'image-resolution' ) } : 100 x100 - 10000 x10000 { t ( 'pixels' ) }
< / p >
)
}
< p className = "description" >
{ t ( 'file-format' ) } : { acceptString }
< / p >
< p className = "description" >
{ t ( 'file-size' ) } : { t ( 'to' ) } { ( maxFileSize / 1024 / 1024 ) } { t ( 'mb' ) }
< / p >
< input
ref = { fileInputRef }
type = "file"
accept = { acceptString }
style = { { display : 'none' } }
onChange = { handleFileInputChange }
aria - label = "Выбор файла для защиты"
multiple // Добавляем multiple для выбора нескольких файлов
/ >
< button
className = "btn btn-primary"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
type = "button"
disabled = { uploadingCount > maxParallelUploads }
>
в ы б е р е т е д о к у м е н т д л я з а г р у з к и
< / button >
{
error && (
< div className = "error-message" >
< p className = "error-message-text" > { error } < / p >
< / div >
)
}
{
selectedFiles . length > 0 && (
< div className = "mt-6" >
< div className = "selected-file-action-panel" >
< div >
< p className = "selected-file-action-info" >
< span > { t ( 'total-files' ) } : { selectedFiles . length } < / span >
< span > { t ( 'uploading' ) } : { uploadingCount } < / span >
< span > { t ( 'completed' ) } : { completedCount } < / span >
< / p >
< div className = "w-full bg-gray-200 rounded-full h-2.5 mt-2" >
< div
className = "bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style = { { width : ` ${ getTotalProgress } % ` }
}
>
< / div >
< / div >
< / div >
< div className = "flex gap-2" >
< button
className = { ` btn btn-primary ` }
onClick = { handleStartAllUploads }
type = "button"
disabled = { uploadingCount > 0 || selectedFiles . every ( f = > f . status !== 'pending' ) }
>
{ t ( 'upload-all' ) }
< / button >
< button
className = "btn btn-primary"
onClick = { ( ) = > handleClearFile ( ) }
type = "button"
>
{ t ( 'clear-all' ) }
< / button >
< / div >
< / div >
< div className = "selected-file-wrapper" >
{
selectedFiles . map ( ( file , index ) = > (
< div
key = { ` ${ file . name } - ${ index } ` }
className = { ` selected-file ${ file . status === 'completed' ? 'done' : '' } ${ file . status === 'error' ? 'error' : '' } ` }
>
< div className = "selected-file-file-info" >
< div >
< p className = "text-gray-700" >
< span className = "font-medium" > { t ( 'file' ) } : < / span > { file . name }
< / p >
< p className = "text-gray-700" >
< span className = "font-medium" > { t ( 'size' ) } : < / span > { file . size }
< / p >
< p className = "text-gray-700" >
< span className = "font-medium" > { t ( 'status' ) } : < / span >
< span className = { ` ml-2 ${ file . status === 'completed' ? 'text-green-600' :
file . status === 'uploading' ? 'text-blue-600' :
file . status === 'error' ? 'text-red-600' :
'text-gray-600'
} ` } >
{
file . status === 'completed' ? t ( 'completed' ) :
file . status === 'uploading' ? t ( 'uploading' ) :
file . status === 'error' ? t ( 'error' ) :
t ( 'pending' )
}
< / span >
< / p >
{
file . error && (
< p className = "text-red-600 text-sm mt-1" > { file . error } < / p >
)
}
< / div >
< div className = "selected-file-right-block" >
< div >
< span className = {
` upload-progress ${ file . status === 'completed' ? 'text-green-600' :
file . status === 'uploading' ? 'text-blue-600' :
'text-gray-600'
} `
} >
{ file . progress } %
< / span >
< button
className = "btn btn-cancel"
onClick = { ( ) = > handleClearFile ( file . uploadId || file . name ) }
type = "button"
>
{ t ( 'remove' ) }
< / button >
< / div >
< / div >
< / div >
{
( fileType === 'image' && file . preview ) && (
< div
className = "image-preview-wrapper"
>
< img
src = { file . preview }
alt = "Предпросмотр загруженного документа"
className = "uploaded-image"
onError = { ( ) = > setError ( 'Не удалось загрузить превью документа' )
}
/ >
< / div >
)
}
{
file . status === 'pending' && (
< div className = "mt-2 text-center" >
< button
className = "btn btn-confirm"
onClick = { ( ) = > uploadSingleFile ( file , 'single' )
}
type = "button"
disabled = { uploadingCount >= maxParallelUploads }
>
{ t ( 'start-upload' ) }
< / button >
< / div >
) }
< / div >
) ) }
< / div >
< / div >
) }
< / div >
< / div >
) ;
}