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
+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',
},
};