initial commit
This commit is contained in:
@@ -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',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user