Files
no-copy-frontend/src/app/components/UploadSectionFile.tsx
T
2026-02-19 17:23:52 +07:00

637 lines
19 KiB
TypeScript

'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';
import DropDownListForImageConvert from '@/app/components/DropDownListForImageConvert';
export interface SelectedFile {
file: File;
preview: string | undefined;
name: string;
size: string;
dimensions?: {
width: number;
height: number;
};
uploadId?: string;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled';
error?: string;
convertTo?: string | null;
}
interface FileUploadInitResponse {
upload_id: string;
file_name: string;
total_chunks: number;
chunk_size: number;
status: string;
}
interface UploadSectionFile {
fileType: string;
allowedExtensions: string[];
maxFileSize: number;
maxParallelUploads?: number;
}
export default function UploadSectionFile({
fileType,
allowedExtensions,
maxFileSize,
maxParallelUploads = 3
}: UploadSectionFile) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
const [error, setError] = useState<string | null>(null);
const [activeUploads, setActiveUploads] = useState<Set<string>>(new Set()); // ID активных загрузок
const queryClient = useQueryClient();
const isCancelledRef = useRef<Map<string, boolean>>(new Map()); // Для отслеживания отмен по файлам
const selectedFilesRef = useRef<SelectedFile[]>(selectedFiles);
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 handleFilesSelect = useCallback(async (files: File[]): Promise<void> => {
setError(null);
const newFiles: SelectedFile[] = [];
for (const file of files) {
const validation = validateFile(file);
if (!validation.isValid) {
setError(prev => prev ? `${prev}, ${validation.errorMessage}` : validation.errorMessage || t('unknown-validation-error'));
continue;
}
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'
});
}
setSelectedFiles(prev => [...prev, ...newFiles]);
}, [fileType, t]);
const handleFileInputChange = (event: ChangeEvent<HTMLInputElement>): void => {
const files = Array.from(event.target.files || []);
handleFilesSelect(files);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
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 files = Array.from(event.dataTransfer.files);
handleFilesSelect(files);
};
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([]);
}
setError(null);
};
const uploadSingleFile = async (fileInfo: SelectedFile): Promise<void> => {
const fileId = `${fileInfo.name}-${Date.now()}`;
isCancelledRef.current.set(fileId, false);
try {
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, status: 'uploading' } : f
));
const extension = fileInfo.file.name.split('.').pop() || '';
const initMessageBody = {
file_name: fileInfo.file.name,
file_type: fileType,
extension: extension,
file_size: fileInfo.file.size
};
const response = await fileUpload(initMessageBody, fileInfo.convertTo) as FileUploadInitResponse;
if (!response?.upload_id) {
throw new Error('Failed to get upload_id');
}
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, uploadId: response.upload_id } : f
));
setActiveUploads(prev => new Set(prev).add(response.upload_id));
const CHUNK_SIZE = response.chunk_size;
const totalChunks = response.total_chunks;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelledRef.current.get(fileId)) {
return;
}
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileInfo.file.size);
const chunk = fileInfo.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.errorMesage !== null) {
throw chunkResponse.errorMesage;
}
const progress = Math.floor((chunkIndex + 1) / totalChunks * 100);
setSelectedFiles(prev => prev.map(f =>
f.name === fileInfo.name ? { ...f, progress } : f
));
}
const chunkStatus = await checkChunkStatus(response.upload_id);
if (chunkStatus.message_body.missing_chunks === 0) {
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;
});
await queryClient.invalidateQueries({ queryKey: ['userFilesData'] });
await queryClient.invalidateQueries({ queryKey: ['userFilesInfo'] });
await queryClient.invalidateQueries({ queryKey: ['userData'] });
} else {
throw new Error('Not all chunks were uploaded');
}
} catch (error) {
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
));
}
}
};
useEffect(() => {
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;
await Promise.all(batch.map(file => uploadSingleFile(file)));
if (selectedFilesRef.current.some(f => f.status === 'pending')) {
await uploadNextBatch();
}
};
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]); */
useNavigationBlocker({
shouldBlock: !!activeUploads.size,
message: t('have-unsaved-changes'),
onConfirm: async () => {
console.log('User confirmed navigation');
if (activeUploads.size) {
await handleClearFile();
}
},
onCancel: () => {
console.log('User cancelled navigation');
}
});
function converButtonHandler(value: {
convertTo: string;
file: SelectedFile
}): void {
setSelectedFiles(selectedFiles.map(selectedFile => {
if (selectedFile.name === value.file.name) {
return {
...selectedFile,
convertTo: value.convertTo
};
}
return selectedFile;
}));
}
return (
<div className="upload-section" >
<h3>
{t('upload-for-protection', {
fileType: t(fileType.toLocaleLowerCase())
})}
</h3>
< div
className={`drag-drop-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<IconShieldAdd />
<h4>
{t('drag-the-file-here')}
</h4>
{
fileType === "image" && (
<p className="description" >
{t('image-resolution')}: 100x100 - 10000x10000 {t('pixels')}
</p>
)
}
<p className="description" >
{t('file-format')}: {acceptString}
</p>
< p className="description" >
{t('file-size')}: {t('to')} {(maxFileSize / 1024 / 1024)} {t('mb')}
</p>
< input
ref={fileInputRef}
type="file"
accept={acceptString}
style={{ display: 'none' }}
onChange={handleFileInputChange}
aria-label="Выбор файла для защиты"
multiple // Добавляем multiple для выбора нескольких файлов
/>
<button
className="btn btn-primary"
onClick={() => fileInputRef.current?.click()}
type="button"
disabled={uploadingCount > maxParallelUploads}
>
{t('select-files-to-protect')}
</button>
{
error && (
<div className="error-message">
<p className="error-message-text"> {error} </p>
</div>
)
}
{
selectedFiles.length > 0 && (
<div className="mt-6" >
<div className="selected-file-action-panel" >
<div>
<p className="selected-file-action-info" >
<span>{t('total-files')}: {selectedFiles.length}</span>
<span>{t('uploading')}: {uploadingCount}</span>
<span>{t('completed')}: {completedCount}</span>
</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>
</div>
</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="selected-file-wrapper">
{
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="selected-file-right-block">
{
(fileType === 'image' && file.status === 'pending') && (
<>
<DropDownListForImageConvert
value={file.convertTo}
translatedValue={file.convertTo === 'reject' ? t('do-not-convert') : file.convertTo}
callBack={converButtonHandler}
selectedFiles={selectedFiles}
>
<li data-file={file.name} data-convert-to="reject">
{t('do-not-convert')}
</li>
{
allowedExtensions && allowedExtensions.map(extension => {
if (extension === 'gif') {
return null
}
return (
<li key={extension} data-file={file.name} data-convert-to={extension}>
{t('convert-to')} {extension}
</li>
)
})
}
</DropDownListForImageConvert>
</>
)
}
<div>
<span className={
`upload-progress ${file.status === 'completed' ? 'text-green-600' :
file.status === 'uploading' ? 'text-blue-600' :
'text-gray-600'
}`
}>
{file.progress} %
</span>
< button
className="btn btn-cancel"
onClick={() => handleClearFile(file.uploadId || file.name)}
type="button"
>
{t('remove')}
</button>
</div>
</div>
</div>
{
(fileType === 'image' && file.preview) && (
<div
className="image-preview-wrapper"
>
<img
src={file.preview}
alt="Предпросмотр загруженного изображения"
className="uploaded-image"
onError={() => setError('Не удалось загрузить превью изображения')
}
/>
</div>
)
}
{
file.status === 'pending' && (
<div className="mt-2 text-center" >
<button
className="btn btn-confirm"
onClick={() => uploadSingleFile(file)
}
type="button"
disabled={uploadingCount >= maxParallelUploads}
>
{t('start-upload')}
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}