Files
no-copy-frontend/src/app/ui/search/section-search-file.tsx
T
2026-02-19 17:23:52 +07:00

382 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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="error-message">
<p className="error-message-text">{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>
);
}