461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { InspectMode } from './InspectMode';
|
||
import { PromptReuseButton } from './PromptReuseButton';
|
||
import { CompletedTimerBadge } from './GenerationTimer';
|
||
|
||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||
|
||
interface EnhancementOptionsData {
|
||
imageStyle?: string;
|
||
aspectRatio?: string;
|
||
mood?: string;
|
||
lighting?: string;
|
||
cameraAngle?: string;
|
||
negativePrompts?: string[];
|
||
}
|
||
|
||
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?: EnhancementOptionsData;
|
||
}
|
||
|
||
interface ResultCardProps {
|
||
result: GenerationResult;
|
||
apiKey: string;
|
||
onZoom: (url: string) => void;
|
||
onCopy: (text: string) => void;
|
||
onDownload: (url: string, filename: string) => void;
|
||
onReusePrompt: (prompt: string) => void;
|
||
}
|
||
|
||
type ViewMode = 'preview' | 'inspect';
|
||
type CodeTab = 'curl' | 'fetch' | 'rest';
|
||
|
||
export function ResultCard({
|
||
result,
|
||
apiKey,
|
||
onZoom,
|
||
onCopy,
|
||
onDownload,
|
||
onReusePrompt,
|
||
}: ResultCardProps) {
|
||
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
||
const [activeTab, setActiveTab] = useState<CodeTab>('curl');
|
||
|
||
// Build enhancement options JSON for code examples
|
||
const buildEnhancementOptionsJson = () => {
|
||
if (!result.enhancementOptions) return '';
|
||
|
||
const opts: any = {};
|
||
if (result.enhancementOptions.imageStyle)
|
||
opts.imageStyle = result.enhancementOptions.imageStyle;
|
||
if (result.enhancementOptions.aspectRatio)
|
||
opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
||
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
||
if (result.enhancementOptions.lighting) opts.lighting = result.enhancementOptions.lighting;
|
||
if (result.enhancementOptions.cameraAngle)
|
||
opts.cameraAngle = result.enhancementOptions.cameraAngle;
|
||
if (result.enhancementOptions.negativePrompts)
|
||
opts.negativePrompts = result.enhancementOptions.negativePrompts;
|
||
|
||
if (Object.keys(opts).length === 0) return '';
|
||
return JSON.stringify(opts, null, 2)
|
||
.split('\n')
|
||
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
||
.join('\n');
|
||
};
|
||
|
||
const enhancementOptionsJson = buildEnhancementOptionsJson();
|
||
const hasEnhancementOptions = !!result.enhancementOptions;
|
||
|
||
const curlCode = hasEnhancementOptions
|
||
? `# Right image (with enhancement options)
|
||
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",
|
||
"enhancementOptions": ${enhancementOptionsJson.replace(/\n/g, '\n ')}
|
||
}'`
|
||
: `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 = hasEnhancementOptions
|
||
? `// Right image (with enhancement options)
|
||
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',
|
||
enhancementOptions: ${enhancementOptionsJson}
|
||
})
|
||
})
|
||
.then(res => res.json())
|
||
.then(data => console.log(data));`
|
||
: `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));`;
|
||
|
||
const restCode = hasEnhancementOptions
|
||
? `### Right Image - With Enhancement Options
|
||
POST ${API_BASE_URL}/api/text-to-image
|
||
Content-Type: application/json
|
||
X-API-Key: ${apiKey}
|
||
|
||
{
|
||
"prompt": "${result.originalPrompt.replace(/"/g, '\\"')}",
|
||
"filename": "generated_image",
|
||
"enhancementOptions": ${enhancementOptionsJson}
|
||
}`
|
||
: `### Generate Image - Text to Image
|
||
POST ${API_BASE_URL}/api/text-to-image
|
||
Content-Type: application/json
|
||
X-API-Key: ${apiKey}
|
||
|
||
{
|
||
"prompt": "${result.originalPrompt.replace(/"/g, '\\"')}",
|
||
"filename": "generated_image"
|
||
}`;
|
||
|
||
const getCodeForTab = () => {
|
||
switch (activeTab) {
|
||
case 'curl':
|
||
return curlCode;
|
||
case 'fetch':
|
||
return fetchCode;
|
||
case 'rest':
|
||
return restCode;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl animate-fade-in">
|
||
{/* Header */}
|
||
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-gray-500">{result.timestamp.toLocaleString()}</span>
|
||
{result.durationMs && <CompletedTimerBadge durationMs={result.durationMs} />}
|
||
</div>
|
||
|
||
{/* View Mode Toggle */}
|
||
<div className="flex gap-1 p-1 bg-slate-950/50 border border-slate-700 rounded-lg">
|
||
<button
|
||
onClick={() => setViewMode('preview')}
|
||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||
viewMode === 'preview' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
||
}`}
|
||
aria-pressed={viewMode === 'preview'}
|
||
>
|
||
Preview
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('inspect')}
|
||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||
viewMode === 'inspect' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
||
}`}
|
||
aria-pressed={viewMode === 'inspect'}
|
||
>
|
||
Inspect
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{viewMode === 'preview' ? (
|
||
<>
|
||
{/* Image Comparison */}
|
||
<div className="mb-5 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-800">
|
||
<div className="flex gap-4 pb-4">
|
||
{/* Left Image */}
|
||
<ImagePreview
|
||
image={result.leftImage}
|
||
label="Original Prompt"
|
||
prompt={result.originalPrompt}
|
||
onZoom={onZoom}
|
||
onDownload={onDownload}
|
||
onReusePrompt={onReusePrompt}
|
||
filename="original.png"
|
||
/>
|
||
|
||
{/* Right Image */}
|
||
<ImagePreview
|
||
image={result.rightImage}
|
||
label="Enhanced Prompt"
|
||
prompt={result.enhancedPrompt || result.originalPrompt}
|
||
onZoom={onZoom}
|
||
onDownload={onDownload}
|
||
onReusePrompt={onReusePrompt}
|
||
filename="enhanced.png"
|
||
hasEnhancementOptions={!!result.enhancementOptions}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* API Code Examples */}
|
||
<CodeExamples
|
||
activeTab={activeTab}
|
||
setActiveTab={setActiveTab}
|
||
code={getCodeForTab()}
|
||
onCopy={onCopy}
|
||
/>
|
||
</>
|
||
) : (
|
||
<InspectMode
|
||
leftData={
|
||
result.leftData || {
|
||
request: { prompt: result.originalPrompt },
|
||
response: { success: true, url: result.leftImage?.url },
|
||
geminiParams: {},
|
||
}
|
||
}
|
||
rightData={
|
||
result.rightData || {
|
||
request: { prompt: result.enhancedPrompt || result.originalPrompt },
|
||
response: { success: true, url: result.rightImage?.url },
|
||
geminiParams: {},
|
||
}
|
||
}
|
||
onCopy={onCopy}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Image Preview Component
|
||
function ImagePreview({
|
||
image,
|
||
label,
|
||
prompt,
|
||
onZoom,
|
||
onDownload,
|
||
onReusePrompt,
|
||
filename,
|
||
hasEnhancementOptions = false,
|
||
}: {
|
||
image: GenerationResult['leftImage'];
|
||
label: string;
|
||
prompt: string;
|
||
onZoom: (url: string) => void;
|
||
onDownload: (url: string, filename: string) => void;
|
||
onReusePrompt: (prompt: string) => void;
|
||
filename: string;
|
||
hasEnhancementOptions?: boolean;
|
||
}) {
|
||
const [promptExpanded, setPromptExpanded] = useState(false);
|
||
const [urlCopied, setUrlCopied] = useState(false);
|
||
|
||
const copyImageUrl = () => {
|
||
if (image?.url) {
|
||
navigator.clipboard.writeText(image.url);
|
||
setUrlCopied(true);
|
||
setTimeout(() => setUrlCopied(false), 2000);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex-shrink-0">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-gray-400">{label}</span>
|
||
{hasEnhancementOptions && (
|
||
<span className="px-2 py-0.5 text-xs bg-amber-600/20 text-amber-400 rounded-full border border-amber-600/30">
|
||
+ Options
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{image?.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">{image.error}</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
image && (
|
||
<>
|
||
<div className="relative group cursor-pointer">
|
||
<img
|
||
src={image.url}
|
||
alt={label}
|
||
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
|
||
onClick={() => onZoom(image.url)}
|
||
/>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDownload(image.url, filename);
|
||
}}
|
||
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"
|
||
aria-label={`Download ${label}`}
|
||
>
|
||
Download
|
||
</button>
|
||
</div>
|
||
|
||
{/* Image URL with Copy Button */}
|
||
<div className="mt-2 flex items-center gap-2 max-w-sm">
|
||
<div className="flex-1 px-2 py-1 bg-slate-800/50 border border-slate-700 rounded text-xs text-gray-400 font-mono truncate">
|
||
{image.url}
|
||
</div>
|
||
<button
|
||
onClick={copyImageUrl}
|
||
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded text-xs text-gray-300 hover:text-white transition-colors whitespace-nowrap"
|
||
aria-label="Copy image URL"
|
||
>
|
||
{urlCopied ? '✓ Copied' : 'Copy URL'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{/* Prompt with Truncation */}
|
||
<div className="mt-2 flex items-start gap-2 max-w-sm">
|
||
<div className="flex-1">
|
||
<p
|
||
className={`text-sm text-gray-300 leading-relaxed ${
|
||
!promptExpanded ? 'line-clamp-4' : ''
|
||
}`}
|
||
>
|
||
{prompt}
|
||
</p>
|
||
{prompt.length > 150 && (
|
||
<button
|
||
onClick={() => setPromptExpanded(!promptExpanded)}
|
||
className="mt-1 text-xs text-amber-400 hover:text-amber-300 transition-colors"
|
||
>
|
||
{promptExpanded ? 'Show less' : 'Show more...'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<PromptReuseButton prompt={prompt} onReuse={onReusePrompt} label={label} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Code Examples Component
|
||
function CodeExamples({
|
||
activeTab,
|
||
setActiveTab,
|
||
code,
|
||
onCopy,
|
||
}: {
|
||
activeTab: CodeTab;
|
||
setActiveTab: (tab: CodeTab) => void;
|
||
code: string;
|
||
onCopy: (text: string) => void;
|
||
}) {
|
||
return (
|
||
<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">
|
||
<TabButton
|
||
active={activeTab === 'curl'}
|
||
onClick={() => setActiveTab('curl')}
|
||
label="cURL"
|
||
/>
|
||
<TabButton
|
||
active={activeTab === 'fetch'}
|
||
onClick={() => setActiveTab('fetch')}
|
||
label="JS Fetch"
|
||
/>
|
||
<TabButton
|
||
active={activeTab === 'rest'}
|
||
onClick={() => setActiveTab('rest')}
|
||
label="REST"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => onCopy(code)}
|
||
className="px-3 py-1 text-xs bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 rounded transition-colors"
|
||
aria-label="Copy code"
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
<pre className="p-4 text-xs md:text-sm text-gray-300 overflow-x-auto">
|
||
<code>{code}</code>
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TabButton({
|
||
active,
|
||
onClick,
|
||
label,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
label: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={`px-3 py-1 text-xs rounded transition-colors ${
|
||
active ? 'bg-slate-700 text-white' : 'text-gray-400 hover:text-white'
|
||
}`}
|
||
aria-pressed={active}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|