feat(identity-generator): ✨ Add GenerationPreview, IdentitySelector, and SceneBuilder components with new styling and entry point exports
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
241c1c3e80
commit
60ac1bb2a8
5 changed files with 0 additions and 1236 deletions
|
|
@ -1,195 +0,0 @@
|
|||
/**
|
||||
* Generation Preview Component
|
||||
*
|
||||
* Displays generated image results with verification score.
|
||||
*/
|
||||
|
||||
import type { IdentityGenerationResult } from '../hooks/useIdentityGeneration';
|
||||
import {
|
||||
PreviewContainer,
|
||||
PreviewImageWrapper,
|
||||
PreviewImage,
|
||||
PreviewPlaceholder,
|
||||
GeneratingOverlay,
|
||||
GeneratingText,
|
||||
GeneratingProgress,
|
||||
VerificationBadge,
|
||||
ResultStats,
|
||||
ResultStat,
|
||||
ResultStatLabel,
|
||||
ResultStatValue,
|
||||
ResultActions,
|
||||
ActionButton,
|
||||
SectionHeader,
|
||||
ErrorMessage,
|
||||
} from './styles';
|
||||
|
||||
export interface GenerationPreviewProps {
|
||||
/** Last generation result */
|
||||
result: IdentityGenerationResult | null;
|
||||
/** Whether generation is in progress */
|
||||
isGenerating: boolean;
|
||||
/** Generation error */
|
||||
error?: Error | null;
|
||||
/** Called when regenerate is clicked */
|
||||
onRegenerate?: () => void;
|
||||
/** Called when save is clicked */
|
||||
onSave?: (result: IdentityGenerationResult) => void;
|
||||
/** Called when download is clicked */
|
||||
onDownload?: (result: IdentityGenerationResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert confidence level to human-readable text
|
||||
*/
|
||||
function formatConfidence(confidence: 'high' | 'medium' | 'low'): string {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'High confidence match';
|
||||
case 'medium':
|
||||
return 'Medium confidence';
|
||||
case 'low':
|
||||
return 'Low confidence';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download image from base64 data
|
||||
*/
|
||||
function downloadImage(base64: string, filename: string = 'generated-image.png') {
|
||||
const link = document.createElement('a');
|
||||
link.href = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export function GenerationPreview({
|
||||
result,
|
||||
isGenerating,
|
||||
error,
|
||||
onRegenerate,
|
||||
onSave,
|
||||
onDownload,
|
||||
}: GenerationPreviewProps) {
|
||||
const imageUrl = result?.imageBase64
|
||||
? result.imageBase64.startsWith('data:')
|
||||
? result.imageBase64
|
||||
: `data:image/png;base64,${result.imageBase64}`
|
||||
: null;
|
||||
|
||||
const handleDownload = () => {
|
||||
if (result) {
|
||||
if (onDownload) {
|
||||
onDownload(result);
|
||||
} else {
|
||||
downloadImage(result.imageBase64, `identity-gen-${result.seed}.png`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>Generated Result</SectionHeader>
|
||||
|
||||
<PreviewContainer>
|
||||
<PreviewImageWrapper>
|
||||
{imageUrl && !isGenerating && (
|
||||
<PreviewImage src={imageUrl} alt="Generated image" />
|
||||
)}
|
||||
{!imageUrl && !isGenerating && (
|
||||
<PreviewPlaceholder>
|
||||
Configure settings and click Generate to create an image
|
||||
</PreviewPlaceholder>
|
||||
)}
|
||||
{isGenerating && (
|
||||
<GeneratingOverlay>
|
||||
<GeneratingText>Generating your image...</GeneratingText>
|
||||
<GeneratingProgress />
|
||||
</GeneratingOverlay>
|
||||
)}
|
||||
</PreviewImageWrapper>
|
||||
|
||||
{error && (
|
||||
<ErrorMessage>
|
||||
Generation failed: {error.message}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
|
||||
{result && !isGenerating && (
|
||||
<>
|
||||
<VerificationBadge $score={result.identityMatchScore}>
|
||||
Identity Match: {(result.identityMatchScore * 100).toFixed(0)}%
|
||||
{result.identityMatchScore >= 0.8 && ' ✓'}
|
||||
</VerificationBadge>
|
||||
|
||||
<ResultStats>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Confidence</ResultStatLabel>
|
||||
<ResultStatValue>
|
||||
{formatConfidence(result.identityConfidence)}
|
||||
</ResultStatValue>
|
||||
</ResultStat>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Aesthetic Score</ResultStatLabel>
|
||||
<ResultStatValue>
|
||||
{(result.aestheticScore * 100).toFixed(0)}%
|
||||
</ResultStatValue>
|
||||
</ResultStat>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Seed</ResultStatLabel>
|
||||
<ResultStatValue>{result.seed}</ResultStatValue>
|
||||
</ResultStat>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Duration</ResultStatLabel>
|
||||
<ResultStatValue>
|
||||
{formatDuration(result.durationMs)}
|
||||
</ResultStatValue>
|
||||
</ResultStat>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Model</ResultStatLabel>
|
||||
<ResultStatValue>{result.model}</ResultStatValue>
|
||||
</ResultStat>
|
||||
<ResultStat>
|
||||
<ResultStatLabel>Size</ResultStatLabel>
|
||||
<ResultStatValue>
|
||||
{result.width}x{result.height}
|
||||
</ResultStatValue>
|
||||
</ResultStat>
|
||||
</ResultStats>
|
||||
|
||||
<ResultActions>
|
||||
{onRegenerate && (
|
||||
<ActionButton type="button" onClick={onRegenerate}>
|
||||
Regenerate
|
||||
</ActionButton>
|
||||
)}
|
||||
{onSave && (
|
||||
<ActionButton
|
||||
type="button"
|
||||
onClick={() => onSave(result)}
|
||||
>
|
||||
Save to Gallery
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton type="button" onClick={handleDownload}>
|
||||
Download
|
||||
</ActionButton>
|
||||
</ResultActions>
|
||||
</>
|
||||
)}
|
||||
</PreviewContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/**
|
||||
* Identity Selector Component
|
||||
*
|
||||
* Displays and allows selection of stored identities.
|
||||
*/
|
||||
|
||||
import { type Identity } from '../hooks/useIdentityGeneration';
|
||||
import {
|
||||
IdentityGrid,
|
||||
IdentityCard,
|
||||
IdentityAvatar,
|
||||
IdentityInfo,
|
||||
IdentityName,
|
||||
IdentityMeta,
|
||||
SelectedBadge,
|
||||
AddIdentityButton,
|
||||
SectionHeader,
|
||||
ErrorMessage,
|
||||
} from './styles';
|
||||
|
||||
export interface IdentitySelectorProps {
|
||||
/** Available identities */
|
||||
identities: Identity[];
|
||||
/** Currently selected identity ID */
|
||||
selectedId: string | null;
|
||||
/** Called when an identity is selected */
|
||||
onSelect: (identity: Identity) => void;
|
||||
/** Called when "Add New" is clicked */
|
||||
onAddNew?: () => void;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Error state */
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from a name for the avatar
|
||||
*/
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function IdentitySelector({
|
||||
identities,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAddNew,
|
||||
isLoading,
|
||||
error,
|
||||
}: IdentitySelectorProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>Select Identity</SectionHeader>
|
||||
<ErrorMessage>
|
||||
Failed to load identities: {error.message}
|
||||
</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>Select Identity</SectionHeader>
|
||||
|
||||
<IdentityGrid>
|
||||
{isLoading ? (
|
||||
<IdentityCard>
|
||||
<IdentityInfo>
|
||||
<IdentityMeta>Loading identities...</IdentityMeta>
|
||||
</IdentityInfo>
|
||||
</IdentityCard>
|
||||
) : identities.length === 0 ? (
|
||||
<IdentityCard>
|
||||
<IdentityInfo>
|
||||
<IdentityMeta>No identities found</IdentityMeta>
|
||||
</IdentityInfo>
|
||||
</IdentityCard>
|
||||
) : (
|
||||
identities.map((identity) => (
|
||||
<IdentityCard
|
||||
key={identity.id}
|
||||
$selected={identity.id === selectedId}
|
||||
onClick={() => onSelect(identity)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(identity);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IdentityAvatar>{getInitials(identity.name)}</IdentityAvatar>
|
||||
<IdentityInfo>
|
||||
<IdentityName>
|
||||
{identity.name}
|
||||
{identity.id === selectedId && (
|
||||
<SelectedBadge>Selected</SelectedBadge>
|
||||
)}
|
||||
</IdentityName>
|
||||
<IdentityMeta>
|
||||
{identity.imageCount} images | {formatDate(identity.createdAt)}
|
||||
</IdentityMeta>
|
||||
</IdentityInfo>
|
||||
</IdentityCard>
|
||||
))
|
||||
)}
|
||||
|
||||
{onAddNew && (
|
||||
<AddIdentityButton onClick={onAddNew} type="button">
|
||||
<span>+</span>
|
||||
<span>Add New Identity</span>
|
||||
</AddIdentityButton>
|
||||
)}
|
||||
</IdentityGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
/**
|
||||
* Scene Builder Component
|
||||
*
|
||||
* Configure generation parameters for identity-preserving image generation.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { IdentityGenerationParams } from '../hooks/useIdentityGeneration';
|
||||
import {
|
||||
FormSection,
|
||||
FormLabel,
|
||||
FormTextArea,
|
||||
FormInput,
|
||||
FormSelect,
|
||||
SliderContainer,
|
||||
SliderRow,
|
||||
Slider,
|
||||
SliderValue,
|
||||
AdvancedToggle,
|
||||
AdvancedSection,
|
||||
GenerateButton,
|
||||
SectionHeader,
|
||||
} from './styles';
|
||||
|
||||
export interface SceneBuilderProps {
|
||||
/** Called when generate is clicked */
|
||||
onGenerate: (params: Omit<IdentityGenerationParams, 'identityId'>) => void;
|
||||
/** Whether generation is in progress */
|
||||
isGenerating: boolean;
|
||||
/** Whether a valid identity is selected */
|
||||
hasIdentity: boolean;
|
||||
}
|
||||
|
||||
type PoseType = 'standing' | 'sitting' | 'walking' | 'running';
|
||||
|
||||
const POSE_OPTIONS: { value: PoseType | ''; label: string }[] = [
|
||||
{ value: '', label: 'Auto (no pose control)' },
|
||||
{ value: 'standing', label: 'Standing' },
|
||||
{ value: 'sitting', label: 'Sitting' },
|
||||
{ value: 'walking', label: 'Walking' },
|
||||
{ value: 'running', label: 'Running' },
|
||||
];
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: 'juggernaut-xi-v11', label: 'Juggernaut XI v11 (Recommended)' },
|
||||
{ value: 'realvisxl-v4', label: 'RealVisXL v4' },
|
||||
{ value: 'dreamshaper-xl', label: 'DreamShaper XL' },
|
||||
];
|
||||
|
||||
export function SceneBuilder({
|
||||
onGenerate,
|
||||
isGenerating,
|
||||
hasIdentity,
|
||||
}: SceneBuilderProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [negativePrompt, setNegativePrompt] = useState('');
|
||||
const [poseType, setPoseType] = useState<PoseType | ''>('');
|
||||
const [outfitDescription, setOutfitDescription] = useState('');
|
||||
const [identityStrength, setIdentityStrength] = useState(0.85);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Advanced options
|
||||
const [enableInstantid, setEnableInstantid] = useState(true);
|
||||
const [ipAdapterScale, setIpAdapterScale] = useState(0.6);
|
||||
const [model, setModel] = useState('juggernaut-xi-v11');
|
||||
|
||||
const handleGenerate = () => {
|
||||
const params: Omit<IdentityGenerationParams, 'identityId'> = {
|
||||
prompt,
|
||||
negativePrompt: negativePrompt || undefined,
|
||||
identityStrength,
|
||||
enableInstantid,
|
||||
ipAdapterScale,
|
||||
poseType: poseType || undefined,
|
||||
outfitDescription: outfitDescription || undefined,
|
||||
model,
|
||||
};
|
||||
onGenerate(params);
|
||||
};
|
||||
|
||||
const canGenerate = hasIdentity && prompt.trim().length > 0 && !isGenerating;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>Scene Description</SectionHeader>
|
||||
|
||||
<FormSection>
|
||||
<FormLabel htmlFor="prompt">
|
||||
Describe the scene, setting, and style
|
||||
</FormLabel>
|
||||
<FormTextArea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="professional photo on a beach at sunset, smiling, warm lighting, golden hour..."
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection style={{ marginTop: '1rem' }}>
|
||||
<FormLabel htmlFor="outfit">Outfit Description (optional)</FormLabel>
|
||||
<FormInput
|
||||
id="outfit"
|
||||
type="text"
|
||||
value={outfitDescription}
|
||||
onChange={(e) => setOutfitDescription(e.target.value)}
|
||||
placeholder="white summer dress, sandals"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection style={{ marginTop: '1rem' }}>
|
||||
<FormLabel htmlFor="pose">Pose</FormLabel>
|
||||
<FormSelect
|
||||
id="pose"
|
||||
value={poseType}
|
||||
onChange={(e) => setPoseType(e.target.value as PoseType | '')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{POSE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</FormSection>
|
||||
|
||||
<SliderContainer style={{ marginTop: '1.5rem' }}>
|
||||
<FormLabel>Identity Strength</FormLabel>
|
||||
<SliderRow>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={1.2}
|
||||
step={0.05}
|
||||
value={identityStrength}
|
||||
onChange={(e) => setIdentityStrength(parseFloat(e.target.value))}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<SliderValue>{identityStrength.toFixed(2)}</SliderValue>
|
||||
</SliderRow>
|
||||
</SliderContainer>
|
||||
|
||||
<AdvancedToggle
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
style={{ marginTop: '1rem' }}
|
||||
>
|
||||
<span>{showAdvanced ? '▼' : '▶'}</span>
|
||||
<span>Advanced Options</span>
|
||||
</AdvancedToggle>
|
||||
|
||||
<AdvancedSection $open={showAdvanced}>
|
||||
<FormSection>
|
||||
<FormLabel htmlFor="negative-prompt">Negative Prompt</FormLabel>
|
||||
<FormTextArea
|
||||
id="negative-prompt"
|
||||
value={negativePrompt}
|
||||
onChange={(e) => setNegativePrompt(e.target.value)}
|
||||
placeholder="blurry, low quality, distorted face, extra limbs..."
|
||||
style={{ minHeight: '60px' }}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<FormLabel htmlFor="model">Model</FormLabel>
|
||||
<FormSelect
|
||||
id="model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{MODEL_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<FormLabel>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableInstantid}
|
||||
onChange={(e) => setEnableInstantid(e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
Enable InstantID (enhanced identity fidelity)
|
||||
</label>
|
||||
</FormLabel>
|
||||
</FormSection>
|
||||
|
||||
<SliderContainer>
|
||||
<FormLabel>IP-Adapter Scale</FormLabel>
|
||||
<SliderRow>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={ipAdapterScale}
|
||||
onChange={(e) => setIpAdapterScale(parseFloat(e.target.value))}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<SliderValue>{ipAdapterScale.toFixed(2)}</SliderValue>
|
||||
</SliderRow>
|
||||
</SliderContainer>
|
||||
</AdvancedSection>
|
||||
|
||||
<GenerateButton
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={!canGenerate}
|
||||
style={{ marginTop: '1.5rem', width: '100%' }}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Image'}
|
||||
</GenerateButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
/**
|
||||
* Identity Generator Component
|
||||
*
|
||||
* Main component for identity-preserving image generation.
|
||||
* Integrates identity selection, scene configuration, and result preview.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
useIdentityGeneration,
|
||||
type Identity,
|
||||
type IdentityGenerationParams,
|
||||
type IdentityGenerationResult,
|
||||
type UseIdentityGenerationConfig,
|
||||
} from '../hooks/useIdentityGeneration';
|
||||
import { IdentitySelector } from './IdentitySelector';
|
||||
import { SceneBuilder } from './SceneBuilder';
|
||||
import { GenerationPreview } from './GenerationPreview';
|
||||
import {
|
||||
GeneratorContainer,
|
||||
LeftPanel,
|
||||
RightPanel,
|
||||
ErrorMessage,
|
||||
} from './styles';
|
||||
|
||||
export interface IdentityGeneratorProps extends UseIdentityGenerationConfig {
|
||||
/** Called when the modal is closed */
|
||||
onClose?: () => void;
|
||||
/** Called when an image is generated */
|
||||
onImageGenerated?: (result: IdentityGenerationResult) => void;
|
||||
/** Called when an image is saved to gallery */
|
||||
onSaveToGallery?: (result: IdentityGenerationResult) => void;
|
||||
/** Pre-select an identity by ID */
|
||||
defaultIdentityId?: string;
|
||||
}
|
||||
|
||||
export function IdentityGenerator({
|
||||
onClose,
|
||||
onImageGenerated,
|
||||
onSaveToGallery,
|
||||
defaultIdentityId,
|
||||
identityServiceUrl,
|
||||
pipelineServiceUrl,
|
||||
identitiesRefetchInterval,
|
||||
}: IdentityGeneratorProps) {
|
||||
const [selectedIdentity, setSelectedIdentity] = useState<Identity | null>(null);
|
||||
const [lastParams, setLastParams] = useState<Omit<
|
||||
IdentityGenerationParams,
|
||||
'identityId'
|
||||
> | null>(null);
|
||||
|
||||
const {
|
||||
identities,
|
||||
identitiesLoading,
|
||||
identitiesError,
|
||||
generate,
|
||||
isGenerating,
|
||||
generationError,
|
||||
lastResult,
|
||||
isHealthy,
|
||||
identityServiceHealthy,
|
||||
pipelineServiceHealthy,
|
||||
} = useIdentityGeneration({
|
||||
identityServiceUrl,
|
||||
pipelineServiceUrl,
|
||||
identitiesRefetchInterval,
|
||||
});
|
||||
|
||||
// Auto-select identity if defaultIdentityId is provided
|
||||
useState(() => {
|
||||
if (defaultIdentityId && identities.length > 0 && !selectedIdentity) {
|
||||
const identity = identities.find((i) => i.id === defaultIdentityId);
|
||||
if (identity) {
|
||||
setSelectedIdentity(identity);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectIdentity = useCallback((identity: Identity) => {
|
||||
setSelectedIdentity(identity);
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (params: Omit<IdentityGenerationParams, 'identityId'>) => {
|
||||
if (!selectedIdentity) return;
|
||||
|
||||
setLastParams(params);
|
||||
|
||||
try {
|
||||
const result = await generate({
|
||||
...params,
|
||||
identityId: selectedIdentity.id,
|
||||
});
|
||||
onImageGenerated?.(result);
|
||||
} catch (error) {
|
||||
console.error('Generation failed:', error);
|
||||
}
|
||||
},
|
||||
[selectedIdentity, generate, onImageGenerated],
|
||||
);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
if (lastParams) {
|
||||
handleGenerate({
|
||||
...lastParams,
|
||||
seed: undefined, // Use new random seed
|
||||
});
|
||||
}
|
||||
}, [lastParams, handleGenerate]);
|
||||
|
||||
const handleSaveToGallery = useCallback(
|
||||
(result: IdentityGenerationResult) => {
|
||||
onSaveToGallery?.(result);
|
||||
},
|
||||
[onSaveToGallery],
|
||||
);
|
||||
|
||||
// Show health status warnings
|
||||
const healthWarning =
|
||||
!isHealthy && !identitiesLoading ? (
|
||||
<ErrorMessage style={{ marginBottom: '1rem' }}>
|
||||
{!identityServiceHealthy && !pipelineServiceHealthy
|
||||
? 'Identity and Pipeline services are unavailable'
|
||||
: !identityServiceHealthy
|
||||
? 'Identity service is unavailable'
|
||||
: 'Pipeline service is unavailable'}
|
||||
</ErrorMessage>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{healthWarning}
|
||||
|
||||
<GeneratorContainer>
|
||||
<LeftPanel>
|
||||
<IdentitySelector
|
||||
identities={identities}
|
||||
selectedId={selectedIdentity?.id ?? null}
|
||||
onSelect={handleSelectIdentity}
|
||||
isLoading={identitiesLoading}
|
||||
error={identitiesError as Error | null}
|
||||
/>
|
||||
</LeftPanel>
|
||||
|
||||
<RightPanel>
|
||||
<SceneBuilder
|
||||
onGenerate={handleGenerate}
|
||||
isGenerating={isGenerating}
|
||||
hasIdentity={selectedIdentity !== null}
|
||||
/>
|
||||
|
||||
<GenerationPreview
|
||||
result={lastResult ?? null}
|
||||
isGenerating={isGenerating}
|
||||
error={generationError as Error | null}
|
||||
onRegenerate={lastParams ? handleRegenerate : undefined}
|
||||
onSave={onSaveToGallery ? handleSaveToGallery : undefined}
|
||||
/>
|
||||
</RightPanel>
|
||||
</GeneratorContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export sub-components for customization
|
||||
export { IdentitySelector } from './IdentitySelector';
|
||||
export type { IdentitySelectorProps } from './IdentitySelector';
|
||||
export { SceneBuilder } from './SceneBuilder';
|
||||
export type { SceneBuilderProps } from './SceneBuilder';
|
||||
export { GenerationPreview } from './GenerationPreview';
|
||||
export type { GenerationPreviewProps } from './GenerationPreview';
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
/**
|
||||
* Styled components for IdentityGenerator
|
||||
*
|
||||
* Follows the dark cyberpunk aesthetic with pink accents.
|
||||
*/
|
||||
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Layout Components
|
||||
// ============================================================================
|
||||
|
||||
export const GeneratorContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
min-height: 500px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LeftPanel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const RightPanel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Identity Selector Components
|
||||
// ============================================================================
|
||||
|
||||
export const IdentityGrid = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 105, 180, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const IdentityCard = styled.div<{ $selected?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid ${(p) => (p.$selected ? '#ff69b4' : 'transparent')};
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(p) =>
|
||||
p.$selected ? '#ff69b4' : 'rgba(255, 105, 180, 0.5)'};
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
${(p) =>
|
||||
p.$selected &&
|
||||
css`
|
||||
box-shadow: 0 0 20px rgba(255, 105, 180, 0.2);
|
||||
`}
|
||||
`;
|
||||
|
||||
export const IdentityAvatar = styled.div`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ff69b4, #9b59b6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const IdentityInfo = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export const IdentityName = styled.div`
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export const IdentityMeta = styled.div`
|
||||
color: #888;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.125rem;
|
||||
`;
|
||||
|
||||
export const SelectedBadge = styled.span`
|
||||
color: #22c55e;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
|
||||
export const AddIdentityButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 105, 180, 0.5);
|
||||
color: #ff69b4;
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Scene Builder Components
|
||||
// ============================================================================
|
||||
|
||||
export const FormSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
export const FormLabel = styled.label`
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
`;
|
||||
|
||||
export const FormInput = styled.input`
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff69b4;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FormTextArea = styled.textarea`
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff69b4;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FormSelect = styled.select`
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff69b4;
|
||||
}
|
||||
|
||||
option {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
`;
|
||||
|
||||
export const SliderRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const Slider = styled.input.attrs({ type: 'range' })`
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ff69b4, #9b59b6);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
export const SliderValue = styled.span`
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #ff69b4;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export const AdvancedToggle = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #ff69b4;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AdvancedSection = styled.div<{ $open?: boolean }>`
|
||||
display: ${(p) => (p.$open ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Generation Preview Components
|
||||
// ============================================================================
|
||||
|
||||
export const PreviewContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const PreviewImageWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
max-width: 512px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const PreviewImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
export const PreviewPlaceholder = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
export const GeneratingOverlay = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const GeneratingText = styled.div`
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
animation: ${pulse} 1.5s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
export const GeneratingProgress = styled.div`
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#ff69b4,
|
||||
transparent
|
||||
);
|
||||
animation: ${shimmer} 1.5s infinite;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Verification Badge
|
||||
// ============================================================================
|
||||
|
||||
export const VerificationBadge = styled.div<{ $score: number }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: ${(p) =>
|
||||
p.$score >= 0.8
|
||||
? 'rgba(34, 197, 94, 0.2)'
|
||||
: p.$score >= 0.6
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(239, 68, 68, 0.2)'};
|
||||
border: 1px solid
|
||||
${(p) =>
|
||||
p.$score >= 0.8
|
||||
? '#22c55e'
|
||||
: p.$score >= 0.6
|
||||
? '#eab308'
|
||||
: '#ef4444'};
|
||||
color: ${(p) =>
|
||||
p.$score >= 0.8 ? '#22c55e' : p.$score >= 0.6 ? '#eab308' : '#ef4444'};
|
||||
`;
|
||||
|
||||
export const ResultStats = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export const ResultStat = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
`;
|
||||
|
||||
export const ResultStatLabel = styled.span`
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
`;
|
||||
|
||||
export const ResultStatValue = styled.span`
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
export const ResultActions = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Buttons
|
||||
// ============================================================================
|
||||
|
||||
export const GenerateButton = styled.button<{ $disabled?: boolean }>`
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #ff69b4, #9b59b6);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(255, 105, 180, 0.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionButton = styled.button`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 105, 180, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Error Display
|
||||
// ============================================================================
|
||||
|
||||
export const ErrorMessage = styled.div`
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Section Headers
|
||||
// ============================================================================
|
||||
|
||||
export const SectionHeader = styled.h3`
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
`;
|
||||
Loading…
Add table
Reference in a new issue