diff --git a/src/app/[locale]/pages/search/page.tsx b/src/app/[locale]/pages/search/page.tsx new file mode 100644 index 0000000..b21aa3b --- /dev/null +++ b/src/app/[locale]/pages/search/page.tsx @@ -0,0 +1,22 @@ +import SectionSearchFile from '@/app/components/section-search-file'; +export default function Page() { + return ( + <> +
+

+ Поиск защищенного контента +

+

+ Загрузите файл для поиска похожего защищенного контента в вашей библиотеке и в интернете +

+
+
+ +
+ test +
+
+ + + ) +} \ No newline at end of file diff --git a/src/app/components/section-search-file.tsx b/src/app/components/section-search-file.tsx new file mode 100644 index 0000000..bff82db --- /dev/null +++ b/src/app/components/section-search-file.tsx @@ -0,0 +1,425 @@ +'use client' + +import { useRef, useState, useCallback, ChangeEvent, DragEvent, useEffect, useMemo } from 'react'; +import { IconShieldAdd } 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'; +interface SelectedFile { + file: File; + preview: string | undefined; + name: string; + size: string; + dimensions?: { + width: number; + height: number; + }; +} + +interface FileUploadInitResponse { + upload_id: string, + file_name: string, + total_chunks: number, + chunk_size: number, + status: string +} + +interface SectionSearchFile { + fileType: string + allowedExtensions: string[] + maxFileSize: number +} + +export default function SectionSearchFile({ fileType, allowedExtensions, maxFileSize }: SectionSearchFile) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [error, setError] = useState(null); + const [uploadId, setUploadId] = useState(null); + const [isFileUploaded, setIsFileUploaded] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const queryClient = useQueryClient(); + const isCancelledRef = useRef(false); + + 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 handleFileSelect = useCallback(async (file: File | null): Promise => { + if (!file) { + setError(null); + return; + } + + setError(null); + setIsFileUploaded(false); + setUploadProgress(0); + + const validation = validateFile(file); + + if (!validation.isValid) { + setError(validation.errorMessage || t('unknown-validation-error')); + return; + } + + let dimensions = 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(`${t('image-size')}: ${dimensions.width}x${dimensions.height}. ${t('acceptable-range')}: 100x100 - 10000x10000 ${t('pixels')}.`); + return; + } + } catch (err) { + setError(t('error-reading-image')); + return; + } + } + + setSelectedFile({ + file, + name: file.name, + size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, + dimensions, + preview: file.size < 10 * 1024 * 1024 ? URL.createObjectURL(file) : undefined + }); + + console.log('Файл выбран:', file.name, file.size); + }, [fileType, t]); + + const handleFileInputChange = (event: ChangeEvent): 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): void => { + event.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (): void => { + setIsDragging(false); + }; + + const handleDrop = (event: DragEvent): void => { + event.preventDefault(); + setIsDragging(false); + + const file = event.dataTransfer.files[0]; + handleFileSelect(file); + }; + + const handleClearFile = async (): Promise => { + if (selectedFile && selectedFile.preview) { + URL.revokeObjectURL(selectedFile.preview); + } + + if (uploadId) { + await cancelUpload(uploadId); + } + + isCancelledRef.current = true; + + setIsFileUploaded(false); + setSelectedFile(null); + setError(null); + setUploadProgress(0); + }; + + const handlerFileUpload = useCallback(async (fileInfo: SelectedFile): Promise => { + if (uploadId) return; + + setError(null); + isCancelledRef.current = false; + const file = fileInfo.file; + + 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 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', '0'); */ + + const chunkResponse = await chunkUpload(formData); + + if (chunkResponse.message_desc !== 'Chunk uploaded successfully') { + throw new Error(`Chunk ${chunkIndex} upload failed`); + } + + setUploadProgress(Math.floor((chunkIndex + 1) / totalChunks * 100)); + } + + 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, fileType, t]); + + 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 ( +
+

+ {t('upload-for-protection', { fileType: t(fileType.toLocaleLowerCase()) })} +

+ +
+ +

+ {t('drag-the-file-here')} +

+ {/*

+ {t('or')} +

*/} + + {fileType === "image" && ( +

+ {t('image-resolution')}: 100x100 - 10000x10000 {t('pixels')} +

+ )} +

+ {t('file-format')}: {acceptString} +

+

+ {t('file-size')}: {t('to')} {(maxFileSize / 1024 / 1024)} {t('mb')} +

+ + + + + + {error && ( +
+

{error}

+
+ )} + + {selectedFile && ( +
+
+
+

+ + {t('file')}: + {selectedFile.name} +

+

+ + {t('size')}: + {selectedFile.size} +

+

+ {t('cost-of-protection')}: 0 +

+

+ {t('current-balance')}: 0 +

+
+
+ {isFileUploaded && ( +
+ {t('file-successfully-uploaded')} +
+ )} +
+
+ + {(fileType === 'image' && selectedFile.preview) && ( + Предпросмотр загруженного изображения setError('Не удалось загрузить превью изображения')} + /> + )} + +
+ + + {uploadProgress !== 0 && ( +
+ {uploadProgress}% +
+ )} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/components/upload-section-file.tsx b/src/app/components/upload-section-file.tsx index f929c2e..752a818 100644 --- a/src/app/components/upload-section-file.tsx +++ b/src/app/components/upload-section-file.tsx @@ -229,6 +229,7 @@ export default function UploadSectionFile({ fileType, allowedExtensions, maxFile formData.append('upload_id', response.upload_id); formData.append('chunk_number', chunkIndex.toString()); formData.append('chunk', chunk); + /* formData.append('findSimilar', '0'); */ const chunkResponse = await chunkUpload(formData); diff --git a/src/app/styles/pages-styles.scss b/src/app/styles/pages-styles.scss index 340b296..182d198 100644 --- a/src/app/styles/pages-styles.scss +++ b/src/app/styles/pages-styles.scss @@ -2759,4 +2759,17 @@ } } } +} + +.search-grid { + display: grid; + grid-template-columns: 1fr 300px; + gap: 24px; + margin-bottom: 24px; + max-width: calc(100vw - var(--side-bar-width) - 60px); + + @media (max-width: 440px) { + grid-template-columns: auto; + max-width: calc(100vw - var(--side-bar-width) - 30px); + } } \ No newline at end of file diff --git a/src/app/ui/navigation/navigation.json b/src/app/ui/navigation/navigation.json index da6ad54..8d2d423 100644 --- a/src/app/ui/navigation/navigation.json +++ b/src/app/ui/navigation/navigation.json @@ -26,7 +26,7 @@ }, { "name": "search", - "href": "/pages/emptypage", + "href": "/pages/search", "img": "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" }, {