Files
no-copy-frontend/src/app/components/UploadSectionFile.tsx
T

597 lines
18 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';
2026-02-11 20:11:39 +07:00
import { resolve } from 'path';
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;
};
2026-02-11 20:11:39 +07:00
uploadId?: string;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled';
error?: string;
2025-12-23 18:56:48 +07:00
}
interface FileUploadInitResponse {
2026-02-11 20:11:39 +07:00
upload_id: string;
file_name: string;
total_chunks: number;
chunk_size: number;
status: string;
2025-12-23 18:56:48 +07:00
}
2025-12-24 15:41:06 +07:00
interface UploadSectionFile {
2026-02-11 20:11:39 +07:00
fileType: string;
allowedExtensions: string[];
maxFileSize: number;
maxParallelUploads?: number;
2025-12-24 15:41:06 +07:00
}
2025-12-23 18:56:48 +07:00
2026-02-11 20:11:39 +07:00
export default function UploadSectionFile({
fileType,
allowedExtensions,
maxFileSize,
maxParallelUploads = 3
}: UploadSectionFile) {
2025-12-23 18:56:48 +07:00
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
2026-02-11 20:11:39 +07:00
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
2025-12-23 18:56:48 +07:00
const [error, setError] = useState<string | null>(null);
2026-02-11 20:11:39 +07:00
const [activeUploads, setActiveUploads] = useState<Set<string>>(new Set()); // ID активных загрузок
2025-12-29 14:00:02 +07:00
const queryClient = useQueryClient();
2026-02-11 20:11:39 +07:00
const isCancelledRef = useRef<Map<string, boolean>>(new Map()); // Для отслеживания отмен по файлам
const selectedFilesRef = useRef<SelectedFile[]>(selectedFiles);
2025-12-26 22:16:22 +07:00
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;
});
};
2026-02-11 20:11:39 +07:00
const handleFilesSelect = useCallback(async (files: File[]): Promise<void> => {
2025-12-23 18:56:48 +07:00
setError(null);
2026-02-11 20:11:39 +07:00
const newFiles: SelectedFile[] = [];
2025-12-23 18:56:48 +07:00
2026-02-11 20:11:39 +07:00
for (const file of files) {
const validation = validateFile(file);
2025-12-23 18:56:48 +07:00
2026-02-11 20:11:39 +07:00
if (!validation.isValid) {
setError(prev => prev ? `${prev}, ${validation.errorMessage}` : validation.errorMessage || t('unknown-validation-error'));
continue;
2025-12-23 18:56:48 +07:00
}
2026-02-11 20:11:39 +07:00
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;
}
}
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'
});
2025-12-23 18:56:48 +07:00
}
2025-12-26 22:16:22 +07:00
2026-02-11 20:11:39 +07:00
setSelectedFiles(prev => [...prev, ...newFiles]);
2025-12-26 22:16:22 +07:00
}, [fileType, t]);
2025-12-23 18:56:48 +07:00
const handleFileInputChange = (event: ChangeEvent<HTMLInputElement>): void => {
2026-02-11 20:11:39 +07:00
const files = Array.from(event.target.files || []);
handleFilesSelect(files);
2025-12-23 18:56:48 +07:00
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDragOver = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (): void => {
setIsDragging(false);
};
2026-02-11 20:11:39 +07:00
2025-12-23 18:56:48 +07:00
const handleDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
setIsDragging(false);
2026-02-11 20:11:39 +07:00
const files = Array.from(event.dataTransfer.files);
handleFilesSelect(files);
2025-12-23 18:56:48 +07:00
};
2026-02-11 20:11:39 +07:00
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 {
// Очистка всех файлов
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([]);
2025-12-23 18:56:48 +07:00
}
setError(null);
};
2026-02-11 20:11:39 +07:00
const uploadSingleFile = async (fileInfo: SelectedFile): Promise<void> => {
const fileId = `${fileInfo.name}-${Date.now()}`;
isCancelledRef.current.set(fileId, false);
2025-12-24 21:36:59 +07:00
2025-12-26 17:56:57 +07:00
try {
2026-02-11 20:11:39 +07:00
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, status: 'uploading' } : f
));
const extension = fileInfo.file.name.split('.').pop() || '';
2025-12-26 17:56:57 +07:00
const initMessageBody = {
2026-02-11 20:11:39 +07:00
file_name: fileInfo.file.name,
2025-12-26 17:56:57 +07:00
file_type: fileType,
extension: extension,
2026-02-11 20:11:39 +07:00
file_size: fileInfo.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
2026-02-11 20:11:39 +07:00
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, uploadId: response.upload_id } : f
));
setActiveUploads(prev => new Set(prev).add(response.upload_id));
2025-12-26 22:16:22 +07:00
const CHUNK_SIZE = response.chunk_size;
const totalChunks = response.total_chunks;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
2026-02-11 20:11:39 +07:00
if (isCancelledRef.current.get(fileId)) {
2025-12-26 22:16:22 +07:00
return;
}
const start = chunkIndex * CHUNK_SIZE;
2026-02-11 20:11:39 +07:00
const end = Math.min(start + CHUNK_SIZE, fileInfo.file.size);
const chunk = fileInfo.file.slice(start, end);
2025-12-26 22:16:22 +07:00
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);
2026-02-04 16:10:16 +07:00
if (chunkResponse.errorMesage !== null) {
throw chunkResponse.errorMesage;
2025-12-26 22:16:22 +07:00
}
2026-02-11 20:11:39 +07:00
const progress = Math.floor((chunkIndex + 1) / totalChunks * 100);
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, progress } : f
));
2025-12-26 22:16:22 +07:00
}
const chunkStatus = await checkChunkStatus(response.upload_id);
if (chunkStatus.message_body.missing_chunks === 0) {
2026-02-11 20:11:39 +07:00
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;
});
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'] });
2026-02-04 15:13:17 +07:00
await queryClient.invalidateQueries({ queryKey: ['userData'] });
2025-12-26 22:16:22 +07:00
} else {
throw new Error('Not all chunks were uploaded');
}
} catch (error) {
2026-02-11 20:11:39 +07:00
if (!isCancelledRef.current.get(fileId)) {
const errorMessage = (() => {
switch (error) {
case 'duplicate-file':
return t('error-duplicate-file');
case 'user-not-have-tokens-for-protect':
return t('error-user-not-have-tokens-for-protect');
default:
return t('error-uploading-file');
}
})();
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, status: 'error', error: errorMessage } : f
));
2025-12-26 17:56:57 +07:00
}
} finally {
2026-02-11 20:11:39 +07:00
/* if (fileInfo.uploadId) {
setActiveUploads(prev => {
const newSet = new Set(prev);
newSet.delete(fileInfo.uploadId!);
return newSet;
});
} */
2025-12-23 18:56:48 +07:00
}
2026-02-11 20:11:39 +07:00
};
2025-12-23 18:56:48 +07:00
2025-12-25 13:19:32 +07:00
useEffect(() => {
2026-02-11 20:11:39 +07:00
selectedFilesRef.current = selectedFiles;
}, [selectedFiles]);
const handleStartAllUploads = useCallback(async (): Promise<void> => {
const uploadNextBatch = async () => {
const currentPending = selectedFilesRef.current.filter(f => f.status === 'pending');
const batch = currentPending.slice(0, maxParallelUploads);
if (batch.length === 0) return;
console.log('Текущие pending файлы:', currentPending);
console.log('Загружаемая партия:', batch);
await Promise.all(batch.map(file => uploadSingleFile(file)));
if (selectedFilesRef.current.some(f => f.status === 'pending')) {
await uploadNextBatch();
2025-12-25 13:19:32 +07:00
}
};
2026-02-11 20:11:39 +07:00
await uploadNextBatch();
}, [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]
);
/* useEffect(() => {
// Обработка закрытия вкладки
// Тут можно попробовать изучить вопрос с navigator.sendBeacon('', blob)
// он нужен для того что бы послать запрос на бек при закрытии вкладки,
// но у него есть свои особенности и стандартные запросы не работают.
const handleUnload = () => {
if (activeUploads.size) {
//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 (activeUploads.size) {
window.addEventListener('unload', handleUnload);
}
return () => {
window.removeEventListener('unload', handleUnload);
};
}, [activeUploads.size]); */
2025-12-25 13:19:32 +07:00
useNavigationBlocker({
2026-02-11 20:11:39 +07:00
shouldBlock: !!activeUploads.size,
2025-12-25 13:19:32 +07:00
message: t('have-unsaved-changes'),
onConfirm: async () => {
console.log('User confirmed navigation');
2026-02-11 20:11:39 +07:00
if (activeUploads.size) {
await handleClearFile();
2025-12-25 13:19:32 +07:00
}
},
onCancel: () => {
console.log('User cancelled navigation');
}
});
2026-02-11 20:11:39 +07:00
2025-12-23 18:56:48 +07:00
return (
2026-02-11 20:11:39 +07:00
<div className="upload-section" >
2025-12-24 15:41:06 +07:00
<h3>
2026-02-11 20:11:39 +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
2026-02-11 20:11:39 +07:00
< div
2025-12-23 18:56:48 +07:00
className={`drag-drop-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<IconShieldAdd />
2025-12-24 15:41:06 +07:00
<h4>
{t('drag-the-file-here')}
</h4>
2026-01-15 13:11:53 +07:00
2026-02-11 20:11:39 +07:00
{
fileType === "image" && (
<p className="description" >
{t('image-resolution')}: 100x100 - 10000x10000 {t('pixels')}
</p>
)
}
<p className="description" >
2026-01-15 13:11:53 +07:00
{t('file-format')}: {acceptString}
2025-12-24 15:41:06 +07:00
</p>
2026-02-11 20:11:39 +07:00
< p className="description" >
2026-01-15 13:11:53 +07:00
{t('file-size')}: {t('to')} {(maxFileSize / 1024 / 1024)} {t('mb')}
</p>
2026-02-11 20:11:39 +07:00
< input
2025-12-23 18:56:48 +07:00
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="Выбор файла для защиты"
2026-02-11 20:11:39 +07:00
multiple // Добавляем multiple для выбора нескольких файлов
2025-12-23 18:56:48 +07:00
/>
<button
className="btn btn-primary"
2026-02-11 20:11:39 +07:00
onClick={() => fileInputRef.current?.click()}
2025-12-23 18:56:48 +07:00
type="button"
2026-02-11 20:11:39 +07:00
disabled={uploadingCount > maxParallelUploads}
2025-12-23 18:56:48 +07:00
>
2025-12-24 15:41:06 +07:00
{t('select-files-to-protect')}
2025-12-23 18:56:48 +07:00
</button>
2026-02-11 20:11:39 +07:00
{
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>
)
}
2025-12-23 18:56:48 +07:00
2026-02-11 20:11:39 +07:00
{
selectedFiles.length > 0 && (
<div className="mt-6" >
<div className="flex justify-between items-center mb-4" >
<div>
<p className="text-gray-700 font-medium" >
{t('total-files')}: {selectedFiles.length} |
{t('uploading')}: {uploadingCount} |
{t('completed')}: {completedCount}
</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>
2025-12-24 15:41:06 +07:00
</div>
2026-02-11 20:11:39 +07:00
</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="space-y-4 max-h-96 overflow-y-auto" >
{
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="flex items-center" >
<span className={
`font-medium text-lg mr-4 ${file.status === 'completed' ? 'text-green-600' :
file.status === 'uploading' ? 'text-blue-600' :
'text-gray-600'
}`
}>
{file.progress} %
</span>
< button
className="btn btn-cancel text-sm py-1 px-3"
onClick={() => handleClearFile(file.uploadId || file.name)}
type="button"
>
{t('remove')}
</button>
</div>
</div>
{
(fileType === 'image' && file.preview) && (
<img
src={file.preview}
alt="Предпросмотр загруженного изображения"
className="uploaded-image"
onError={() => setError('Не удалось загрузить превью изображения')
}
/>
)}
{
file.status === 'pending' && (
<div className="mt-3 text-center" >
<button
className="btn btn-confirm text-sm py-1 px-4"
onClick={() => uploadSingleFile(file)
}
type="button"
disabled={uploadingCount >= maxParallelUploads}
>
{t('start-upload')}
</button>
</div>
)}
</div>
))}
2025-12-24 15:41:06 +07:00
</div>
2025-12-23 18:56:48 +07:00
</div>
2026-02-11 20:11:39 +07:00
)}
2025-12-23 18:56:48 +07:00
</div>
</div>
);
}