initial commit

This commit is contained in:
root
2026-05-13 14:20:41 +00:00
commit 6e178d2012
6022 changed files with 399872 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface User {
id: number;
email: string;
displayName: string;
role: string;
status: string;
balance: number;
createdAt: string;
sessionId: string;
}
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [uniqueizerLoading, setUniqueizerLoading] = useState(false);
useEffect(() => {
async function loadUser() {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (!response.ok) {
router.push('/');
return;
}
const data = await response.json();
setUser(data.user);
} catch (err) {
router.push('/');
} finally {
setLoading(false);
}
}
loadUser();
}, [router]);
async function handleLogout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
});
router.push('/');
} catch (err) {
console.error('Logout error:', err);
router.push('/');
}
}
async function getCsrfToken(): Promise<string> {
const response = await fetch('/api/auth/csrf', { credentials: 'same-origin' });
const data = await response.json();
return data.csrfToken || '';
}
async function startUniqueizer() {
setUniqueizerLoading(true);
try {
const csrfToken = await getCsrfToken();
const response = await fetch('/api/scenario/uniqueizer/start', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
});
if (response.ok) {
const data = await response.json();
// Передаём generationUuid в URL
router.push(`/uniqueizer?generationUuid=${data.generationUuid}`);
} else {
const data = await response.json();
alert('Ошибка запуска сценария: ' + (data.message || response.statusText));
}
} catch (err: unknown) {
alert('Ошибка: ' + (err instanceof Error ? err.message : String(err)));
} finally {
setUniqueizerLoading(false);
}
}
if (loading) {
return <div style={styles.loading}>Загрузка...</div>;
}
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.headerTitle}>Uno Click</h1>
<button onClick={handleLogout} style={styles.logoutBtn}>
Выйти
</button>
</div>
<div style={styles.welcome}>
<h2 style={styles.welcomeTitle}>
Добро пожаловать, {user?.displayName || user?.email}!
</h2>
<p style={styles.welcomeText}>Выберите сценарий для запуска</p>
</div>
<div style={styles.scenariosGrid}>
{/* Nano Banana */}
<div style={styles.scenarioCard}>
<div style={{ ...styles.scenarioIcon, ...styles.scenarioIconBlue }}>
🎨
</div>
<div style={styles.scenarioName}>Nano Banana</div>
<div style={styles.scenarioDesc}>
Генерация изображений по текстовому описанию. Введите промпт и получите уникальную иллюстрацию.
</div>
<a href="/prompt?scenario=nano-banana" style={styles.btnPrimary}>
Запустить
</a>
</div>
{/* Demo Scenario */}
<div style={styles.scenarioCard}>
<div style={{ ...styles.scenarioIcon, ...styles.scenarioIconGreen }}>
🧪
</div>
<div style={styles.scenarioName}>Demo Scenario</div>
<div style={styles.scenarioDesc}>
Тестовый сценарий для проверки функциональности. Многоступенчатая генерация с подтверждением.
</div>
<a href="/prompt?scenario=demo-scenario" style={styles.btnSecondary}>
Запустить
</a>
</div>
{/* Уникализация */}
<div style={styles.scenarioCard}>
<div style={{ ...styles.scenarioIcon, ...styles.scenarioIconBlue }}>
</div>
<div style={styles.scenarioName}>Уникализация</div>
<div style={styles.scenarioDesc}>
Запуск сценария уникализации контента.
</div>
<button
onClick={startUniqueizer}
disabled={uniqueizerLoading}
style={{
...styles.btnPrimary,
...(uniqueizerLoading ? styles.btnDisabled : {}),
}}
>
{uniqueizerLoading ? 'Запуск...' : 'Запустить'}
</button>
</div>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
padding: '40px 20px',
background: '#f5f5f5',
},
container: {
maxWidth: '800px',
margin: '0 auto',
},
header: {
background: 'white',
padding: '20px 30px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: '20px',
fontWeight: 600,
color: '#333',
},
logoutBtn: {
background: 'none',
border: '1px solid #ddd',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#666',
},
welcome: {
background: 'white',
padding: '20px 30px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
marginBottom: '20px',
},
welcomeTitle: {
fontSize: '18px',
fontWeight: 600,
color: '#333',
marginBottom: '8px',
},
welcomeText: {
color: '#666',
fontSize: '14px',
},
scenariosGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '20px',
},
scenarioCard: {
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
padding: '24px',
transition: 'box-shadow 0.2s',
},
scenarioIcon: {
width: '48px',
height: '48px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
marginBottom: '16px',
},
scenarioIconBlue: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
},
scenarioIconGreen: {
background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)',
},
scenarioName: {
fontSize: '16px',
fontWeight: 600,
color: '#333',
marginBottom: '8px',
},
scenarioDesc: {
fontSize: '14px',
color: '#666',
marginBottom: '20px',
lineHeight: 1.5,
},
btnPrimary: {
width: '100%',
padding: '12px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
},
btnSecondary: {
width: '100%',
padding: '12px',
background: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
},
btnDisabled: {
background: '#ccc',
cursor: 'not-allowed',
},
loading: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontSize: '18px',
color: '#666',
},
};
+6
View File
@@ -0,0 +1,6 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
+18
View File
@@ -0,0 +1,18 @@
import './globals.css';
export const metadata = {
title: 'Uno Click',
description: 'Uno Click - платформа для генерации контента',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body>{children}</body>
</html>
);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Проверяем авторизацию при загрузке
useEffect(() => {
async function checkAuth() {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (response.ok) {
router.push('/dashboard');
}
} catch (err) {
// Не авторизован, остаёмся на странице входа
}
}
checkAuth();
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Ошибка входа');
}
// CSRF токен теперь в cookie с SameSite защитой
// Next.js автоматически отправит его с последующими запросами
router.push('/dashboard');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
setLoading(false);
}
}
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>Uno Click</h1>
<p style={styles.subtitle}>Войдите для продолжения</p>
{error && <div style={styles.error}>{error}</div>}
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label htmlFor="email" style={styles.label}>Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="test.user@uno-click.local"
style={styles.input}
/>
</div>
<div style={styles.formGroup}>
<label htmlFor="password" style={styles.label}>Пароль</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="test123"
style={styles.input}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
...styles.button,
...(loading ? styles.buttonDisabled : {}),
}}
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: '#f5f5f5',
},
card: {
background: 'white',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '400px',
},
title: {
fontSize: '24px',
fontWeight: 600,
marginBottom: '8px',
color: '#333',
},
subtitle: {
color: '#666',
marginBottom: '32px',
fontSize: '14px',
},
formGroup: {
marginBottom: '20px',
},
label: {
display: 'block',
marginBottom: '6px',
fontSize: '14px',
color: '#333',
fontWeight: 500,
},
input: {
width: '100%',
padding: '12px 14px',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
transition: 'border-color 0.2s',
},
button: {
width: '100%',
padding: '12px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
transition: 'background 0.2s',
},
buttonDisabled: {
background: '#ccc',
cursor: 'not-allowed',
},
error: {
background: '#fee',
color: '#c00',
padding: '12px',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
};
+519
View File
@@ -0,0 +1,519 @@
'use client';
import { useEffect, useState, useRef, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
interface Scenario {
name: string;
desc: string;
icon: string;
class: 'blue' | 'green';
stepId: string;
}
interface UploadedFile {
key: string;
name: string;
size: number;
preview: string;
}
function PromptContent() {
const router = useRouter();
const searchParams = useSearchParams();
const scenarioId = searchParams.get('scenario') || 'nano-banana';
const scenarios: Record<string, Scenario> = {
'nano-banana': {
name: 'Nano Banana',
desc: 'Генерация изображений по промпту',
icon: '🎨',
class: 'blue',
stepId: '1',
},
'demo-scenario': {
name: 'Demo Scenario',
desc: 'Тестовый сценарий с подтверждением',
icon: '🧪',
class: 'green',
stepId: 'collect-input',
},
};
const scenario = scenarios[scenarioId] || scenarios['nano-banana'];
const [prompt, setPrompt] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
const [csrfToken, setCsrfToken] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const fileUploadRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadCsrfToken();
setupDragDrop();
}, []);
async function loadCsrfToken() {
try {
const response = await fetch('/api/auth/csrf', { credentials: 'same-origin' });
const data = await response.json();
setCsrfToken(data.csrfToken || '');
} catch (err) {
console.error('Failed to load CSRF token:', err);
}
}
function setupDragDrop() {
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 = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
setError('Недопустимый тип файла. Разрешены: JPEG, PNG, GIF, WebP');
return;
}
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
setError('Файл слишком большой. Максимум: 10MB');
return;
}
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (e) => setPreview(e.target?.result as string);
reader.readAsDataURL(file);
setError('');
}
function removeFile() {
setSelectedFile(null);
setPreview(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 uploadFileToS3(file: File, generationUuid: string, generationStepId: string): Promise<string> {
const formData = new FormData();
formData.append('file', file);
formData.append('generationUuid', generationUuid);
formData.append('generationStepId', generationStepId);
const response = await fetch('/api/upload/image', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: formData,
credentials: 'same-origin',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Ошибка загрузки файла');
return data.data.s3Key;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
setStatus('Запуск сценария...');
try {
// Шаг 1: Запуск сценария
const startResponse = await fetch(`/api/scenario/${scenarioId}/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({}),
});
const startData = await startResponse.json();
if (!startResponse.ok) throw new Error(startData.message || 'Ошибка запуска сценария');
const generationUuid = startData.generationUuid;
// Шаг 2: Загрузка файла (если есть)
let uploadedFileKey: string | undefined;
if (selectedFile) {
setStatus('Загрузка файла...');
const stepRecordResponse = await fetch(`/api/scenario/${scenarioId}/step/${scenario.stepId}/record`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({ prompt }),
});
const stepRecordData = await stepRecordResponse.json();
const generationStepId = stepRecordData.stepRecordId;
uploadedFileKey = await uploadFileToS3(selectedFile, generationUuid, generationStepId);
}
// Шаг 3: Выполнение шага
const stepPayload: Record<string, string> = { prompt };
if (uploadedFileKey) stepPayload.imageKey = uploadedFileKey;
setStatus('Генерация...');
const stepResponse = await fetch(`/api/scenario/${scenarioId}/step/${scenario.stepId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(stepPayload),
});
const stepData = await stepResponse.json();
if (!stepResponse.ok) throw new Error(stepData.message || 'Ошибка выполнения шага');
router.push(`/result?generationUuid=${generationUuid}`);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
setLoading(false);
setStatus('');
}
}
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.headerTitle}>Создание</h1>
<button onClick={() => router.push('/dashboard')} style={styles.btnSmall}>
Назад
</button>
</div>
<div style={styles.card}>
<div style={styles.scenarioInfo}>
<div style={{ ...styles.scenarioIcon, ...styles[scenario.class] }}>
{scenario.icon}
</div>
<div>
<div style={styles.scenarioName}>{scenario.name}</div>
<div style={styles.scenarioDesc}>{scenario.desc}</div>
</div>
</div>
{error && <div style={styles.error}>{error}</div>}
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label htmlFor="prompt" style={styles.label}>Опишите, что хотите создать</label>
<textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
required
placeholder="Например: кот в космосе, цифровая иллюстрация, яркие цвета..."
style={styles.textarea}
/>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Изображение (опционально)</label>
<div
ref={fileUploadRef}
style={styles.fileUpload}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div style={styles.fileUploadLabel}>
<div style={styles.fileUploadIcon}>📁</div>
<div style={styles.fileUploadText}>Нажмите для загрузки или перетащите файл</div>
<div style={{ ...styles.fileUploadText, fontSize: '12px', color: '#999', marginTop: '4px' }}>
JPEG, PNG, GIF, WebP до 10MB
</div>
</div>
</div>
{preview && (
<div style={styles.filePreview}>
<img src={preview} alt="Preview" style={styles.previewImg} />
<div style={styles.filePreviewInfo}>
<div style={styles.filePreviewName}>{selectedFile?.name}</div>
<div>{selectedFile && formatFileSize(selectedFile.size)}</div>
</div>
<button type="button" onClick={removeFile} style={styles.fileRemove}>×</button>
</div>
)}
</div>
<button
type="submit"
disabled={loading}
style={{
...styles.btnPrimary,
...(loading ? styles.btnDisabled : {}),
}}
>
{loading ? 'Запуск...' : 'Создать'}
</button>
</form>
{status && (
<div style={styles.status}>
<span style={styles.spinner}></span>
<span>{status}</span>
</div>
)}
</div>
</div>
</div>
);
}
export default function PromptPage() {
return (
<Suspense fallback={<div style={styles.page}><div style={styles.container}><div style={styles.emptyState}>Загрузка...</div></div></div>}>
<PromptContent />
</Suspense>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
padding: '40px 20px',
background: '#f5f5f5',
},
container: {
maxWidth: '600px',
margin: '0 auto',
},
emptyState: {
textAlign: 'center',
padding: '60px 20px',
color: '#666',
},
header: {
background: 'white',
padding: '20px 30px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: '20px',
fontWeight: 600,
color: '#333',
},
btnSmall: {
background: 'none',
border: '1px solid #ddd',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#666',
},
card: {
background: 'white',
padding: '30px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
},
scenarioInfo: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '24px',
padding: '16px',
background: '#f8f9fa',
borderRadius: '6px',
},
scenarioIcon: {
width: '40px',
height: '40px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
},
blue: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
},
green: {
background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)',
},
scenarioName: {
fontWeight: 600,
color: '#333',
},
scenarioDesc: {
fontSize: '13px',
color: '#666',
},
formGroup: {
marginBottom: '20px',
},
label: {
display: 'block',
marginBottom: '8px',
fontSize: '14px',
color: '#333',
fontWeight: 500,
},
textarea: {
width: '100%',
padding: '14px',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical',
minHeight: '120px',
},
fileUpload: {
border: '2px dashed #ddd',
borderRadius: '6px',
padding: '20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
marginBottom: '10px',
},
fileUploadLabel: {
display: 'block',
cursor: 'pointer',
},
fileUploadIcon: {
fontSize: '32px',
marginBottom: '8px',
},
fileUploadText: {
fontSize: '14px',
color: '#666',
},
filePreview: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '10px',
padding: '10px',
background: '#f8f9fa',
borderRadius: '6px',
},
previewImg: {
maxWidth: '80px',
maxHeight: '80px',
borderRadius: '4px',
objectFit: 'cover',
},
filePreviewInfo: {
flex: 1,
fontSize: '13px',
color: '#666',
},
filePreviewName: {
fontWeight: 500,
color: '#333',
marginBottom: '4px',
},
fileRemove: {
background: 'none',
border: 'none',
color: '#dc3545',
cursor: 'pointer',
fontSize: '18px',
padding: '4px 8px',
},
btnPrimary: {
padding: '12px 24px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
width: '100%',
},
btnDisabled: {
background: '#ccc',
cursor: 'not-allowed',
},
error: {
background: '#fee',
color: '#c00',
padding: '12px',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
status: {
textAlign: 'center',
padding: '20px',
color: '#666',
},
spinner: {
display: 'inline-block',
width: '20px',
height: '20px',
border: '2px solid #ddd',
borderTopColor: '#007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '10px',
verticalAlign: 'middle',
},
};
+470
View File
@@ -0,0 +1,470 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
interface FileResult {
url: string;
contentType: string;
format?: string;
width?: number;
height?: number;
duration?: number;
}
interface ResultData {
status: string;
scenarioName?: string;
requestPayload?: { prompt?: string };
files?: FileResult[];
}
function ResultContent() {
const router = useRouter();
const searchParams = useSearchParams();
const generationUuid = searchParams.get('generationUuid');
const [result, setResult] = useState<ResultData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
useEffect(() => {
if (generationUuid) {
loadResult();
} else {
setError('Не указан generationUuid');
setLoading(false);
}
return () => {
if (autoRefresh) {
// Cleanup если нужно
}
};
}, [generationUuid]);
async function loadResult() {
if (!generationUuid) return;
try {
const response = await fetch(`/api/result/${generationUuid}`, {
credentials: 'same-origin',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Ошибка загрузки результата');
}
setResult(data.result);
setError('');
setLoading(false);
// Авто-обновление если ещё генерируется
if (data.result.status === 'running' || data.result.status === 'waiting_for_input') {
setAutoRefresh(true);
setTimeout(loadResult, 3000);
} else {
setAutoRefresh(false);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
setLoading(false);
}
}
function getStatusText(status: string): string {
const statuses: Record<string, string> = {
running: 'Генерация...',
completed: 'Готово',
failed: 'Ошибка',
waiting_for_input: 'Ожидание...',
created: 'Создано',
};
return statuses[status] || status;
}
function getStatusClass(status: string): string {
return `status-badge status-${status}`;
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function renderFile(file: FileResult, index: number) {
const isVideo = file.contentType === 'video';
const previewContent = isVideo
? <video src={file.url} controls style={styles.previewMedia} />
: <img src={file.url} alt={`Result ${index + 1}`} style={styles.previewMedia} />;
return (
<div key={index} style={styles.fileCard}>
<div style={styles.filePreview}>{previewContent}</div>
<div style={styles.fileInfo}>
<div style={styles.fileMeta}>
{file.format && (
<span style={styles.fileMetaItem}>
<strong>Формат:</strong> {file.format.toUpperCase()}
</span>
)}
{file.width && file.height && (
<span style={styles.fileMetaItem}>
<strong>Размер:</strong> {file.width}×{file.height}
</span>
)}
{file.duration && (
<span style={styles.fileMetaItem}>
<strong>Длительность:</strong> {formatDuration(file.duration)}
</span>
)}
</div>
<div style={styles.fileActions}>
<a href={file.url} target="_blank" rel="noopener noreferrer" style={styles.btnPrimary}>
👁 Открыть
</a>
<a href={file.url} download style={styles.btnSecondary}>
Скачать
</a>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.headerTitle}>Результат</h1>
<a href="/dashboard" style={styles.btnSmall}>К сценариям</a>
</div>
<div style={styles.card}>
<div style={styles.emptyState}>
<div style={styles.emptyStateIcon}>
<span style={styles.spinnerLarge}></span>
</div>
<div style={styles.emptyStateText}>Загружаем результат...</div>
</div>
</div>
</div>
</div>
);
}
if (error && !result) {
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.headerTitle}>Результат</h1>
<a href="/dashboard" style={styles.btnSmall}>К сценариям</a>
</div>
<div style={styles.card}>
<div style={styles.error}>{error}</div>
</div>
</div>
</div>
);
}
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.headerTitle}>Результат</h1>
<div style={styles.headerActions}>
<a href="/dashboard" style={styles.btnSmall}>К сценариям</a>
</div>
</div>
<div style={styles.card}>
<div style={styles.cardHeader}>
<span style={styles.cardTitle}>{result?.scenarioName || 'Загрузка...'}</span>
<span style={{
...styles.statusBadge,
...(result?.status === 'completed' ? styles.statusCompleted :
result?.status === 'failed' ? styles.statusFailed :
styles.statusRunning),
}}>
{result ? getStatusText(result.status) : 'Загрузка...'}
</span>
</div>
<div style={styles.cardBody}>
{error && <div style={styles.error}>{error}</div>}
{result?.requestPayload?.prompt && (
<div style={styles.promptInfo}>
<div style={styles.promptLabel}>Промпт</div>
<div style={styles.promptText}>{result.requestPayload.prompt}</div>
</div>
)}
{result?.files && result.files.length > 0 ? (
<div style={styles.fileContainer}>
{result.files.map((file, index) => renderFile(file, index))}
</div>
) : result?.status === 'completed' ? (
<div style={styles.emptyState}>
<div style={styles.emptyStateIcon}></div>
<div style={styles.emptyStateText}>Результат получен, но файлы не найдены</div>
</div>
) : result?.status === 'failed' ? (
<div style={styles.emptyState}>
<div style={styles.emptyStateIcon}></div>
<div style={styles.emptyStateText}>Произошла ошибка при генерации</div>
</div>
) : (
<div style={styles.emptyState}>
<div style={styles.emptyStateIcon}></div>
<div style={styles.emptyStateText}>Результат ещё не готов</div>
<button onClick={loadResult} style={styles.refreshBtn}>
<span style={{
...styles.spinner,
display: autoRefresh ? 'inline-block' : 'none',
}}></span>
<span>Проверить снова</span>
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default function ResultPage() {
return (
<Suspense fallback={<div style={styles.page}><div style={styles.container}><div style={styles.emptyState}>Загрузка...</div></div></div>}>
<ResultContent />
</Suspense>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
padding: '40px 20px',
background: '#f5f5f5',
},
container: {
maxWidth: '900px',
margin: '0 auto',
},
header: {
background: 'white',
padding: '20px 30px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: '20px',
fontWeight: 600,
color: '#333',
},
headerActions: {
display: 'flex',
gap: '10px',
},
btnSmall: {
padding: '8px 16px',
borderRadius: '6px',
fontSize: '13px',
cursor: 'pointer',
border: '1px solid #ddd',
background: 'white',
color: '#666',
textDecoration: 'none',
},
card: {
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
overflow: 'hidden',
},
cardHeader: {
padding: '20px 30px',
borderBottom: '1px solid #eee',
display: 'flex',
alignItems: 'center',
},
cardTitle: {
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
cardBody: {
padding: '30px',
},
statusBadge: {
display: 'inline-block',
padding: '4px 12px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 500,
marginLeft: '10px',
},
statusRunning: {
background: '#fff3cd',
color: '#856404',
},
statusCompleted: {
background: '#d4edda',
color: '#155724',
},
statusFailed: {
background: '#f8d7da',
color: '#721c24',
},
emptyState: {
textAlign: 'center',
padding: '60px 20px',
color: '#666',
},
emptyStateIcon: {
fontSize: '48px',
marginBottom: '16px',
},
emptyStateText: {
fontSize: '16px',
marginBottom: '24px',
},
refreshBtn: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '12px 24px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
},
spinner: {
display: 'inline-block',
width: '16px',
height: '16px',
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
spinnerLarge: {
width: '32px',
height: '32px',
border: '2px solid rgba(0,123,255,0.3)',
borderTopColor: '#007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
error: {
background: '#fee',
color: '#c00',
padding: '12px',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
promptInfo: {
background: '#f8f9fa',
padding: '16px 20px',
borderRadius: '6px',
marginBottom: '20px',
},
promptLabel: {
fontSize: '12px',
color: '#666',
marginBottom: '4px',
},
promptText: {
fontSize: '14px',
color: '#333',
},
fileContainer: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
},
fileCard: {
border: '1px solid #eee',
borderRadius: '8px',
overflow: 'hidden',
},
filePreview: {
width: '100%',
background: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '300px',
maxHeight: '600px',
},
previewMedia: {
maxWidth: '100%',
maxHeight: '600px',
objectFit: 'contain',
},
fileInfo: {
padding: '16px 20px',
background: '#fafafa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px',
},
fileMeta: {
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
},
fileMetaItem: {
fontSize: '13px',
color: '#666',
},
fileActions: {
display: 'flex',
gap: '10px',
},
btnPrimary: {
padding: '10px 20px',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
textDecoration: 'none',
border: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: '#007bff',
color: 'white',
},
btnSecondary: {
padding: '10px 20px',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
textDecoration: 'none',
border: '1px solid #ddd',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: '#f5f5f5',
color: '#333',
},
};
+686
View File
@@ -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>
);
}