banatie-service/apps/landing/src/app/demo/tti/page.tsx

650 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
import { GenerationTimer } from '@/components/demo/GenerationTimer';
import { ResultCard } from '@/components/demo/ResultCard';
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
// Generate random 6-character uppercase ID for pairing images
function generatePairId(): string {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
interface GenerationResult {
id: string;
timestamp: Date;
originalPrompt: string;
enhancedPrompt?: string;
leftImage: {
url: string;
width: number;
height: number;
error?: string;
} | null;
rightImage: {
url: string;
width: number;
height: number;
error?: string;
} | null;
durationMs?: number;
leftData?: {
request: object;
response: object;
geminiParams: object;
};
rightData?: {
request: object;
response: object;
geminiParams: object;
};
enhancementOptions?: {
template?: string;
meta?: {
tags?: string[];
};
} & AdvancedOptionsData;
}
interface ApiKeyInfo {
organizationSlug?: string;
projectSlug?: string;
}
export default function DemoTTIPage() {
// API Key State
const [apiKey, setApiKey] = useState('');
const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [apiKeyValidated, setApiKeyValidated] = useState(false);
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
const [apiKeyError, setApiKeyError] = useState('');
const [validatingKey, setValidatingKey] = useState(false);
// Prompt State
const [prompt, setPrompt] = useState('');
const [generating, setGenerating] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState<number | undefined>();
const [generationError, setGenerationError] = useState('');
// Enhancement Options State
const [aspectRatio, setAspectRatio] = useState('1:1');
const [template, setTemplate] = useState('photorealistic');
const [advancedOptions, setAdvancedOptions] = useState<AdvancedOptionsData>({});
const [showAdvancedModal, setShowAdvancedModal] = useState(false);
// Results State
const [results, setResults] = useState<GenerationResult[]>([]);
// Modal State
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Load API key from localStorage on mount
useEffect(() => {
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
if (storedApiKey) {
setApiKey(storedApiKey);
// Auto-validate the stored key
validateStoredApiKey(storedApiKey);
}
}, []);
// Validate stored API key (without user interaction)
const validateStoredApiKey = async (keyToValidate: string) => {
setValidatingKey(true);
setApiKeyError('');
try {
const response = await fetch(`${API_BASE_URL}/api/info`, {
headers: {
'X-API-Key': keyToValidate,
},
});
if (response.ok) {
const data = await response.json();
setApiKeyValidated(true);
if (data.keyInfo) {
setApiKeyInfo({
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
});
} else {
setApiKeyInfo({
organizationSlug: 'Unknown',
projectSlug: 'Unknown',
});
}
} else {
// Stored key is invalid, clear it
localStorage.removeItem(API_KEY_STORAGE_KEY);
setApiKeyError('Stored API key is invalid or expired');
setApiKeyValidated(false);
}
} catch (error) {
setApiKeyError('Failed to validate stored API key');
setApiKeyValidated(false);
} finally {
setValidatingKey(false);
}
};
// Validate API Key
const validateApiKey = async () => {
if (!apiKey.trim()) {
setApiKeyError('Please enter an API key');
return;
}
setValidatingKey(true);
setApiKeyError('');
try {
// Test API key with a minimal request to /api/info or similar
const response = await fetch(`${API_BASE_URL}/api/info`, {
headers: {
'X-API-Key': apiKey,
},
});
if (response.ok) {
const data = await response.json();
setApiKeyValidated(true);
// Save to localStorage on successful validation
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
// Extract org/project info from API response
if (data.keyInfo) {
setApiKeyInfo({
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
});
} else {
setApiKeyInfo({
organizationSlug: 'Unknown',
projectSlug: 'Unknown',
});
}
} else {
const error = await response.json();
setApiKeyError(error.message || 'Invalid API key');
setApiKeyValidated(false);
}
} catch (error) {
setApiKeyError('Failed to validate API key. Please check your connection.');
setApiKeyValidated(false);
} finally {
setValidatingKey(false);
}
};
// Revoke API Key
const revokeApiKey = () => {
// Clear localStorage
localStorage.removeItem(API_KEY_STORAGE_KEY);
// Clear state
setApiKey('');
setApiKeyValidated(false);
setApiKeyInfo(null);
setApiKeyError('');
};
// Generate Images
const generateImages = async () => {
if (!prompt.trim()) {
setGenerationError('Please enter a prompt');
return;
}
setGenerating(true);
setGenerationError('');
const startTime = Date.now();
setGenerationStartTime(startTime);
const resultId = Date.now().toString();
const pairId = generatePairId(); // NEW: Generate unique pair ID
const timestamp = new Date();
try {
// Call API twice in parallel
// Left: original prompt WITHOUT enhancement (autoEnhance: false)
// Right: original prompt WITH enhancement (autoEnhance: true + template)
const [leftResult, rightResult] = await Promise.all([
fetch(`${API_BASE_URL}/api/text-to-image`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({
prompt: prompt.trim(),
filename: `demo_${resultId}_left`,
aspectRatio,
autoEnhance: false, // Explicitly disable enhancement for left image
meta: {
tags: [pairId, 'simple'], // NEW: Pair ID + "simple" tag
},
}),
}),
fetch(`${API_BASE_URL}/api/text-to-image`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({
prompt: prompt.trim(),
filename: `demo_${resultId}_right`,
aspectRatio,
autoEnhance: true, // Enable enhancement for right image
enhancementOptions: {
template: template || 'photorealistic', // Only template parameter
},
meta: {
tags: [pairId, 'enhanced'], // NEW: Pair ID + "enhanced" tag
},
}),
}),
]);
const leftData = await leftResult.json();
const rightData = await rightResult.json();
const endTime = Date.now();
const durationMs = endTime - startTime;
// Create result object
const newResult: GenerationResult = {
id: resultId,
timestamp,
originalPrompt: prompt.trim(),
enhancedPrompt: rightData.data?.promptEnhancement?.enhancedPrompt,
leftImage: leftData.success
? {
url: leftData.data.url || leftData.data.filepath,
width: 1024, // Default, would come from API
height: 1024,
}
: null,
rightImage: rightData.success
? {
url: rightData.data.url || rightData.data.filepath,
width: 1024,
height: 1024,
}
: null,
durationMs,
// Store full request/response data for inspect mode
leftData: {
request: {
prompt: prompt.trim(),
filename: `demo_${resultId}_left`,
aspectRatio,
autoEnhance: false,
meta: {
tags: [pairId, 'simple'],
},
},
response: leftData,
geminiParams: leftData.data?.geminiParams || {},
},
rightData: {
request: {
prompt: prompt.trim(),
filename: `demo_${resultId}_right`,
aspectRatio,
autoEnhance: true,
enhancementOptions: {
template: template || 'photorealistic',
},
meta: {
tags: [pairId, 'enhanced'],
},
},
response: rightData,
geminiParams: rightData.data?.geminiParams || {},
},
// Store enhancement options for display in inspect mode
enhancementOptions: {
template,
meta: {
tags: [pairId, 'enhanced'],
},
},
};
if (!leftData.success) {
newResult.leftImage = { url: '', width: 0, height: 0, error: leftData.error };
}
if (!rightData.success) {
newResult.rightImage = { url: '', width: 0, height: 0, error: rightData.error };
}
// Add to results at the top
setResults((prev) => [newResult, ...prev]);
// Clear prompt
setPrompt('');
} catch (error) {
setGenerationError(error instanceof Error ? error.message : 'Failed to generate images');
} finally {
setGenerating(false);
setGenerationStartTime(undefined);
}
};
// Handle Ctrl+Enter
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
generateImages();
}
};
// Copy to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Download image
const downloadImage = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Download failed:', error);
}
};
// Reuse prompt
const reusePrompt = (promptText: string) => {
setPrompt(promptText);
textareaRef.current?.focus();
};
return (
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
{/* Minimized API Key Badge */}
{apiKeyValidated && apiKeyInfo && (
<MinimizedApiKey
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
apiKey={apiKey}
onRevoke={revokeApiKey}
/>
)}
{/* Page Header */}
<header className="mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
Text-to-Image Workbench
</h1>
<p className="text-gray-400 text-base md:text-lg">
Developer tool for API testing and prompt engineering
</p>
</header>
{/* API Key Section - Only show when not validated */}
{!apiKeyValidated && (
<section
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
aria-label="API Key Validation"
>
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
<div className="flex gap-3">
<div className="flex-1 relative">
<input
type={apiKeyVisible ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
validateApiKey();
}
}}
placeholder="Enter your API key"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12"
aria-label="API key input"
/>
<button
type="button"
onClick={() => setApiKeyVisible(!apiKeyVisible)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
>
{apiKeyVisible ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<button
onClick={validateApiKey}
disabled={validatingKey}
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
>
{validatingKey ? 'Validating...' : 'Validate'}
</button>
</div>
{apiKeyError && (
<p className="mt-3 text-sm text-red-400" role="alert">
{apiKeyError}
</p>
)}
</section>
)}
{/* Unified Prompt & Generation Card */}
<section
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
aria-label="Image Generation"
>
{/* Prompt Textarea */}
<div className="mb-4">
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
Your Prompt
</label>
<textarea
id="prompt-input"
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the image you want to generate..."
disabled={!apiKeyValidated || generating}
rows={4}
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-none"
aria-label="Image generation prompt"
/>
</div>
{/* Enhancement Options Row */}
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
{/* Aspect Ratio */}
<div className="flex-1 min-w-[150px]">
<label
htmlFor="aspect-ratio"
className="block text-xs font-medium text-gray-400 mb-1.5"
>
Aspect Ratio
</label>
<select
id="aspect-ratio"
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={!apiKeyValidated || generating}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="1:1">Square (1:1)</option>
<option value="3:4">Portrait (3:4)</option>
<option value="4:3">Landscape (4:3)</option>
<option value="9:16">Vertical (9:16)</option>
<option value="16:9">Widescreen (16:9)</option>
<option value="21:9">Ultrawide (21:9)</option>
</select>
</div>
{/* Template */}
<div className="flex-1 min-w-[150px]">
<label htmlFor="template" className="block text-xs font-medium text-gray-400 mb-1.5">
Template
</label>
<select
id="template"
value={template}
onChange={(e) => setTemplate(e.target.value)}
disabled={!apiKeyValidated || generating}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="photorealistic">Photorealistic</option>
<option value="illustration">Illustration</option>
<option value="minimalist">Minimalist</option>
<option value="sticker">Sticker</option>
<option value="product">Product</option>
<option value="comic">Comic</option>
<option value="general">General</option>
</select>
</div>
{/* Advanced Options Button - Disabled (Coming Soon) */}
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-medium text-gray-400 mb-1.5 md:invisible">
Advanced
</label>
<div className="relative">
<button
disabled={true}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-gray-500 opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
aria-label="Advanced options (coming soon)"
title="Advanced options coming soon"
>
<span></span>
<span>Advanced</span>
<span className="ml-1 px-1.5 py-0.5 text-xs bg-slate-700 text-gray-400 rounded-full">
🔒
</span>
</button>
</div>
</div>
</div>
{/* Generate Button & Timer */}
<div className="flex items-center justify-between gap-4 flex-wrap pt-2 border-t border-slate-700/50">
<div className="text-sm text-gray-500">
{generating ? (
<GenerationTimer isGenerating={generating} startTime={generationStartTime} />
) : (
'Press Ctrl+Enter to submit'
)}
</div>
<button
onClick={generateImages}
disabled={!apiKeyValidated || generating || !prompt.trim()}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-amber-600 to-orange-600 text-white font-semibold hover:from-amber-500 hover:to-orange-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-900/30 focus:ring-2 focus:ring-amber-500"
>
{generating ? 'Generating...' : 'Generate Images'}
</button>
</div>
{generationError && (
<p className="mt-3 text-sm text-red-400" role="alert">
{generationError}
</p>
)}
</section>
{/* Advanced Options Modal */}
<AdvancedOptionsModal
isOpen={showAdvancedModal}
onClose={() => setShowAdvancedModal(false)}
value={advancedOptions}
onChange={setAdvancedOptions}
disabled={generating}
/>
{/* Results Section */}
{results.length > 0 && (
<section className="space-y-6" aria-label="Generated Results">
<h2 className="text-xl md:text-2xl font-bold text-white">Generated Images</h2>
{results.map((result) => (
<ResultCard
key={result.id}
result={result}
apiKey={apiKey}
onZoom={setZoomedImage}
onCopy={copyToClipboard}
onDownload={downloadImage}
onReusePrompt={reusePrompt}
/>
))}
</section>
)}
{/* Zoom Modal */}
{zoomedImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setZoomedImage(null)}
role="dialog"
aria-modal="true"
aria-label="Zoomed image view"
>
<button
onClick={() => setZoomedImage(null)}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:ring-2 focus:ring-white"
aria-label="Close zoomed image"
>
</button>
<img
src={zoomedImage}
alt="Zoomed"
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}