Files
no-copy-frontend/src/app/components/upload-section-file.tsx
T

431 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-23 18:56:48 +07:00
'use client'
2025-12-26 17:56:57 +07:00
import { useRef, useState, useCallback, ChangeEvent, DragEvent, useEffect, useMemo } from 'react';
2025-12-23 18:56:48 +07:00
import { IconShieldAdd } from '@/app/ui/icons/icons';
2025-12-26 11:02:35 +07:00
import { fileUpload, cancelUpload, chunkUpload, checkChunkStatus } from '@/app/actions/fileUpload';
2025-12-24 15:41:06 +07:00
import { useTranslations } from 'next-intl';
2025-12-25 13:19:32 +07:00
import { useNavigationBlocker } from '@/app/hooks/useNavigationBlocker';
2025-12-29 14:00:02 +07:00
import { useQueryClient } from '@tanstack/react-query';
2025-12-23 18:56:48 +07:00
interface SelectedFile {
file: File;
2025-12-26 22:16:22 +07:00
preview: string | undefined;
2025-12-23 18:56:48 +07:00
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
}
2025-12-24 15:41:06 +07:00
interface UploadSectionFile {
fileType: string
2025-12-24 21:36:59 +07:00
allowedExtensions: string[]
maxFileSize: number
2025-12-24 15:41:06 +07:00
}
2025-12-23 18:56:48 +07:00
2025-12-24 21:36:59 +07:00
export default function UploadSectionFile({ fileType, allowedExtensions, maxFileSize }: UploadSectionFile) {
2025-12-23 18:56:48 +07:00
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);
2025-12-24 15:41:06 +07:00
const [uploadId, setUploadId] = useState<string | null>(null);
const [isFileUploaded, setIsFileUploaded] = useState<boolean>(false);
2025-12-24 21:36:59 +07:00
const [uploadProgress, setUploadProgress] = useState<number>(0);
2025-12-29 14:00:02 +07:00
const queryClient = useQueryClient();
2025-12-26 22:16:22 +07:00
const isCancelledRef = useRef(false);
2025-12-24 15:41:06 +07:00
const t = useTranslations('Global');
2025-12-26 17:56:57 +07:00
const acceptString = useMemo(() => {
if (!allowedExtensions || !Array.isArray(allowedExtensions)) {
return '';
}
return allowedExtensions.map(e => `.${e}`).join(', ');
}, [allowedExtensions]);
2025-12-23 18:56:48 +07:00
const validateFile = (file: File): { isValid: boolean; errorMessage?: string } => {
2026-01-07 19:14:37 +07:00
if (!allowedExtensions.includes(file.type as string)) {
2025-12-23 18:56:48 +07:00
const extension = file.name.split('.').pop()?.toLowerCase();
2025-12-26 17:56:57 +07:00
if (!extension || !allowedExtensions.includes(extension as string)) {
2025-12-23 18:56:48 +07:00
return {
isValid: false,
2025-12-25 17:24:59 +07:00
errorMessage: t('unsupported-file-format')
2025-12-23 18:56:48 +07:00
};
}
}
2025-12-24 21:36:59 +07:00
const MAX_SIZE = maxFileSize;
2025-12-23 18:56:48 +07:00
if (file.size > MAX_SIZE) {
return {
isValid: false,
2025-12-25 17:24:59 +07:00
errorMessage: t('the-file-is-too-large')
2025-12-23 18:56:48 +07:00
};
}
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);
2025-12-25 17:24:59 +07:00
reject(new Error(t('error-uploading-file')));
2025-12-23 18:56:48 +07:00
};
img.src = objectUrl;
});
};
const handleFileSelect = useCallback(async (file: File | null): Promise<void> => {
if (!file) {
setError(null);
return;
}
2025-12-26 20:17:41 +07:00
2025-12-23 18:56:48 +07:00
setError(null);
2025-12-26 22:16:22 +07:00
setIsFileUploaded(false);
setUploadProgress(0);
2025-12-23 18:56:48 +07:00
const validation = validateFile(file);
if (!validation.isValid) {
2025-12-25 17:24:59 +07:00
setError(validation.errorMessage || t('unknown-validation-error'));
2025-12-23 18:56:48 +07:00
return;
}
2025-12-26 22:16:22 +07:00
let dimensions = undefined;
if (fileType === "image") {
try {
2025-12-24 21:36:59 +07:00
dimensions = await getImageDimensions(file) as { width: number, height: number };
2026-01-07 19:14:37 +07:00
if (dimensions.width < 100 || dimensions.height < 100 ||
2025-12-24 21:36:59 +07:00
dimensions.width > 10000 || dimensions.height > 10000) {
2026-01-06 16:00:07 +07:00
setError(`${t('image-size')}: ${dimensions.width}x${dimensions.height}. ${t('acceptable-range')}: 100x100 - 10000x10000 ${t('pixels')}.`);
2025-12-24 21:36:59 +07:00
return;
}
2025-12-26 22:16:22 +07:00
} catch (err) {
setError(t('error-reading-image'));
return;
2025-12-23 18:56:48 +07:00
}
}
2025-12-26 22:16:22 +07:00
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]);
2025-12-23 18:56:48 +07:00
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 handleClearFile = async (): Promise<void> => {
2025-12-26 22:16:22 +07:00
if (selectedFile && selectedFile.preview) {
2025-12-23 18:56:48 +07:00
URL.revokeObjectURL(selectedFile.preview);
}
2025-12-24 15:41:06 +07:00
if (uploadId) {
await cancelUpload(uploadId);
}
2025-12-23 18:56:48 +07:00
2025-12-26 22:16:22 +07:00
isCancelledRef.current = true;
2025-12-24 15:41:06 +07:00
setIsFileUploaded(false);
2025-12-23 18:56:48 +07:00
setSelectedFile(null);
setError(null);
2025-12-24 21:36:59 +07:00
setUploadProgress(0);
2025-12-23 18:56:48 +07:00
};
2025-12-26 22:16:22 +07:00
const handlerFileUpload = useCallback(async (fileInfo: SelectedFile): Promise<void> => {
if (uploadId) return;
2025-12-24 21:36:59 +07:00
2025-12-26 17:56:57 +07:00
setError(null);
2025-12-26 22:16:22 +07:00
isCancelledRef.current = false;
const file = fileInfo.file;
2025-12-24 21:36:59 +07:00
2025-12-26 17:56:57 +07:00
try {
2025-12-26 22:16:22 +07:00
const extension = file.name.split('.').pop() || '';
2025-12-26 17:56:57 +07:00
const initMessageBody = {
file_name: file.name,
file_type: fileType,
extension: extension,
2025-12-26 22:16:22 +07:00
file_size: file.size
2025-12-26 17:56:57 +07:00
};
const response = await fileUpload(initMessageBody) as FileUploadInitResponse;
2025-12-26 22:16:22 +07:00
if (!response?.upload_id) {
2025-12-26 17:56:57 +07:00
throw new Error('Failed to get upload_id');
2025-12-23 18:56:48 +07:00
}
2025-12-26 17:56:57 +07:00
2025-12-26 22:16:22 +07:00
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);
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);
2025-12-29 14:00:02 +07:00
await queryClient.invalidateQueries({ queryKey: ['userFilesData'] });
2026-01-07 13:03:50 +07:00
await queryClient.invalidateQueries({ queryKey: ['userFilesInfo'] });
2025-12-26 22:16:22 +07:00
} else {
throw new Error('Not all chunks were uploaded');
}
} catch (error) {
if (!isCancelledRef.current) {
setError(t('error-uploading-file'));
console.error('Upload error:', error);
2025-12-26 17:56:57 +07:00
}
} finally {
2025-12-26 18:51:05 +07:00
setUploadId(null);
2025-12-23 18:56:48 +07:00
}
2025-12-26 22:16:22 +07:00
}, [uploadId, fileType, t]);
2025-12-23 18:56:48 +07:00
2025-12-25 13:19:32 +07:00
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');
}
});
2025-12-23 18:56:48 +07:00
return (
<div className="upload-section">
2025-12-24 15:41:06 +07:00
<h3>
2025-12-24 21:36:59 +07:00
{t('upload-for-protection', { fileType: t(fileType.toLocaleLowerCase()) })}
2025-12-24 15:41:06 +07:00
</h3>
2025-12-23 18:56:48 +07:00
<div
className={`drag-drop-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px dashed #3b82f6' : '2px dashed #d1d5db',
backgroundColor: isDragging ? '#eff6ff' : '#f9fafb',
padding: '2rem',
borderRadius: '.75rem',
textAlign: 'center',
transition: 'all .2s ease-in-out'
}}
>
<IconShieldAdd />
2025-12-24 15:41:06 +07:00
<h4>
{t('drag-the-file-here')}
</h4>
<p className="mb-2.5 text-[#6b7280]">
{t('or')}
</p>
2025-12-23 18:56:48 +07:00
<input
ref={fileInputRef}
type="file"
2025-12-26 17:56:57 +07:00
accept={acceptString}
2025-12-23 18:56:48 +07:00
style={{ display: 'none' }}
onChange={handleFileInputChange}
aria-label="Выбор файла для защиты"
/>
<button
className="btn btn-primary"
onClick={handleButtonClick}
type="button"
>
2025-12-24 15:41:06 +07:00
{t('select-files-to-protect')}
2025-12-23 18:56:48 +07:00
</button>
2025-12-25 15:31:24 +07:00
{fileType === "image" && (
2025-12-25 13:19:32 +07:00
<p className="description">
2025-12-25 17:24:59 +07:00
{t('image-resolution')}: 100x100 - 10000x10000
2025-12-25 13:19:32 +07:00
</p>
)}
2025-12-23 18:56:48 +07:00
<p className="description">
2025-12-25 17:24:59 +07:00
{t('file-size')}: {t('to')} {(maxFileSize / 1024 / 1024)} {t('mb')}
2025-12-23 18:56:48 +07:00
</p>
<p className="description">
2025-12-26 17:56:57 +07:00
{t('file-format')}: {acceptString}
2025-12-23 18:56:48 +07:00
</p>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
2025-12-24 15:41:06 +07:00
<p className="text-red-600 font-medium">{error}</p>
2025-12-23 18:56:48 +07:00
</div>
)}
{selectedFile && (
<div
2025-12-25 17:24:59 +07:00
className={`selected-file ${isFileUploaded ? 'done' : ''}`}
2025-12-23 18:56:48 +07:00
>
<div className="selected-file-file-info">
2025-12-24 15:41:06 +07:00
<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>
<p className="text-gray-700">
2025-12-25 17:24:59 +07:00
<span className="font-medium">{t('cost-of-protection')}:</span> 0
2025-12-24 15:41:06 +07:00
</p>
<p className="text-gray-700">
2025-12-25 17:24:59 +07:00
<span className="font-medium">{t('current-balance')}:</span> 0
2025-12-24 15:41:06 +07:00
</p>
</div>
<div>
{isFileUploaded && (
<div className="font-medium text-green-600 text-2xl">
{t('file-successfully-uploaded')}
</div>
)}
</div>
2025-12-23 18:56:48 +07:00
</div>
2025-12-26 22:16:22 +07:00
{(fileType === 'image' && selectedFile.preview) && (
2025-12-24 21:36:59 +07:00
<img
src={selectedFile.preview}
alt="Предпросмотр загруженного изображения"
className="uploaded-image"
onError={() => setError('Не удалось загрузить превью изображения')}
/>
)}
2025-12-23 18:56:48 +07:00
2025-12-24 21:36:59 +07:00
<div className="mt-4 flex gap-3 justify-center relative">
2025-12-23 18:56:48 +07:00
<button
className="btn btn-cancel"
onClick={handleClearFile}
type="button"
>
2025-12-24 15:41:06 +07:00
{isFileUploaded ? t('close') : t('cancel')}
2025-12-23 18:56:48 +07:00
</button>
<button
2025-12-26 20:17:41 +07:00
className={`btn btn-confirm ${error || isFileUploaded || uploadId ? 'disabled' : ''}`}
2025-12-23 18:56:48 +07:00
onClick={() => {
handlerFileUpload(selectedFile);
}}
type="button"
2025-12-26 20:17:41 +07:00
disabled={error || isFileUploaded || uploadId ? true : false}
2025-12-23 18:56:48 +07:00
>
2025-12-24 15:41:06 +07:00
{t('upload-file')}
</button>
2025-12-24 21:36:59 +07:00
{uploadProgress !== 0 && (
<div
className={`absolute top-5 right-5 font-medium ${isFileUploaded ? "text-green-600" : ""}`}
>
{uploadProgress}%
</div>
)}
2025-12-23 18:56:48 +07:00
</div>
</div>
)}
</div>
</div>
);
}