650 lines
22 KiB
TypeScript
650 lines
22 KiB
TypeScript
'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>
|
||
);
|
||
}
|