feat: add demo page

This commit is contained in:
Oleg Proskurin 2025-10-04 01:20:51 +07:00
parent f0e2fcdaa6
commit ea680f4c5e
3 changed files with 513 additions and 4 deletions

View File

@ -1,5 +1,5 @@
{
"name": "landing",
"name": "@banatie/landing",
"version": "0.1.0",
"private": true,
"scripts": {

View File

@ -0,0 +1,509 @@
'use client';
import { useState, useRef, KeyboardEvent } from 'react';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
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;
}
interface ApiKeyInfo {
organizationName?: string;
projectName?: 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 [generationError, setGenerationError] = useState('');
// Results State
const [results, setResults] = useState<GenerationResult[]>([]);
// Modal State
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 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) {
setApiKeyValidated(true);
// Try to extract org/project info if available
// For now, we'll set placeholder - this would come from API response
setApiKeyInfo({
organizationName: 'Your Organization',
projectName: 'Your Project',
});
} 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);
}
};
// Generate Images
const generateImages = async () => {
if (!prompt.trim()) {
setGenerationError('Please enter a prompt');
return;
}
setGenerating(true);
setGenerationError('');
const resultId = Date.now().toString();
const timestamp = new Date();
try {
// Call API twice in parallel (both identical for now)
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`,
}),
}),
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`,
}),
}),
]);
const leftData = await leftResult.json();
const rightData = await rightResult.json();
// 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,
};
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);
}
};
// 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);
}
};
return (
<div className="relative z-10 max-w-7xl mx-auto px-6 py-16 min-h-screen">
{/* Page Header */}
<div className="mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Text-to-Image Demo
</h1>
<p className="text-gray-400 text-lg">
Generate AI images with automatic prompt enhancement
</p>
</div>
{/* API Key Section */}
<div className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-xl font-semibold text-white mb-4">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)}
placeholder="Enter your API key"
disabled={apiKeyValidated}
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 pr-12"
/>
<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"
>
{apiKeyVisible ? '👁️' : '👁️‍🗨️'}
</button>
</div>
{!apiKeyValidated && (
<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"
>
{validatingKey ? 'Validating...' : 'Validate'}
</button>
)}
</div>
{apiKeyError && (
<p className="mt-3 text-sm text-red-400">{apiKeyError}</p>
)}
{apiKeyValidated && apiKeyInfo && (
<div className="mt-3 text-sm text-green-400">
Validated {apiKeyInfo.organizationName} / {apiKeyInfo.projectName}
</div>
)}
</div>
{/* Prompt Input Section */}
<div className="mb-12 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
<h2 className="text-xl font-semibold text-white mb-4">Your Prompt</h2>
<textarea
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"
/>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">Press Ctrl+Enter to submit</p>
<button
onClick={generateImages}
disabled={!apiKeyValidated || generating || !prompt.trim()}
className="px-8 py-3 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"
>
{generating ? 'Generating...' : 'Generate Images'}
</button>
</div>
{generationError && (
<p className="mt-3 text-sm text-red-400">{generationError}</p>
)}
</div>
{/* Results Section */}
{results.length > 0 && (
<div className="space-y-8">
<h2 className="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}
/>
))}
</div>
)}
{/* Zoom Modal */}
{zoomedImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setZoomedImage(null)}
>
<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"
>
</button>
<img
src={zoomedImage}
alt="Zoomed"
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
// Result Card Component
function ResultCard({
result,
apiKey,
onZoom,
onCopy,
onDownload,
}: {
result: GenerationResult;
apiKey: string;
onZoom: (url: string) => void;
onCopy: (text: string) => void;
onDownload: (url: string, filename: string) => void;
}) {
const [activeTab, setActiveTab] = useState<'curl' | 'fetch'>('curl');
const curlCode = `curl -X POST ${API_BASE_URL}/api/text-to-image \\
-H "Content-Type: application/json" \\
-H "X-API-Key: ${apiKey}" \\
-d '{
"prompt": "${result.originalPrompt.replace(/"/g, '\\"')}",
"filename": "generated_image"
}'`;
const fetchCode = `fetch('${API_BASE_URL}/api/text-to-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': '${apiKey}'
},
body: JSON.stringify({
prompt: '${result.originalPrompt.replace(/'/g, "\\'")}',
filename: 'generated_image'
})
})
.then(res => res.json())
.then(data => console.log(data));`;
return (
<div className="p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl animate-fade-in">
{/* Timestamp */}
<div className="mb-4 text-sm text-gray-500">
{result.timestamp.toLocaleString()}
</div>
{/* Horizontal Scrollable Image Comparison */}
<div className="mb-6 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-800">
<div className="flex gap-4 pb-4">
{/* Left Image */}
<div className="flex-shrink-0">
<div className="mb-2 text-sm font-medium text-gray-400">
Original Prompt
</div>
{result.leftImage?.error ? (
<div className="h-96 w-96 flex items-center justify-center bg-red-900/20 border border-red-700 rounded-lg">
<div className="text-center p-4">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-red-400">
{result.leftImage.error}
</div>
</div>
</div>
) : (
result.leftImage && (
<div className="relative group cursor-pointer">
<img
src={result.leftImage.url}
alt="Original"
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
onClick={() => onZoom(result.leftImage!.url)}
/>
<button
onClick={(e) => {
e.stopPropagation();
onDownload(result.leftImage!.url, 'original.png');
}}
className="absolute top-2 right-2 px-3 py-1.5 bg-black/70 hover:bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
Download
</button>
</div>
)
)}
<div className="mt-2 text-sm text-gray-300 max-w-sm">
{result.originalPrompt}
</div>
</div>
{/* Right Image */}
<div className="flex-shrink-0">
<div className="mb-2 text-sm font-medium text-gray-400">
Enhanced Prompt
</div>
{result.rightImage?.error ? (
<div className="h-96 w-96 flex items-center justify-center bg-red-900/20 border border-red-700 rounded-lg">
<div className="text-center p-4">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-red-400">
{result.rightImage.error}
</div>
</div>
</div>
) : (
result.rightImage && (
<div className="relative group cursor-pointer">
<img
src={result.rightImage.url}
alt="Enhanced"
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
onClick={() => onZoom(result.rightImage!.url)}
/>
<button
onClick={(e) => {
e.stopPropagation();
onDownload(result.rightImage!.url, 'enhanced.png');
}}
className="absolute top-2 right-2 px-3 py-1.5 bg-black/70 hover:bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
Download
</button>
</div>
)
)}
<div className="mt-2 text-sm text-gray-300 max-w-sm">
{result.enhancedPrompt || result.originalPrompt}
</div>
</div>
</div>
</div>
{/* API Code Examples */}
<div className="bg-slate-950/50 rounded-xl border border-slate-700 overflow-hidden">
<div className="flex items-center gap-2 bg-slate-900/50 px-4 py-2 border-b border-slate-700">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/50"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500/50"></div>
<div className="w-3 h-3 rounded-full bg-green-500/50"></div>
</div>
<div className="flex-1 flex gap-2 ml-4">
<button
onClick={() => setActiveTab('curl')}
className={`px-3 py-1 text-xs rounded transition-colors ${
activeTab === 'curl'
? 'bg-slate-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
cURL
</button>
<button
onClick={() => setActiveTab('fetch')}
className={`px-3 py-1 text-xs rounded transition-colors ${
activeTab === 'fetch'
? 'bg-slate-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
JS Fetch
</button>
</div>
<button
onClick={() => onCopy(activeTab === 'curl' ? curlCode : fetchCode)}
className="px-3 py-1 text-xs bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 rounded transition-colors"
>
Copy
</button>
</div>
<pre className="p-4 text-xs md:text-sm text-gray-300 overflow-x-auto">
<code>{activeTab === 'curl' ? curlCode : fetchCode}</code>
</pre>
</div>
</div>
);
}

View File

@ -59,11 +59,11 @@ export default function RootLayout({
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className={`${inter.variable} antialiased`}>
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950">
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
{/* Animated gradient background */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/30 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/30 rounded-full blur-3xl animate-pulse delay-700"></div>
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700"></div>
</div>
{/* Header */}