initial commit
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
function UniqueizerContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileUploadRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [status, setStatus] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [videoKey, setVideoKey] = useState<string | null>(null);
|
||||
|
||||
// generationUuid получаем из URL (передаётся из дашборда)
|
||||
const generationUuidFromUrl = searchParams.get('generationUuid');
|
||||
const [generationUuid, setGenerationUuid] = useState<string | null>(generationUuidFromUrl);
|
||||
|
||||
const [scenarioStarted, setScenarioStarted] = useState(false);
|
||||
|
||||
// Проверяем generationUuid из URL
|
||||
useEffect(() => {
|
||||
const uuidFromUrl = searchParams.get('generationUuid');
|
||||
if (uuidFromUrl) {
|
||||
setGenerationUuid(uuidFromUrl);
|
||||
setScenarioStarted(true);
|
||||
setStatus('Сценарий запущен');
|
||||
} else {
|
||||
setError('generationUuid не указан. Вернитесь на дашборд и запустите сценарий.');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Drag and drop
|
||||
useEffect(() => {
|
||||
const el = fileUploadRef.current;
|
||||
if (!el) return;
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
el.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
el.addEventListener(eventName, () => el.classList.add('dragover'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
el.addEventListener(eventName, () => el.classList.remove('dragover'), false);
|
||||
});
|
||||
|
||||
el.addEventListener('drop', handleDrop, false);
|
||||
}, []);
|
||||
|
||||
function preventDefaults(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
const dt = e.dataTransfer;
|
||||
if (dt?.files.length) {
|
||||
handleFileSelect(dt.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files?.length) {
|
||||
handleFileSelect(e.target.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError('Недопустимый тип файла. Разрешены: MP4, MOV, AVI, WebM');
|
||||
return;
|
||||
}
|
||||
const maxSize = 500 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
setError('Файл слишком большой. Максимум: 500MB');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setPreview(URL.createObjectURL(file));
|
||||
setError('');
|
||||
setVideoKey(null);
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
setSelectedFile(null);
|
||||
setPreview(null);
|
||||
setVideoKey(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function handleUploadVideo() {
|
||||
try {
|
||||
const s3Key = await uploadVideoMultipart();
|
||||
setVideoKey(s3Key); // Сохраняем ключ после загрузки
|
||||
} catch (err: unknown) {
|
||||
// Ошибка уже обработана в uploadVideoMultipart
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadVideoMultipart() {
|
||||
if (!selectedFile) throw new Error('Файл не выбран');
|
||||
if (!generationUuid) throw new Error('generationUuid не получен');
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setStatus('Инициализация загрузки...');
|
||||
|
||||
try {
|
||||
const csrfToken = await getCsrfToken();
|
||||
|
||||
// 1. Инициализация multipart upload
|
||||
const initResponse = await fetch('/api/upload/video/init', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-csrf-token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
filename: selectedFile.name,
|
||||
contentType: selectedFile.type,
|
||||
fileSize: selectedFile.size,
|
||||
generationUuid,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
const errorData = await initResponse.json();
|
||||
throw new Error(errorData.message || 'Ошибка инициализации загрузки');
|
||||
}
|
||||
|
||||
const initData = await initResponse.json();
|
||||
const { fileId, uploadId, parts, s3Key } = initData.data;
|
||||
|
||||
setStatus(`Загрузка видео... 0%`);
|
||||
|
||||
// 2. Параллельная загрузка частей напрямую в S3
|
||||
const uploadedParts = new Array(parts.length);
|
||||
const PART_SIZE = 10 * 1024 * 1024; // 10MB (увеличено для эффективности)
|
||||
const MAX_CONCURRENT = 6; // До 6 частей параллельно
|
||||
|
||||
// Функция загрузки одной части с retry и прогрессом
|
||||
const uploadPartWithRetry = async (partIndex: number, maxRetries = 3): Promise<{ PartNumber: number; ETag: string } | undefined> => {
|
||||
const part = parts[partIndex];
|
||||
const start = partIndex * PART_SIZE;
|
||||
const end = Math.min(start + PART_SIZE, selectedFile.size);
|
||||
const chunk = selectedFile.slice(start, end);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
console.log(`[Upload] Часть ${part.partNumber}: начало загрузки, размер: ${(chunk.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// Используем XMLHttpRequest для лучшей производительности с бинарными данными
|
||||
const etag = await new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', part.presignedUrl, true);
|
||||
|
||||
// Отслеживаем прогресс загрузки
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = (e.loaded / e.total * 100).toFixed(1);
|
||||
const speed = (e.loaded / ((Date.now() - startTime) / 1000) / 1024 / 1024).toFixed(2);
|
||||
console.log(`[Upload] Часть ${part.partNumber}: ${percent}% (${speed} MB/s)`);
|
||||
}
|
||||
};
|
||||
|
||||
// Устанавливаем таймаут 120 секунд
|
||||
xhr.timeout = 120000;
|
||||
|
||||
xhr.onload = function() {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
const speed = (chunk.size / 1024 / 1024 / parseFloat(duration)).toFixed(2);
|
||||
console.log(`[Upload] Часть ${part.partNumber}: завершено за ${duration}s (${speed} MB/s)`);
|
||||
|
||||
if (xhr.status === 200) {
|
||||
const etag = xhr.getResponseHeader('ETag');
|
||||
if (etag) {
|
||||
resolve(etag.replace(/"/g, '')); // MinIO возвращает ETag в кавычках
|
||||
} else {
|
||||
reject(new Error('Нет ETag'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
console.error(`[Upload] Часть ${part.partNumber}: ошибка сети`);
|
||||
reject(new Error('Network error'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
console.error(`[Upload] Часть ${part.partNumber}: таймаут`);
|
||||
reject(new Error('Timeout'));
|
||||
};
|
||||
|
||||
xhr.send(chunk);
|
||||
});
|
||||
|
||||
return {
|
||||
PartNumber: part.partNumber,
|
||||
ETag: etag,
|
||||
};
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(`Часть ${part.partNumber}: ${err instanceof Error ? err.message : 'ошибка'}`);
|
||||
}
|
||||
// Ждём перед retry
|
||||
await new Promise(r => setTimeout(r, 100 * attempt));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Загружаем части пачками по MAX_CONCURRENT
|
||||
for (let i = 0; i < parts.length; i += MAX_CONCURRENT) {
|
||||
const batch = [];
|
||||
const batchEnd = Math.min(i + MAX_CONCURRENT, parts.length);
|
||||
|
||||
for (let j = i; j < batchEnd; j++) {
|
||||
batch.push(uploadPartWithRetry(j));
|
||||
}
|
||||
|
||||
const results = await Promise.all(batch);
|
||||
|
||||
// Сохраняем результаты
|
||||
for (let k = 0; k < results.length; k++) {
|
||||
uploadedParts[i + k] = results[k];
|
||||
}
|
||||
|
||||
// Обновляем прогресс
|
||||
const progress = Math.round(((i + batch.length) / parts.length) * 100);
|
||||
setUploadProgress(progress);
|
||||
setStatus(`Загрузка видео... ${progress}%`);
|
||||
}
|
||||
|
||||
// 3. Завершение multipart upload
|
||||
setStatus('Завершение загрузки...');
|
||||
const completeResponse = await fetch('/api/upload/video/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-csrf-token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
fileId,
|
||||
uploadId,
|
||||
parts: uploadedParts.filter(p => p !== undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
const errorData = await completeResponse.json();
|
||||
throw new Error(errorData.message || 'Ошибка завершения загрузки');
|
||||
}
|
||||
|
||||
setUploadProgress(100);
|
||||
setStatus('Видео загружено');
|
||||
setUploading(false);
|
||||
|
||||
return s3Key;
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
|
||||
setUploading(false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUniqueize() {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (!generationUuid) throw new Error('generationUuid не получен');
|
||||
|
||||
let s3Key = videoKey;
|
||||
|
||||
// Если видео ещё не загружено, загружаем
|
||||
if (!s3Key) {
|
||||
setUploading(true);
|
||||
setStatus('Загрузка видео...');
|
||||
s3Key = await uploadVideoMultipart();
|
||||
setVideoKey(s3Key);
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
setStatus('Видео отправлено на обработку');
|
||||
|
||||
const csrfToken = await getCsrfToken();
|
||||
|
||||
// Вызываем step/1 с url видео
|
||||
const stepPayload = {
|
||||
input: {
|
||||
url: s3Key,
|
||||
},
|
||||
};
|
||||
|
||||
const stepResponse = await fetch('/api/scenario/uniqueizer/step/1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-csrf-token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(stepPayload),
|
||||
});
|
||||
|
||||
if (!stepResponse.ok) {
|
||||
const data = await stepResponse.json();
|
||||
throw new Error(data.message || 'Ошибка выполнения шага');
|
||||
}
|
||||
|
||||
// Переход на страницу результата
|
||||
setTimeout(() => {
|
||||
router.push(`/result?generationUuid=${generationUuid}`);
|
||||
}, 1000);
|
||||
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка обработки');
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCsrfToken(): Promise<string> {
|
||||
const response = await fetch('/api/auth/csrf', { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
return data.csrfToken || '';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<h1 style={styles.title}>Уникализация видео</h1>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
{status && (
|
||||
<div style={styles.status}>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статус сценария */}
|
||||
{!scenarioStarted && (
|
||||
<div style={styles.infoBox}>
|
||||
<span style={styles.spinner}></span>
|
||||
Запуск сценария...
|
||||
</div>
|
||||
)}
|
||||
{scenarioStarted && !error && (
|
||||
<div style={styles.infoBox}>
|
||||
✓ Сценарий запущен (ID: {generationUuid?.slice(0, 8)}...)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Загрузка видео */}
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>1. Загрузите видео</h2>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
ref={fileUploadRef}
|
||||
style={styles.fileUpload}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/webm"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={styles.fileUploadIcon}>🎬</div>
|
||||
<div style={styles.fileUploadText}>
|
||||
Нажмите для загрузки или перетащите файл
|
||||
</div>
|
||||
<div style={styles.fileUploadHint}>
|
||||
MP4, MOV, AVI, WebM до 500MB
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.filePreview}>
|
||||
<video src={preview!} controls style={styles.previewVideo} />
|
||||
<div style={styles.filePreviewInfo}>
|
||||
<div style={styles.filePreviewName}>{selectedFile.name}</div>
|
||||
<div>{formatFileSize(selectedFile.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeFile}
|
||||
style={styles.fileRemove}
|
||||
disabled={uploading}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && !videoKey && (
|
||||
<div>
|
||||
{uploading && uploadProgress > 0 && (
|
||||
<div style={styles.progressContainer}>
|
||||
<div style={{
|
||||
...styles.progressBar,
|
||||
width: `${uploadProgress}%`,
|
||||
}}></div>
|
||||
<span style={styles.progressText}>{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUploadVideo}
|
||||
disabled={uploading || !scenarioStarted}
|
||||
style={{
|
||||
...styles.btn,
|
||||
...styles.btnPrimary,
|
||||
...((uploading || !scenarioStarted) ? styles.btnDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Загрузка...' : 'Загрузить видео'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка уникализации */}
|
||||
{(videoKey || selectedFile) && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>2. Уникализируйте</h2>
|
||||
<p style={styles.sectionDesc}>
|
||||
После нажатия видео будет отправлено на обработку
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUniqueize}
|
||||
disabled={uploading || !scenarioStarted}
|
||||
style={{
|
||||
...styles.btn,
|
||||
...styles.btnPrimary,
|
||||
...((uploading || !scenarioStarted) ? styles.btnDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Загрузка...' : 'Уникализировать'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка закрытия */}
|
||||
<button onClick={handleClose} style={styles.btnSecondary}>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
minHeight: '100vh',
|
||||
background: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
container: {
|
||||
maxWidth: '700px',
|
||||
width: '100%',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#666',
|
||||
fontSize: '16px',
|
||||
},
|
||||
card: {
|
||||
background: 'white',
|
||||
padding: '40px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '24px',
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoBox: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '12px',
|
||||
background: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '20px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
section: {
|
||||
marginBottom: '32px',
|
||||
padding: '20px',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
sectionDesc: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
fileUpload: {
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
fileUploadIcon: {
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
fileUploadText: {
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
fileUploadHint: {
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
},
|
||||
filePreview: {
|
||||
position: 'relative',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
previewVideo: {
|
||||
width: '100%',
|
||||
maxHeight: '400px',
|
||||
borderRadius: '8px',
|
||||
background: '#000',
|
||||
},
|
||||
filePreviewInfo: {
|
||||
padding: '12px',
|
||||
background: '#fff',
|
||||
borderRadius: '6px',
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
filePreviewName: {
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
fileRemove: {
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
background: 'rgba(220, 38, 38, 0.9)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btn: {
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnPrimary: {
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
},
|
||||
btnDisabled: {
|
||||
background: '#ccc',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
btnSecondary: {
|
||||
width: '100%',
|
||||
padding: '12px 24px',
|
||||
background: '#f5f5f5',
|
||||
color: '#333',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
error: {
|
||||
background: '#fee',
|
||||
color: '#c00',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '20px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
status: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '12px',
|
||||
background: '#e7f3ff',
|
||||
color: '#0066cc',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '20px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
spinner: {
|
||||
display: 'inline-block',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid rgba(0,123,255,0.3)',
|
||||
borderTopColor: '#007bff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
progressContainer: {
|
||||
position: 'relative',
|
||||
height: '24px',
|
||||
background: '#e0e0e0',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #007bff, #0056b3)',
|
||||
borderRadius: '12px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
progressText: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
};
|
||||
|
||||
export default function UniqueizerPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={styles.page}><div style={styles.container}><div style={styles.emptyState}>Загрузка...</div></div></div>}>
|
||||
<UniqueizerContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user