initial commit
This commit is contained in:
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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