520 lines
14 KiB
TypeScript
520 lines
14 KiB
TypeScript
'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',
|
||
},
|
||
};
|