382 lines
10 KiB
TypeScript
382 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { useRef, useState, useCallback, ChangeEvent, DragEvent, useEffect, useMemo } from 'react';
|
||
import { IconSearch } 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 { removeUserFile } from '@/app/actions/fileEntity';
|
||
import { FileSearchPanel } from '@/app/ui/search/file-search-panel';
|
||
interface SelectedFile {
|
||
file: File;
|
||
preview: string | undefined;
|
||
name: string;
|
||
size: string;
|
||
}
|
||
|
||
interface FileUploadInitResponse {
|
||
upload_id: string,
|
||
file_name: string,
|
||
total_chunks: number,
|
||
chunk_size: number,
|
||
status: string
|
||
}
|
||
|
||
interface SectionSearchFile {
|
||
allowedExtensions: {
|
||
images: string[],
|
||
videos: string[],
|
||
audios: string[],
|
||
documents: string[]
|
||
}
|
||
maxFileSize: number
|
||
}
|
||
|
||
export default function SectionSearchFile({ allowedExtensions, maxFileSize }: SectionSearchFile) {
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||
const [selectedFile, setSelectedFile] = useState<SelectedFile | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [uploadId, setUploadId] = useState<string | null>(null);
|
||
const [isFileUploaded, setIsFileUploaded] = useState<boolean>(false);
|
||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||
const [fileId, setFileId] = useState<string | null>(null);
|
||
const queryClient = useQueryClient();
|
||
const isCancelledRef = useRef(false);
|
||
|
||
const childRef = useRef(null);
|
||
|
||
const t = useTranslations('Global');
|
||
|
||
const allExtensions = useMemo(() => { return [...allowedExtensions.images, ...allowedExtensions.videos, ...allowedExtensions.audios, ...allowedExtensions.documents] }, []);
|
||
|
||
const acceptString = useMemo(() => {
|
||
if (!allExtensions || !Array.isArray(allExtensions)) {
|
||
return '';
|
||
}
|
||
return allExtensions.map(e => `.${e}`).join(', ');
|
||
}, [allExtensions]);
|
||
|
||
|
||
const getFileType = (fileName: string, allowedExtensions: {
|
||
images: string[],
|
||
videos: string[],
|
||
audios: string[],
|
||
documents: string[]
|
||
}): 'image' | 'video' | 'audio' | 'document' | 'unknown' => {
|
||
|
||
const lastDotIndex = fileName.lastIndexOf('.');
|
||
const extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||
|
||
if (allowedExtensions.images.includes(extension)) {
|
||
return 'image';
|
||
}
|
||
|
||
if (allowedExtensions.videos.includes(extension)) {
|
||
return 'video';
|
||
}
|
||
|
||
if (allowedExtensions.audios.includes(extension)) {
|
||
return 'audio';
|
||
}
|
||
|
||
if (allowedExtensions.documents.includes(extension)) {
|
||
return 'document';
|
||
}
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
const validateFile = (file: File): { isValid: boolean; errorMessage?: string } => {
|
||
if (!allExtensions.includes(file.type as string)) {
|
||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||
if (!extension || !allExtensions.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 handleFileSelect = useCallback(async (file: File | null): Promise<void> => {
|
||
if (!file) {
|
||
setError(null);
|
||
return;
|
||
}
|
||
|
||
if (fileId) {
|
||
await removeUserFile(fileId, 1);
|
||
setFileId(null);
|
||
}
|
||
|
||
setError(null);
|
||
setIsFileUploaded(false);
|
||
|
||
if (childRef.current) {
|
||
//@ts-ignore
|
||
childRef.current.clearList();
|
||
}
|
||
|
||
setUploadProgress(0);
|
||
|
||
const validation = validateFile(file);
|
||
|
||
if (!validation.isValid) {
|
||
setError(validation.errorMessage || t('unknown-validation-error'));
|
||
return;
|
||
}
|
||
|
||
setSelectedFile({
|
||
file,
|
||
name: file.name,
|
||
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||
preview: file.size < 10 * 1024 * 1024 ? URL.createObjectURL(file) : undefined
|
||
});
|
||
|
||
handlerFileUpload({
|
||
file,
|
||
name: file.name,
|
||
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||
preview: file.size < 10 * 1024 * 1024 ? URL.createObjectURL(file) : undefined
|
||
});
|
||
|
||
}, [t]);
|
||
|
||
const handleFileInputChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||
const file = event.target.files?.[0] || null;
|
||
handleFileSelect(file);
|
||
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleButtonClick = (): void => {
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.click();
|
||
}
|
||
};
|
||
|
||
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 file = event.dataTransfer.files[0];
|
||
handleFileSelect(file);
|
||
};
|
||
|
||
const handlerFileUpload = useCallback(async (fileInfo: SelectedFile): Promise<void> => {
|
||
if (uploadId) return;
|
||
|
||
setError(null);
|
||
isCancelledRef.current = false;
|
||
const file = fileInfo.file;
|
||
const fileType = getFileType(fileInfo.file.name, allowedExtensions);
|
||
|
||
try {
|
||
const extension = file.name.split('.').pop() || '';
|
||
const initMessageBody = {
|
||
file_name: file.name,
|
||
file_type: fileType,
|
||
extension: extension,
|
||
file_size: file.size
|
||
};
|
||
|
||
const response = await fileUpload(initMessageBody) as FileUploadInitResponse;
|
||
|
||
if (!response?.upload_id) {
|
||
throw new Error('Failed to get 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) {
|
||
return;
|
||
}
|
||
|
||
const isLastChunk = chunkIndex === totalChunks - 1;
|
||
const start = chunkIndex * CHUNK_SIZE;
|
||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||
const chunk = 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);
|
||
formData.append('findSimilar', '1');
|
||
|
||
const chunkResponse = await chunkUpload(formData);
|
||
|
||
if (chunkResponse.errorMesage !== null) {
|
||
throw chunkResponse.errorMesage;
|
||
}
|
||
|
||
setUploadProgress(Math.floor((chunkIndex + 1) / totalChunks * 100));
|
||
|
||
if (isLastChunk && chunkResponse.fileId) {
|
||
setFileId(chunkResponse.fileId);
|
||
document.cookie = `searchedFileId=${chunkResponse.fileId}`
|
||
}
|
||
}
|
||
|
||
const chunkStatus = await checkChunkStatus(response.upload_id);
|
||
if (chunkStatus.message_body.missing_chunks === 0) {
|
||
setIsFileUploaded(true);
|
||
await queryClient.invalidateQueries({ queryKey: ['userFilesData'] });
|
||
await queryClient.invalidateQueries({ queryKey: ['userFilesInfo'] });
|
||
} else {
|
||
throw new Error('Not all chunks were uploaded');
|
||
}
|
||
|
||
} catch (error) {
|
||
if (!isCancelledRef.current) {
|
||
setError(t('error-uploading-file'));
|
||
console.error('Upload error:', error);
|
||
}
|
||
} finally {
|
||
setUploadId(null);
|
||
}
|
||
}, [uploadId, allowedExtensions]);
|
||
|
||
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');
|
||
}
|
||
};
|
||
if (uploadId) {
|
||
window.addEventListener('unload', handleUnload);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener('unload', handleUnload);
|
||
};
|
||
}, [uploadId]);
|
||
|
||
useNavigationBlocker({
|
||
shouldBlock: !!uploadId,
|
||
message: t('have-unsaved-changes'),
|
||
onConfirm: async () => {
|
||
console.log('User confirmed navigation');
|
||
if (uploadId) {
|
||
await cancelUpload(uploadId);
|
||
}
|
||
},
|
||
onCancel: () => {
|
||
console.log('User cancelled navigation');
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div className="upload-section">
|
||
<div className="search-info">
|
||
<div className="search-info-title">Как работает поиск?</div>
|
||
<div className="search-info-text">
|
||
Наша система анализирует загруженный файл и сравнивает его с вашими защищенными файлами,
|
||
используя алгоритмы компьютерного зрения и цифровых отпечатков.
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`drag-drop-zone ${isDragging ? 'dragging' : ''}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<IconSearch />
|
||
<h4>
|
||
Выберите файл для поиска
|
||
</h4>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={acceptString}
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileInputChange}
|
||
aria-label="Выбор файла для защиты"
|
||
/>
|
||
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleButtonClick}
|
||
type="button"
|
||
disabled={uploadProgress && uploadProgress !== 100 ? true : false}
|
||
>
|
||
Выбрать файл
|
||
</button>
|
||
|
||
{error && (
|
||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||
<p className="text-red-600 font-medium">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{selectedFile && (
|
||
<div
|
||
className={`selected-file`}
|
||
>
|
||
<div className="selected-file-file-info relative">
|
||
<div>
|
||
<p className="text-gray-700">
|
||
<span className="font-medium">
|
||
{t('file')}:
|
||
</span> {selectedFile.name}
|
||
</p>
|
||
<p className="text-gray-700">
|
||
<span className="font-medium">
|
||
{t('size')}:
|
||
</span> {selectedFile.size}
|
||
</p>
|
||
</div>
|
||
{uploadProgress !== 0 && (
|
||
<div
|
||
className={`absolute top-0 right-0 font-medium ${isFileUploaded ? "text-green-600" : ""}`}
|
||
>
|
||
{uploadProgress}%
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isFileUploaded && (
|
||
<FileSearchPanel fileId={fileId} ref={childRef} />
|
||
)}
|
||
</div>
|
||
);
|
||
} |