feat: improve options panel
This commit is contained in:
parent
36e5b910e9
commit
0c0907ef7e
|
|
@ -4,7 +4,7 @@ import { useState, useRef, KeyboardEvent } from 'react';
|
||||||
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
||||||
import { ResultCard } from '@/components/demo/ResultCard';
|
import { ResultCard } from '@/components/demo/ResultCard';
|
||||||
import { EnhancementOptions, EnhancementOptionsData } from '@/components/demo/EnhancementOptions';
|
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
|
@ -36,7 +36,10 @@ interface GenerationResult {
|
||||||
response: object;
|
response: object;
|
||||||
geminiParams: object;
|
geminiParams: object;
|
||||||
};
|
};
|
||||||
enhancementOptions?: EnhancementOptionsData;
|
enhancementOptions?: {
|
||||||
|
imageStyle?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
} & AdvancedOptionsData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKeyInfo {
|
interface ApiKeyInfo {
|
||||||
|
|
@ -60,7 +63,10 @@ export default function DemoTTIPage() {
|
||||||
const [generationError, setGenerationError] = useState('');
|
const [generationError, setGenerationError] = useState('');
|
||||||
|
|
||||||
// Enhancement Options State
|
// Enhancement Options State
|
||||||
const [enhancementOptions, setEnhancementOptions] = useState<EnhancementOptionsData>({});
|
const [aspectRatio, setAspectRatio] = useState('');
|
||||||
|
const [imageStyle, setImageStyle] = useState('');
|
||||||
|
const [advancedOptions, setAdvancedOptions] = useState<AdvancedOptionsData>({});
|
||||||
|
const [showAdvancedModal, setShowAdvancedModal] = useState(false);
|
||||||
|
|
||||||
// Results State
|
// Results State
|
||||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||||
|
|
@ -142,26 +148,23 @@ export default function DemoTTIPage() {
|
||||||
try {
|
try {
|
||||||
// Build enhancement options for right image (only non-empty values)
|
// Build enhancement options for right image (only non-empty values)
|
||||||
const rightEnhancementOptions: any = {};
|
const rightEnhancementOptions: any = {};
|
||||||
if (enhancementOptions.autoEnhance) {
|
if (imageStyle) {
|
||||||
rightEnhancementOptions.autoEnhance = true;
|
rightEnhancementOptions.imageStyle = imageStyle;
|
||||||
}
|
}
|
||||||
if (enhancementOptions.imageStyle) {
|
if (aspectRatio) {
|
||||||
rightEnhancementOptions.imageStyle = enhancementOptions.imageStyle;
|
rightEnhancementOptions.aspectRatio = aspectRatio;
|
||||||
}
|
}
|
||||||
if (enhancementOptions.aspectRatio) {
|
if (advancedOptions.mood) {
|
||||||
rightEnhancementOptions.aspectRatio = enhancementOptions.aspectRatio;
|
rightEnhancementOptions.mood = advancedOptions.mood;
|
||||||
}
|
}
|
||||||
if (enhancementOptions.mood) {
|
if (advancedOptions.lighting) {
|
||||||
rightEnhancementOptions.mood = enhancementOptions.mood;
|
rightEnhancementOptions.lighting = advancedOptions.lighting;
|
||||||
}
|
}
|
||||||
if (enhancementOptions.lighting) {
|
if (advancedOptions.cameraAngle) {
|
||||||
rightEnhancementOptions.lighting = enhancementOptions.lighting;
|
rightEnhancementOptions.cameraAngle = advancedOptions.cameraAngle;
|
||||||
}
|
}
|
||||||
if (enhancementOptions.cameraAngle) {
|
if (advancedOptions.negativePrompts && advancedOptions.negativePrompts.length > 0) {
|
||||||
rightEnhancementOptions.cameraAngle = enhancementOptions.cameraAngle;
|
rightEnhancementOptions.negativePrompts = advancedOptions.negativePrompts;
|
||||||
}
|
|
||||||
if (enhancementOptions.negativePrompts && enhancementOptions.negativePrompts.length > 0) {
|
|
||||||
rightEnhancementOptions.negativePrompts = enhancementOptions.negativePrompts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasEnhancementOptions = Object.keys(rightEnhancementOptions).length > 0;
|
const hasEnhancementOptions = Object.keys(rightEnhancementOptions).length > 0;
|
||||||
|
|
@ -247,7 +250,11 @@ export default function DemoTTIPage() {
|
||||||
geminiParams: rightData.data?.geminiParams || {},
|
geminiParams: rightData.data?.geminiParams || {},
|
||||||
},
|
},
|
||||||
// Store enhancement options for display in inspect mode
|
// Store enhancement options for display in inspect mode
|
||||||
enhancementOptions: hasEnhancementOptions ? enhancementOptions : undefined,
|
enhancementOptions: hasEnhancementOptions ? {
|
||||||
|
imageStyle,
|
||||||
|
aspectRatio,
|
||||||
|
...advancedOptions,
|
||||||
|
} : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!leftData.success) {
|
if (!leftData.success) {
|
||||||
|
|
@ -387,34 +394,101 @@ export default function DemoTTIPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prompt Input Section */}
|
{/* Unified Prompt & Generation Card */}
|
||||||
<section className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Prompt Input">
|
<section className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Image Generation">
|
||||||
<h2 className="text-lg font-semibold text-white mb-3">Your Prompt</h2>
|
{/* Prompt Textarea */}
|
||||||
<textarea
|
<div className="mb-4">
|
||||||
ref={textareaRef}
|
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
|
||||||
value={prompt}
|
Your Prompt
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
</label>
|
||||||
onKeyDown={handleKeyDown}
|
<textarea
|
||||||
placeholder="Describe the image you want to generate..."
|
id="prompt-input"
|
||||||
disabled={!apiKeyValidated || generating}
|
ref={textareaRef}
|
||||||
rows={4}
|
value={prompt}
|
||||||
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"
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
aria-label="Image generation prompt"
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
placeholder="Describe the image you want to generate..."
|
||||||
</section>
|
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 Section */}
|
{/* Enhancement Options Row */}
|
||||||
<section className="mb-6" aria-label="Enhancement Options">
|
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
|
||||||
<EnhancementOptions
|
{/* Aspect Ratio */}
|
||||||
value={enhancementOptions}
|
<div className="flex-1 min-w-[150px]">
|
||||||
onChange={setEnhancementOptions}
|
<label htmlFor="aspect-ratio" className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||||
disabled={!apiKeyValidated || generating}
|
Aspect Ratio
|
||||||
/>
|
</label>
|
||||||
</section>
|
<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="">Auto</option>
|
||||||
|
<option value="square">Square (1:1)</option>
|
||||||
|
<option value="portrait">Portrait (3:4)</option>
|
||||||
|
<option value="landscape">Landscape (4:3)</option>
|
||||||
|
<option value="wide">Wide (16:9)</option>
|
||||||
|
<option value="ultrawide">Ultrawide (21:9)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Generate Button Section */}
|
{/* Image Style */}
|
||||||
<section className="mb-8 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Generation Controls">
|
<div className="flex-1 min-w-[150px]">
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<label htmlFor="image-style" className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||||
|
Image Style
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="image-style"
|
||||||
|
value={imageStyle}
|
||||||
|
onChange={(e) => setImageStyle(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="">Auto</option>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Options Button */}
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 md:invisible">
|
||||||
|
Advanced
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdvancedModal(true)}
|
||||||
|
disabled={!apiKeyValidated || generating}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white hover:bg-slate-750 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 flex items-center justify-center gap-2"
|
||||||
|
aria-label="Open advanced options"
|
||||||
|
>
|
||||||
|
<span>⚙️</span>
|
||||||
|
<span>Advanced</span>
|
||||||
|
{(advancedOptions.mood || advancedOptions.lighting || advancedOptions.cameraAngle || (advancedOptions.negativePrompts && advancedOptions.negativePrompts.length > 0)) && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-xs bg-amber-600/20 text-amber-400 rounded-full">
|
||||||
|
{[
|
||||||
|
advancedOptions.mood,
|
||||||
|
advancedOptions.lighting,
|
||||||
|
advancedOptions.cameraAngle,
|
||||||
|
advancedOptions.negativePrompts && advancedOptions.negativePrompts.length > 0 ? 'prompts' : null
|
||||||
|
].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</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">
|
<div className="text-sm text-gray-500">
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<GenerationTimer isGenerating={generating} startTime={generationStartTime} />
|
<GenerationTimer isGenerating={generating} startTime={generationStartTime} />
|
||||||
|
|
@ -438,6 +512,15 @@ export default function DemoTTIPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Advanced Options Modal */}
|
||||||
|
<AdvancedOptionsModal
|
||||||
|
isOpen={showAdvancedModal}
|
||||||
|
onClose={() => setShowAdvancedModal(false)}
|
||||||
|
value={advancedOptions}
|
||||||
|
onChange={setAdvancedOptions}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<section className="space-y-6" aria-label="Generated Results">
|
<section className="space-y-6" aria-label="Generated Results">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface AdvancedOptionsData {
|
||||||
|
mood?: string;
|
||||||
|
lighting?: string;
|
||||||
|
cameraAngle?: string;
|
||||||
|
negativePrompts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvancedOptionsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
value: AdvancedOptionsData;
|
||||||
|
onChange: (options: AdvancedOptionsData) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedOptionsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: AdvancedOptionsModalProps) {
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const updateValue = (key: keyof AdvancedOptionsData, newValue: any) => {
|
||||||
|
onChange({ ...value, [key]: newValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNegativePromptsChange = (text: string) => {
|
||||||
|
const prompts = text
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0)
|
||||||
|
.slice(0, 10); // Max 10 items
|
||||||
|
updateValue('negativePrompts', prompts.length > 0 ? prompts : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onChange({
|
||||||
|
mood: undefined,
|
||||||
|
lighting: undefined,
|
||||||
|
cameraAngle: undefined,
|
||||||
|
negativePrompts: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyOptions =
|
||||||
|
value.mood ||
|
||||||
|
value.lighting ||
|
||||||
|
value.cameraAngle ||
|
||||||
|
(value.negativePrompts && value.negativePrompts.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="advanced-options-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-slate-900/95 backdrop-blur-sm border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
||||||
|
<h2 id="advanced-options-title" className="text-xl font-semibold text-white">
|
||||||
|
Advanced Options
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
aria-label="Close advanced options"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
{/* Mood */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="mood" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Mood
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mood"
|
||||||
|
value={value.mood || ''}
|
||||||
|
onChange={(e) => updateValue('mood', e.target.value || undefined)}
|
||||||
|
placeholder="e.g., peaceful, energetic, mysterious..."
|
||||||
|
maxLength={100}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full px-4 py-2.5 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"
|
||||||
|
aria-describedby="mood-hint"
|
||||||
|
/>
|
||||||
|
<p id="mood-hint" className="text-xs text-gray-500 mt-1">
|
||||||
|
Max 100 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lighting */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lighting" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Lighting
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lighting"
|
||||||
|
value={value.lighting || ''}
|
||||||
|
onChange={(e) => updateValue('lighting', e.target.value || undefined)}
|
||||||
|
placeholder="e.g., golden hour, dramatic shadows, soft natural light..."
|
||||||
|
maxLength={100}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full px-4 py-2.5 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"
|
||||||
|
aria-describedby="lighting-hint"
|
||||||
|
/>
|
||||||
|
<p id="lighting-hint" className="text-xs text-gray-500 mt-1">
|
||||||
|
Max 100 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Angle */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="camera-angle" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Camera Angle
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="camera-angle"
|
||||||
|
value={value.cameraAngle || ''}
|
||||||
|
onChange={(e) => updateValue('cameraAngle', e.target.value || undefined)}
|
||||||
|
placeholder="e.g., wide shot, close-up, bird's eye view..."
|
||||||
|
maxLength={100}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full px-4 py-2.5 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"
|
||||||
|
aria-describedby="camera-angle-hint"
|
||||||
|
/>
|
||||||
|
<p id="camera-angle-hint" className="text-xs text-gray-500 mt-1">
|
||||||
|
Max 100 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Negative Prompts */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="negative-prompts" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Negative Prompts
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="negative-prompts"
|
||||||
|
value={value.negativePrompts?.join(', ') || ''}
|
||||||
|
onChange={(e) => handleNegativePromptsChange(e.target.value)}
|
||||||
|
placeholder="e.g., blurry, low quality, distorted..."
|
||||||
|
disabled={disabled}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 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-describedby="negative-prompts-hint"
|
||||||
|
/>
|
||||||
|
<p id="negative-prompts-hint" className="text-xs text-gray-500 mt-1">
|
||||||
|
Comma-separated list (max 10 items, 100 chars each)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="sticky bottom-0 bg-slate-900/95 backdrop-blur-sm border-t border-slate-700 px-6 py-4 flex items-center justify-between gap-4">
|
||||||
|
{hasAnyOptions ? (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2.5 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export interface EnhancementOptionsData {
|
|
||||||
imageStyle?: string;
|
|
||||||
aspectRatio?: string;
|
|
||||||
mood?: string;
|
|
||||||
lighting?: string;
|
|
||||||
cameraAngle?: string;
|
|
||||||
negativePrompts?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnhancementOptionsProps {
|
|
||||||
value: EnhancementOptionsData;
|
|
||||||
onChange: (options: EnhancementOptionsData) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMAGE_STYLES = [
|
|
||||||
{ value: '', label: 'Select style...' },
|
|
||||||
{ value: 'photorealistic', label: 'Photorealistic' },
|
|
||||||
{ value: 'illustration', label: 'Illustration' },
|
|
||||||
{ value: 'minimalist', label: 'Minimalist' },
|
|
||||||
{ value: 'sticker', label: 'Sticker' },
|
|
||||||
{ value: 'product', label: 'Product' },
|
|
||||||
{ value: 'comic', label: 'Comic' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ASPECT_RATIOS = [
|
|
||||||
{ value: '', label: 'Select ratio...' },
|
|
||||||
{ value: 'square', label: 'Square (1:1)' },
|
|
||||||
{ value: 'portrait', label: 'Portrait (3:4)' },
|
|
||||||
{ value: 'landscape', label: 'Landscape (4:3)' },
|
|
||||||
{ value: 'wide', label: 'Wide (16:9)' },
|
|
||||||
{ value: 'ultrawide', label: 'Ultrawide (21:9)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EnhancementOptions({ value, onChange, disabled = false }: EnhancementOptionsProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const updateValue = (key: keyof EnhancementOptionsData, newValue: any) => {
|
|
||||||
onChange({ ...value, [key]: newValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNegativePromptsChange = (text: string) => {
|
|
||||||
const prompts = text
|
|
||||||
.split(',')
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter((p) => p.length > 0);
|
|
||||||
updateValue('negativePrompts', prompts.length > 0 ? prompts : undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onChange({
|
|
||||||
imageStyle: undefined,
|
|
||||||
aspectRatio: undefined,
|
|
||||||
mood: undefined,
|
|
||||||
lighting: undefined,
|
|
||||||
cameraAngle: undefined,
|
|
||||||
negativePrompts: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasAnyOptions =
|
|
||||||
value.imageStyle ||
|
|
||||||
value.aspectRatio ||
|
|
||||||
value.mood ||
|
|
||||||
value.lighting ||
|
|
||||||
value.cameraAngle ||
|
|
||||||
(value.negativePrompts && value.negativePrompts.length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
|
|
||||||
{/* Header */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full flex items-center justify-between group disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 rounded-lg"
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls="enhancement-options-content"
|
|
||||||
aria-label={isExpanded ? 'Collapse enhancement options' : 'Expand enhancement options'}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">
|
|
||||||
Enhancement Options
|
|
||||||
<span className="text-gray-500 text-sm font-normal ml-2">
|
|
||||||
(Always enhances second image)
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
{hasAnyOptions && !isExpanded && (
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-amber-600/20 text-amber-400 rounded-full">
|
|
||||||
{Object.keys(value).filter(k => value[k as keyof EnhancementOptionsData]).length} active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className={`w-5 h-5 text-gray-400 transition-transform ${
|
|
||||||
isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expandable Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
id="enhancement-options-content"
|
|
||||||
className="mt-5 space-y-4 animate-fade-in"
|
|
||||||
role="region"
|
|
||||||
aria-label="Enhancement options form"
|
|
||||||
>
|
|
||||||
|
|
||||||
{/* Dropdowns Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Image Style */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="image-style" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Image Style
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="image-style"
|
|
||||||
value={value.imageStyle || ''}
|
|
||||||
onChange={(e) => updateValue('imageStyle', e.target.value || undefined)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
>
|
|
||||||
{IMAGE_STYLES.map((style) => (
|
|
||||||
<option key={style.value} value={style.value}>
|
|
||||||
{style.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aspect Ratio */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="aspect-ratio" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Aspect Ratio
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="aspect-ratio"
|
|
||||||
value={value.aspectRatio || ''}
|
|
||||||
onChange={(e) => updateValue('aspectRatio', e.target.value || undefined)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
>
|
|
||||||
{ASPECT_RATIOS.map((ratio) => (
|
|
||||||
<option key={ratio.value} value={ratio.value}>
|
|
||||||
{ratio.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text Input Fields */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Mood */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="mood" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Mood
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="mood"
|
|
||||||
value={value.mood || ''}
|
|
||||||
onChange={(e) => updateValue('mood', e.target.value || undefined)}
|
|
||||||
placeholder="e.g., peaceful, energetic, mysterious..."
|
|
||||||
maxLength={100}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
aria-describedby="mood-hint"
|
|
||||||
/>
|
|
||||||
<p id="mood-hint" className="text-xs text-gray-500 mt-1">
|
|
||||||
Max 100 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lighting */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="lighting" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Lighting
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="lighting"
|
|
||||||
value={value.lighting || ''}
|
|
||||||
onChange={(e) => updateValue('lighting', e.target.value || undefined)}
|
|
||||||
placeholder="e.g., golden hour, dramatic shadows, soft natural light..."
|
|
||||||
maxLength={100}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
aria-describedby="lighting-hint"
|
|
||||||
/>
|
|
||||||
<p id="lighting-hint" className="text-xs text-gray-500 mt-1">
|
|
||||||
Max 100 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Camera Angle */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="camera-angle" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Camera Angle
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="camera-angle"
|
|
||||||
value={value.cameraAngle || ''}
|
|
||||||
onChange={(e) => updateValue('cameraAngle', e.target.value || undefined)}
|
|
||||||
placeholder="e.g., wide shot, close-up, bird's eye view..."
|
|
||||||
maxLength={100}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
aria-describedby="camera-angle-hint"
|
|
||||||
/>
|
|
||||||
<p id="camera-angle-hint" className="text-xs text-gray-500 mt-1">
|
|
||||||
Max 100 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Negative Prompts */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="negative-prompts" className="block text-sm font-medium text-gray-400 mb-2">
|
|
||||||
Negative Prompts
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="negative-prompts"
|
|
||||||
value={value.negativePrompts?.join(', ') || ''}
|
|
||||||
onChange={(e) => handleNegativePromptsChange(e.target.value)}
|
|
||||||
placeholder="e.g., blurry, low quality, distorted..."
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full px-4 py-2.5 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"
|
|
||||||
aria-describedby="negative-prompts-hint"
|
|
||||||
/>
|
|
||||||
<p id="negative-prompts-hint" className="text-xs text-gray-500 mt-1">
|
|
||||||
Comma-separated list (max 10 items, 100 chars each)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear All Button */}
|
|
||||||
{hasAnyOptions && (
|
|
||||||
<div className="pt-2 border-t border-slate-700">
|
|
||||||
<button
|
|
||||||
onClick={clearAll}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full md:w-auto px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
|
||||||
>
|
|
||||||
Clear All Options
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { CompletedTimerBadge } from './GenerationTimer';
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
interface EnhancementOptionsData {
|
interface EnhancementOptionsData {
|
||||||
autoEnhance: boolean;
|
|
||||||
imageStyle?: string;
|
imageStyle?: string;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
mood?: string;
|
mood?: string;
|
||||||
|
|
@ -76,7 +75,6 @@ export function ResultCard({
|
||||||
if (!result.enhancementOptions) return '';
|
if (!result.enhancementOptions) return '';
|
||||||
|
|
||||||
const opts: any = {};
|
const opts: any = {};
|
||||||
if (result.enhancementOptions.autoEnhance) opts.autoEnhance = true;
|
|
||||||
if (result.enhancementOptions.imageStyle) opts.imageStyle = result.enhancementOptions.imageStyle;
|
if (result.enhancementOptions.imageStyle) opts.imageStyle = result.enhancementOptions.imageStyle;
|
||||||
if (result.enhancementOptions.aspectRatio) opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
if (result.enhancementOptions.aspectRatio) opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
||||||
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ export { PromptReuseButton } from './PromptReuseButton';
|
||||||
export { GenerationTimer, CompletedTimerBadge } from './GenerationTimer';
|
export { GenerationTimer, CompletedTimerBadge } from './GenerationTimer';
|
||||||
export { InspectMode } from './InspectMode';
|
export { InspectMode } from './InspectMode';
|
||||||
export { ResultCard } from './ResultCard';
|
export { ResultCard } from './ResultCard';
|
||||||
|
export { AdvancedOptionsModal } from './AdvancedOptionsModal';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue