| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; |
| import { X, ChevronRight, ChevronLeft, Sparkles, Loader2, User, Palette, Camera, CheckCircle2, EyeOff, Shuffle, Zap, Layers, Check, ChevronDown, Star, Flame, RotateCcw, Search, } from 'lucide-react'; |
| import { DEFAULT_DRAFT, WIZARD_STEPS, SKIN_TONES, EYE_COLORS, EYE_SHAPES, FACE_PRESETS, HAIR_STYLES, HAIR_COLORS, OUTFIT_STYLES_SFW, OUTFIT_STYLES_NSFW, COLOR_PALETTE, ACCESSORIES, POSES, BACKGROUNDS, LIGHTING_OPTIONS, EXPRESSIONS, NSFW_POSES, NSFW_BACKGROUNDS, LINGERIE_GARMENTS, LINGERIE_STYLES, LINGERIE_FABRICS, LINGERIE_BRANDS, LINGERIE_EXTRAS, findPreset, } from './wizardTypes'; |
| import { PROFESSIONS, getProfession } from './professionRegistry'; |
| import { loadVibeTab, saveVibeTab } from '../vibeTabPersistence'; |
| import { useGenerateAvatars } from '../useGenerateAvatars'; |
| import { useAvatarGallery } from '../useAvatarGallery'; |
| import { loadAvatarSettings, resolveCheckpoint } from '../AvatarSettingsPanel'; |
| import { resolveFileUrl } from '../../resolveFileUrl'; |
| import { AVATAR_VIBE_PRESETS, CHARACTER_STYLE_PRESETS, GENDER_OPTIONS, } from '../galleryTypes'; |
| |
| |
| |
| function PillButton({ label, selected, onClick, icon, accent = 'purple' }) { |
| const colors = { |
| purple: selected ? 'bg-purple-500/15 text-purple-300 border-purple-500/30' : '', |
| rose: selected ? 'bg-rose-500/15 text-rose-300 border-rose-500/30' : '', |
| }; |
| return (<button onClick={onClick} role="option" aria-selected={selected} aria-label={label} className={[ |
| 'px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all border', |
| selected ? colors[accent] |
| : 'bg-white/[0.03] text-white/45 border-white/[0.06] hover:bg-white/[0.06] hover:text-white/65', |
| ].join(' ')}> |
| {icon && <span className="mr-1.5">{icon}</span>}{label} |
| </button>); |
| } |
| function ColorSwatch({ color, selected, onClick, label }) { |
| return (<button onClick={onClick} title={label} role="option" aria-selected={selected} aria-label={label || color} className={[ |
| 'w-7 h-7 rounded-full border-2 transition-all flex-shrink-0', |
| selected |
| ? 'border-purple-400 scale-110 shadow-[0_0_8px_rgba(168,85,247,0.4)]' |
| : 'border-white/10 hover:border-white/30 hover:scale-105', |
| ].join(' ')} style={{ backgroundColor: color }}/>); |
| } |
| function SliderField({ label, value, min, max, step, onChange, unit }) { |
| return (<div className="space-y-1.5"> |
| <div className="flex justify-between"> |
| <span className="text-[10px] text-white/40 font-medium">{label}</span> |
| <span className="text-[10px] text-white/25 tabular-nums">{value}{unit}</span> |
| </div> |
| <input type="range" min={min} max={max} step={step ?? 1} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-full accent-purple-500 h-1.5 rounded-full bg-white/[0.06]"/> |
| </div>); |
| } |
| function SectionLabel({ children }) { |
| return <div className="text-[10px] text-white/35 font-semibold uppercase tracking-wider mb-3">{children}</div>; |
| } |
| function AdvancedToggle({ open, onToggle }) { |
| return (<button onClick={onToggle} className="flex items-center gap-1.5 text-[10px] text-white/25 hover:text-white/45 font-medium uppercase tracking-wider transition-colors mt-4 mb-2"> |
| <ChevronRight size={10} className={`transition-transform ${open ? 'rotate-90' : ''}`}/> |
| Advanced |
| </button>); |
| } |
| |
| |
| |
| function isStepCustomized(step, draft) { |
| const d = DEFAULT_DRAFT; |
| switch (step) { |
| case 0: return draft.gender !== d.gender || draft.ageRange !== d.ageRange || draft.name !== d.name; |
| case 1: return draft.bodyType !== d.bodyType || draft.skinTone !== d.skinTone || draft.posture !== d.posture; |
| case 2: return draft.facePreset !== d.facePreset || draft.eyeColor !== d.eyeColor || draft.expression !== d.expression; |
| case 3: return draft.hairStyle !== d.hairStyle || draft.hairColor !== d.hairColor; |
| case 4: return draft.professionId !== d.professionId; |
| case 5: return draft.outfitStyle !== d.outfitStyle || draft.accessories.length > 0; |
| case 6: return draft.portraitType !== d.portraitType || draft.background !== d.background; |
| default: return false; |
| } |
| } |
| |
| |
| |
| function buildPrompt(d, nsfwEnabled) { |
| const parts = []; |
| |
| const framing = { |
| headshot: 'headshot portrait, face closeup', |
| half_body: 'upper body portrait, from waist up', |
| mid_body: 'mid-body portrait, from head to hips, upper body and hip area visible, frame ends at upper thighs', |
| full_body: 'full body portrait', |
| }; |
| parts.push(framing[d.portraitType] || framing.headshot); |
| |
| parts.push('single person, front-facing, looking at camera'); |
| |
| const genderWord = d.gender === 'neutral' ? 'androgynous person' : d.gender === 'female' ? 'woman' : 'man'; |
| const ageWord = d.ageRange === 'young_adult' ? 'young adult' : d.ageRange === 'mature' ? 'mature' : 'adult'; |
| parts.push(`${ageWord} ${genderWord}`); |
| |
| parts.push(`${d.bodyType} build`); |
| parts.push(`${d.posture} posture`); |
| |
| const skin = findPreset(SKIN_TONES, d.skinTone); |
| if (skin) |
| parts.push(skin.prompt); |
| |
| const face = findPreset(FACE_PRESETS, d.facePreset); |
| if (face) |
| parts.push(face.prompt); |
| |
| if (d.jawline >= 70) |
| parts.push('strong defined jawline'); |
| else if (d.jawline <= 30) |
| parts.push('soft rounded jawline'); |
| if (d.lips >= 70) |
| parts.push('full prominent lips'); |
| else if (d.lips <= 30) |
| parts.push('thin delicate lips'); |
| if (d.browDefinition >= 70) |
| parts.push('strong defined eyebrows'); |
| else if (d.browDefinition <= 30) |
| parts.push('soft subtle eyebrows'); |
| |
| const eyeC = findPreset(EYE_COLORS, d.eyeColor); |
| if (eyeC) |
| parts.push(eyeC.prompt); |
| const eyeS = findPreset(EYE_SHAPES, d.eyeShape); |
| if (eyeS) |
| parts.push(eyeS.prompt); |
| |
| const expr = findPreset(EXPRESSIONS, d.expression); |
| if (expr) |
| parts.push(expr.prompt); |
| |
| const hairS = findPreset(HAIR_STYLES, d.hairStyle); |
| if (hairS) |
| parts.push(hairS.prompt); |
| const hairC = findPreset(HAIR_COLORS, d.hairColor); |
| if (hairC) |
| parts.push(hairC.prompt); |
| |
| const outfit = findPreset([...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW], d.outfitStyle); |
| if (outfit) |
| parts.push(outfit.prompt); |
| |
| if (d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear') { |
| const activeGarments = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []); |
| const selections = d.lingerieSelections ?? {}; |
| for (const gId of activeGarments) { |
| const garment = LINGERIE_GARMENTS.find((g) => g.id === gId); |
| if (garment) |
| parts.push(garment.prompt); |
| const sel = selections[gId]; |
| if (sel?.style) { |
| const styles = LINGERIE_STYLES[gId]; |
| const style = styles?.find((s) => s.id === sel.style); |
| if (style) |
| parts.push(style.prompt); |
| } |
| if (sel?.fabric) { |
| const fabric = LINGERIE_FABRICS.find((f) => f.id === sel.fabric); |
| if (fabric) |
| parts.push(fabric.prompt); |
| } |
| if (sel?.brand) { |
| const brand = LINGERIE_BRANDS.find((b) => b.id === sel.brand); |
| if (brand) |
| parts.push(brand.prompt); |
| } |
| } |
| for (const extId of d.lingerieExtras ?? []) { |
| const ext = LINGERIE_EXTRAS.find((e) => e.id === extId); |
| if (ext) |
| parts.push(ext.prompt); |
| } |
| } |
| |
| const prof = getProfession(d.professionId); |
| if (prof && prof.id !== 'custom') |
| parts.push(prof.label); |
| |
| const primC = findPreset(COLOR_PALETTE, d.outfitPrimaryColor); |
| const secC = findPreset(COLOR_PALETTE, d.outfitSecondaryColor); |
| const isLingerie = d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear'; |
| const activeGarmentsForColor = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []); |
| const hasTopPiece = isLingerie && activeGarmentsForColor.includes('top'); |
| const hasBottomPiece = isLingerie && activeGarmentsForColor.includes('bottom'); |
| if (hasTopPiece && hasBottomPiece && primC && secC) { |
| |
| parts.push(`${primC.prompt} colored top`); |
| parts.push(`${secC.prompt} colored bottom`); |
| } |
| else if (hasTopPiece && hasBottomPiece && primC) { |
| parts.push(`${primC.prompt} colored top`); |
| } |
| else if (hasTopPiece && hasBottomPiece && secC) { |
| parts.push(`${secC.prompt} colored bottom`); |
| } |
| else { |
| |
| if (primC && secC) |
| parts.push(`${primC.prompt} and ${secC.prompt} color scheme`); |
| else if (primC) |
| parts.push(`${primC.prompt} colored outfit`); |
| else if (secC) |
| parts.push(`${secC.prompt} accents`); |
| } |
| |
| for (const accId of d.accessories) { |
| const acc = findPreset(ACCESSORIES, accId); |
| if (acc) |
| parts.push(acc.prompt); |
| } |
| |
| const polishMap = { |
| natural: 'natural look', |
| light_makeup: 'light professional makeup', |
| formal: 'formal polished appearance, refined makeup', |
| }; |
| parts.push(polishMap[d.polish] || ''); |
| |
| const pose = findPreset(NSFW_POSES, d.pose) || findPreset(POSES, d.pose); |
| if (pose) |
| parts.push(pose.prompt); |
| const bg = findPreset(NSFW_BACKGROUNDS, d.background) || findPreset(BACKGROUNDS, d.background); |
| if (bg) |
| parts.push(bg.prompt); |
| |
| const light = findPreset(LIGHTING_OPTIONS, d.lighting); |
| if (light) |
| parts.push(light.prompt); |
| |
| if (nsfwEnabled && d.nsfwExposure) { |
| const nsfwMap = { |
| suggestive: 'suggestive pose, revealing outfit, tastefully showing skin, sensual', |
| clothed_revealing: 'clothed but revealing, deep neckline, exposed skin, alluring', |
| partial_nudity: 'partial nudity, exposed skin, provocative sensual pose, alluring', |
| topless: 'topless, implied nude, exposed upper body, sensual artistic pose', |
| full_nude: 'fully nude, naked body, sensual erotic pose, artistic nude', |
| explicit: 'fully nude, explicit adult content, naked body, sensual erotic pose, anatomically detailed', |
| }; |
| parts.push(nsfwMap[d.nsfwExposure] || ''); |
| |
| const intensity = d.nsfwIntensity ?? 5; |
| if (intensity >= 8) |
| parts.push('extremely sensual, raw, uninhibited'); |
| else if (intensity >= 5) |
| parts.push('sensual, inviting'); |
| |
| if (d.nsfwPose === 'subtle') |
| parts.push('subtle teasing pose, coy expression'); |
| else if (d.nsfwPose === 'confident') |
| parts.push('confident provocative display, bold body language'); |
| else if (d.nsfwPose === 'intimate') |
| parts.push('intimate close pose, bedroom eyes, inviting'); |
| else if (d.nsfwPose) { |
| const posePreset = findPreset(NSFW_POSES, d.nsfwPose); |
| if (posePreset) |
| parts.push(posePreset.prompt); |
| } |
| |
| if (d.nsfwFantasyTone === 'romantic') |
| parts.push('romantic tender mood, soft warm tones'); |
| if (d.nsfwFantasyTone === 'seductive') |
| parts.push('seductive alluring expression, smoldering gaze'); |
| if (d.nsfwFantasyTone === 'dramatic') |
| parts.push('dramatic intense mood, powerful commanding presence'); |
| |
| if (d.nsfwDominanceStyle === 'soft') |
| parts.push('gentle submissive energy, yielding'); |
| if (d.nsfwDominanceStyle === 'balanced') |
| parts.push('balanced confident energy, natural relaxed poise'); |
| if (d.nsfwDominanceStyle === 'strong') |
| parts.push('dominant commanding presence, powerful stance, in control'); |
| } |
| |
| const realismWord = d.realism > 70 ? 'photorealistic' : d.realism > 40 ? 'semi-realistic' : 'stylized'; |
| const detailWord = d.detailLevel > 75 ? 'extremely detailed, intricate textures' : d.detailLevel > 50 ? 'highly detailed' : d.detailLevel > 25 ? 'moderate detail' : 'simplified, clean lines'; |
| parts.push(`${realismWord}, ${detailWord}, 8k resolution`); |
| return parts.filter(Boolean).join(', '); |
| } |
| |
| |
| |
| export function CharacterWizard({ backendUrl, apiKey, globalModelImages, onClose, onSaveGeneration }) { |
| const DRAFT_STORAGE_KEY = 'homepilot_wizard_draft'; |
| const STEP_STORAGE_KEY = 'homepilot_wizard_step'; |
| |
| const loadSavedDraft = useCallback(() => { |
| try { |
| const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY); |
| if (raw) |
| return { ...DEFAULT_DRAFT, ...JSON.parse(raw) }; |
| } |
| catch { } |
| return { ...DEFAULT_DRAFT }; |
| }, []); |
| const loadSavedStep = useCallback(() => { |
| try { |
| const raw = sessionStorage.getItem(STEP_STORAGE_KEY); |
| if (raw) { |
| const n = Number(raw); |
| if (n >= 0 && n <= 6) |
| return n; |
| } |
| } |
| catch { } |
| return 0; |
| }, []); |
| const [wizardMode, setWizardMode] = useState('studio'); |
| const [step, setStep] = useState(loadSavedStep); |
| const [draft, setDraft] = useState(loadSavedDraft); |
| const [advancedOpen, setAdvancedOpen] = useState(false); |
| const [selectedResultIndex, setSelectedResultIndex] = useState(null); |
| const [showNsfw, setShowNsfw] = useState(false); |
| const [showCountMenu, setShowCountMenu] = useState(false); |
| const [count, setCount] = useState(4); |
| const [professionSearch, setProfessionSearch] = useState(''); |
| |
| useEffect(() => { |
| try { |
| sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); |
| } |
| catch { } |
| }, [draft]); |
| useEffect(() => { |
| try { |
| sessionStorage.setItem(STEP_STORAGE_KEY, String(step)); |
| } |
| catch { } |
| }, [step]); |
| |
| const [toast, setToast] = useState(null); |
| const toastTimer = useRef(); |
| const showToast = useCallback((message, type = 'info') => { |
| clearTimeout(toastTimer.current); |
| setToast({ message, type }); |
| toastTimer.current = setTimeout(() => setToast(null), 4000); |
| }, []); |
| |
| const fileInputRef = useRef(null); |
| |
| |
| |
| const readNsfw = useCallback(() => { |
| try { |
| return localStorage.getItem('homepilot_nsfw_mode') === 'true'; |
| } |
| catch { |
| return false; |
| } |
| }, []); |
| const [nsfwEnabled, setNsfwEnabled] = useState(readNsfw); |
| useEffect(() => { |
| const sync = () => setNsfwEnabled(readNsfw()); |
| |
| window.addEventListener('focus', sync); |
| |
| window.addEventListener('storage', sync); |
| |
| |
| const interval = setInterval(sync, 1000); |
| return () => { |
| window.removeEventListener('focus', sync); |
| window.removeEventListener('storage', sync); |
| clearInterval(interval); |
| }; |
| }, [readNsfw]); |
| |
| const [vibeTab, _setVibeTab] = useState(loadVibeTab); |
| const setVibeTab = useCallback((tab) => { _setVibeTab(tab); saveVibeTab(tab); }, []); |
| |
| useEffect(() => { |
| if (!nsfwEnabled && vibeTab === 'spicy') |
| setVibeTab('standard'); |
| }, [nsfwEnabled, vibeTab, setVibeTab]); |
| |
| const gen = useGenerateAvatars(backendUrl, apiKey); |
| const gallery = useAvatarGallery(); |
| |
| const update = useCallback((changes) => { |
| setDraft((prev) => ({ ...prev, ...changes })); |
| }, []); |
| |
| |
| const randomizeAppearance = useCallback(() => { |
| const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; |
| const isSpicyMode = nsfwEnabled && vibeTab === 'spicy'; |
| |
| const outfitPool = isSpicyMode ? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW] : OUTFIT_STYLES_SFW; |
| |
| const stylePool = CHARACTER_STYLE_PRESETS.filter((s) => isSpicyMode ? s.category === 'spicy' : s.category === 'standard'); |
| const posePool = isSpicyMode ? NSFW_POSES : POSES; |
| const bgPool = isSpicyMode ? NSFW_BACKGROUNDS : BACKGROUNDS; |
| |
| const combinedOutfitPool = [...outfitPool.map((o) => o.id), ...stylePool.map((s) => s.id)]; |
| setDraft((prev) => ({ |
| ...prev, |
| gender: pick(['female', 'male', 'neutral']), |
| ageRange: pick(['young_adult', 'adult', 'mature']), |
| bodyType: pick(['slim', 'average', 'athletic', 'curvy']), |
| heightCm: 155 + Math.floor(Math.random() * 40), |
| posture: pick(['upright', 'relaxed', 'confident']), |
| skinTone: pick(SKIN_TONES).id, |
| polish: pick(['natural', 'light_makeup', 'formal']), |
| facePreset: pick(FACE_PRESETS).id, |
| eyeColor: pick(EYE_COLORS).id, |
| eyeShape: pick(EYE_SHAPES).id, |
| expression: pick(EXPRESSIONS).id, |
| jawline: 20 + Math.floor(Math.random() * 60), |
| lips: 20 + Math.floor(Math.random() * 60), |
| browDefinition: 20 + Math.floor(Math.random() * 60), |
| hairStyle: pick(HAIR_STYLES).id, |
| hairColor: pick(HAIR_COLORS).id, |
| hairShine: 10 + Math.floor(Math.random() * 70), |
| outfitStyle: pick(combinedOutfitPool), |
| outfitPrimaryColor: pick(COLOR_PALETTE).id, |
| outfitSecondaryColor: pick(COLOR_PALETTE).id, |
| accessories: ACCESSORIES.filter(() => Math.random() > 0.7).map((a) => a.id), |
| portraitType: pick(['headshot', 'half_body', 'full_body']), |
| pose: pick(posePool).id, |
| background: pick(bgPool).id, |
| lighting: pick(LIGHTING_OPTIONS).id, |
| realism: 40 + Math.floor(Math.random() * 50), |
| |
| ...(isSpicyMode ? { |
| nsfwExposure: pick(['suggestive', 'clothed_revealing', 'partial_nudity', 'topless', 'full_nude', 'explicit']), |
| nsfwIntensity: 2 + Math.floor(Math.random() * 8), |
| nsfwPose: pick(['subtle', 'confident', 'intimate', 'seductive_lean', 'lying_down', 'back_arch', 'kneeling', 'over_shoulder', 'hands_above_head']), |
| nsfwDominanceStyle: pick(['soft', 'balanced', 'strong']), |
| nsfwFantasyTone: pick(['romantic', 'seductive', 'dramatic']), |
| } : { |
| nsfwExposure: undefined, |
| nsfwIntensity: undefined, |
| nsfwPose: undefined, |
| nsfwDominanceStyle: undefined, |
| nsfwFantasyTone: undefined, |
| }), |
| })); |
| }, [nsfwEnabled, vibeTab]); |
| |
| const resetToDefaults = useCallback(() => { |
| setDraft({ ...DEFAULT_DRAFT }); |
| setVibeTab('standard'); |
| }, []); |
| |
| const prompt = useMemo(() => buildPrompt(draft, nsfwEnabled), [draft, nsfwEnabled]); |
| |
| const isSpicy = vibeTab === 'spicy' || !!draft.nsfwExposure; |
| |
| const vibes = AVATAR_VIBE_PRESETS.filter((v) => vibeTab === 'standard' ? v.category === 'standard' : v.category === 'spicy'); |
| const charStyles = CHARACTER_STYLE_PRESETS.filter((s) => vibeTab === 'standard' ? s.category === 'standard' : s.category === 'spicy'); |
| |
| |
| |
| const canProceed = true; |
| const goNext = () => { if (canProceed && step < 6) |
| setStep((s) => (s + 1)); }; |
| const goBack = () => { if (step > 0) |
| setStep((s) => (s - 1)); }; |
| |
| |
| |
| const applyProfession = useCallback((profId) => { |
| const prof = getProfession(profId); |
| if (!prof) |
| return; |
| update({ |
| professionId: profId, |
| tools: [...prof.defaults.tools], |
| memoryEngine: prof.defaults.memoryEngine, |
| autonomy: prof.defaults.autonomy, |
| tone: prof.defaults.tone, |
| systemPrompt: prof.defaults.systemPrompt, |
| responseStyle: prof.defaults.responseStyle, |
| }); |
| }, [update]); |
| |
| |
| |
| const handleFileUpload = useCallback(async (file) => { |
| const formData = new FormData(); |
| formData.append('file', file); |
| try { |
| const res = await fetch(`${backendUrl}/upload`, { |
| method: 'POST', |
| headers: apiKey ? { 'x-api-key': apiKey } : undefined, |
| body: formData, |
| }); |
| if (!res.ok) |
| throw new Error('Upload failed'); |
| const data = await res.json(); |
| const url = data.url || data.file_url || ''; |
| update({ referenceUrl: url, referencePreview: URL.createObjectURL(file) }); |
| } |
| catch { |
| showToast('Failed to upload reference photo', 'error'); |
| } |
| }, [backendUrl, apiKey, update, showToast]); |
| |
| |
| |
| const avatarSettings = loadAvatarSettings(); |
| const checkpoint = resolveCheckpoint(avatarSettings, globalModelImages); |
| const onGenerate = useCallback(async () => { |
| |
| |
| const apiMode = draft.generationMode; |
| try { |
| const result = await gen.run({ |
| mode: apiMode, |
| count, |
| prompt: prompt || undefined, |
| reference_image_url: draft.generationMode !== 'studio_random' |
| ? draft.referenceUrl || undefined |
| : undefined, |
| truncation: 0.7, |
| checkpoint_override: checkpoint, |
| }); |
| if (result?.results?.length) { |
| setSelectedResultIndex(null); |
| if (result.results.length === 1) { |
| showToast('Avatar generated β click to select, then Create Avatar', 'success'); |
| } |
| else { |
| showToast(`${result.results.length} avatars generated β pick your favourite`, 'success'); |
| } |
| } |
| } |
| catch { |
| showToast('Generation failed. Click Generate to try again.', 'error'); |
| } |
| }, [gen, draft.generationMode, draft.referenceUrl, count, prompt, checkpoint, showToast]); |
| |
| useEffect(() => { |
| const handler = (e) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && step === 6 && !gen.loading) { |
| e.preventDefault(); |
| onGenerate(); |
| } |
| }; |
| window.addEventListener('keydown', handler); |
| return () => window.removeEventListener('keydown', handler); |
| }, [onGenerate, step, gen.loading]); |
| |
| |
| |
| const handleCreateAvatar = useCallback(() => { |
| if (selectedResultIndex === null || !gen.result?.results?.[selectedResultIndex]) |
| return; |
| const allResults = gen.result.results; |
| const chosen = allResults[selectedResultIndex]; |
| |
| const portraits = allResults.filter((_, i) => i !== selectedResultIndex); |
| |
| const prof = getProfession(draft.professionId); |
| const wizardMeta = { |
| professionId: draft.professionId, |
| professionLabel: prof?.label, |
| professionDescription: prof?.description, |
| tools: draft.tools, |
| memoryEngine: draft.memoryEngine, |
| autonomy: draft.autonomy, |
| tone: draft.tone, |
| systemPrompt: draft.systemPrompt || prof?.defaults.systemPrompt, |
| responseStyle: draft.responseStyle, |
| gender: draft.gender, |
| ageRange: draft.ageRange, |
| outfitStyle: draft.outfitStyle, |
| }; |
| if (onSaveGeneration) { |
| onSaveGeneration(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, isSpicy, wizardMeta); |
| } |
| else { |
| const framingType = draft.portraitType === 'headshot' ? 'headshot' |
| : draft.portraitType === 'mid_body' ? 'mid_body' : 'half_body'; |
| gallery.addAnchorWithPortraits(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, { nsfw: isSpicy || undefined, wizardMeta, framingType }); |
| } |
| const portraitCount = portraits.length; |
| showToast(portraitCount > 0 |
| ? `Avatar created! ${portraitCount} alternative${portraitCount > 1 ? 's' : ''} saved as portraits` |
| : 'Avatar created!', 'success'); |
| |
| try { |
| sessionStorage.removeItem(DRAFT_STORAGE_KEY); |
| sessionStorage.removeItem(STEP_STORAGE_KEY); |
| } |
| catch { } |
| onClose(); |
| }, [selectedResultIndex, gen.result, draft, prompt, isSpicy, gallery, onSaveGeneration, onClose, showToast]); |
| |
| |
| |
| function renderStep() { |
| switch (step) { |
| |
| |
| |
| case 0: |
| return (<div className="space-y-6"> |
| <SectionLabel>Gender</SectionLabel> |
| <div className="flex gap-2"> |
| {GENDER_OPTIONS.map((g) => (<PillButton key={g.id} label={g.label} icon={g.icon} selected={draft.gender === g.id} onClick={() => update({ gender: g.id })}/>))} |
| </div> |
| |
| <SectionLabel>Age Range</SectionLabel> |
| <div className="flex gap-2"> |
| {['young_adult', 'adult', 'mature'].map((a) => (<PillButton key={a} label={a === 'young_adult' ? 'Young Adult' : a === 'adult' ? 'Adult' : 'Mature'} selected={draft.ageRange === a} onClick={() => update({ ageRange: a })}/>))} |
| </div> |
| </div>); |
| |
| |
| |
| case 1: |
| return (<div className="space-y-6"> |
| <SectionLabel>Body Type</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {['slim', 'average', 'athletic', 'curvy'].map((b) => (<PillButton key={b} label={b.charAt(0).toUpperCase() + b.slice(1)} selected={draft.bodyType === b} onClick={() => update({ bodyType: b })}/>))} |
| </div> |
| |
| <SliderField label="Height" value={draft.heightCm} min={150} max={200} unit=" cm" onChange={(v) => update({ heightCm: v })}/> |
| |
| <SectionLabel>Posture</SectionLabel> |
| <div className="flex gap-2"> |
| {['upright', 'relaxed', 'confident'].map((p) => (<PillButton key={p} label={p.charAt(0).toUpperCase() + p.slice(1)} selected={draft.posture === p} onClick={() => update({ posture: p })}/>))} |
| </div> |
| |
| <SectionLabel>Skin Tone</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {SKIN_TONES.map((t) => (<ColorSwatch key={t.id} color={t.color} label={t.label} selected={draft.skinTone === t.id} onClick={() => update({ skinTone: t.id })}/>))} |
| </div> |
| |
| <SectionLabel>Polish</SectionLabel> |
| <div className="flex gap-2"> |
| {['natural', 'light_makeup', 'formal'].map((p) => (<PillButton key={p} label={p === 'light_makeup' ? 'Light Makeup' : p.charAt(0).toUpperCase() + p.slice(1)} selected={draft.polish === p} onClick={() => update({ polish: p })}/>))} |
| </div> |
| </div>); |
| |
| |
| |
| case 2: |
| return (<div className="space-y-6"> |
| <SectionLabel>Face Shape</SectionLabel> |
| <div className="grid grid-cols-4 gap-2"> |
| {FACE_PRESETS.map((f) => (<PillButton key={f.id} label={f.label} selected={draft.facePreset === f.id} onClick={() => update({ facePreset: f.id })}/>))} |
| </div> |
| |
| <SectionLabel>Eye Color</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {EYE_COLORS.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.eyeColor === c.id} onClick={() => update({ eyeColor: c.id })}/>))} |
| </div> |
| |
| <SectionLabel>Eye Shape</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {EYE_SHAPES.map((s) => (<PillButton key={s.id} label={s.label} selected={draft.eyeShape === s.id} onClick={() => update({ eyeShape: s.id })}/>))} |
| </div> |
| |
| <SectionLabel>Default Expression</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {EXPRESSIONS.map((e) => (<PillButton key={e.id} label={e.label} selected={draft.expression === e.id} onClick={() => update({ expression: e.id })}/>))} |
| </div> |
| |
| <AdvancedToggle open={advancedOpen} onToggle={() => setAdvancedOpen(!advancedOpen)}/> |
| {advancedOpen && (<div className="space-y-4 animate-fadeSlideIn"> |
| <SliderField label="Jawline" value={draft.jawline} min={0} max={100} onChange={(v) => update({ jawline: v })}/> |
| <SliderField label="Lips" value={draft.lips} min={0} max={100} onChange={(v) => update({ lips: v })}/> |
| <SliderField label="Brow Definition" value={draft.browDefinition} min={0} max={100} onChange={(v) => update({ browDefinition: v })}/> |
| </div>)} |
| </div>); |
| |
| |
| |
| case 3: |
| return (<div className="space-y-6"> |
| <SectionLabel>Hairstyle</SectionLabel> |
| <div className="grid grid-cols-3 gap-2"> |
| {HAIR_STYLES.map((h) => (<PillButton key={h.id} label={h.label} selected={draft.hairStyle === h.id} onClick={() => update({ hairStyle: h.id })}/>))} |
| </div> |
| |
| <SectionLabel>Hair Color</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {HAIR_COLORS.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.hairColor === c.id} onClick={() => update({ hairColor: c.id })}/>))} |
| </div> |
| |
| <SliderField label="Shine" value={draft.hairShine} min={0} max={100} onChange={(v) => update({ hairShine: v })}/> |
| </div>); |
| |
| |
| |
| case 4: { |
| const q = professionSearch.toLowerCase().trim(); |
| const filteredProfs = q |
| ? PROFESSIONS.filter((p) => p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q) || p.category.toLowerCase().includes(q)) |
| : PROFESSIONS; |
| return (<div className="space-y-6"> |
| <SectionLabel>Choose a Profession</SectionLabel> |
| <p className="text-[10px] text-white/25 -mt-3">Profession influences outfit style, pose, and scene context</p> |
| |
| {/* Search / filter */} |
| <div className="relative"> |
| <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-white/25"/> |
| <input type="text" value={professionSearch} onChange={(e) => setProfessionSearch(e.target.value)} placeholder="Search professions..." className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/[0.04] border border-white/[0.08] text-xs text-white/70 placeholder-white/25 outline-none focus:border-purple-500/30 transition-colors"/> |
| </div> |
| |
| <div className="space-y-2" role="listbox" aria-label="Profession list"> |
| {filteredProfs.length === 0 && (<p className="text-[10px] text-white/25 text-center py-4">No professions match “{professionSearch}”</p>)} |
| {filteredProfs.map((p) => { |
| const active = draft.professionId === p.id; |
| return (<button key={p.id} role="option" aria-selected={active} onClick={() => { applyProfession(p.id); setProfessionSearch(''); }} className={[ |
| 'w-full flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all', |
| active |
| ? 'border-purple-500/30 bg-purple-500/10 text-purple-200' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.05] hover:text-white/70', |
| ].join(' ')}> |
| <span className="text-lg">{p.icon}</span> |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-2"> |
| <span className="text-xs font-semibold">{p.label}</span> |
| {p.recommended && <span className="text-[8px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 font-bold">Recommended</span>} |
| </div> |
| <p className="text-[10px] text-white/30 mt-0.5 truncate">{p.description}</p> |
| </div> |
| {active && <Check size={16} className="text-purple-400 flex-shrink-0"/>} |
| </button>); |
| })} |
| </div> |
| </div>); |
| } |
| |
| |
| |
| case 5: { |
| const outfits = vibeTab === 'spicy' && nsfwEnabled |
| ? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW] |
| : OUTFIT_STYLES_SFW; |
| return (<div className="space-y-6"> |
| {/* SFW / NSFW tabs β only show when NSFW is globally enabled */} |
| {nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit"> |
| <button onClick={() => setVibeTab('standard')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| <Star size={12}/> Standard |
| </button> |
| <button onClick={() => setVibeTab('spicy')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'spicy' |
| ? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm' |
| : 'text-white/40 hover:text-rose-300/60', |
| ].join(' ')}> |
| <Flame size={12}/> Romance & Roleplay |
| <span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span> |
| </button> |
| </div>)} |
| |
| <SectionLabel>Outfit Style</SectionLabel> |
| <div className="grid grid-cols-2 gap-2"> |
| {outfits.map((o) => (<PillButton key={o.id} label={o.label} selected={draft.outfitStyle === o.id} accent={OUTFIT_STYLES_NSFW.some((n) => n.id === o.id) ? 'rose' : 'purple'} onClick={() => update({ outfitStyle: o.id })}/>))} |
| </div> |
| |
| {/* ββ Lingerie Builder β only when lingerie/boudoir_wear is selected ββ */} |
| {(draft.outfitStyle === 'lingerie' || draft.outfitStyle === 'boudoir_wear') && (() => { |
| const activeGarments = draft.lingerieActiveGarments ?? []; |
| const selections = draft.lingerieSelections ?? {}; |
| const viewingGarment = draft.lingerieType; |
| const currentSel = viewingGarment ? (selections[viewingGarment] ?? {}) : {}; |
| return (<div className="space-y-4 p-4 rounded-xl bg-rose-500/[0.03] border border-rose-500/10"> |
| {/* Garment Type β multi-select, click toggles active, also sets viewing */} |
| <div> |
| <SectionLabel>Garment Type</SectionLabel> |
| <div className="text-[9px] text-white/30 mb-2">Select one or more pieces. Click to toggle & configure.</div> |
| <div className="flex flex-wrap gap-2"> |
| {LINGERIE_GARMENTS.map((g) => { |
| const isActive = activeGarments.includes(g.id); |
| const isViewing = viewingGarment === g.id; |
| return (<PillButton key={g.id} label={`${g.icon} ${g.label}${isActive && !isViewing ? ' β' : ''}`} selected={isViewing} accent="rose" onClick={() => { |
| if (isViewing) { |
| // Second click on viewing garment β deactivate it |
| update({ |
| lingerieType: null, |
| lingerieActiveGarments: activeGarments.filter((x) => x !== g.id), |
| }); |
| } |
| else if (isActive) { |
| // Already active but not viewing β switch to view it |
| update({ lingerieType: g.id }); |
| } |
| else { |
| // Not active β activate + view, preserve previous selections |
| update({ |
| lingerieType: g.id, |
| lingerieActiveGarments: [...activeGarments, g.id], |
| }); |
| } |
| }}/>); |
| })} |
| </div> |
| {/* Active garment summary chips */} |
| {activeGarments.length > 1 && (<div className="flex flex-wrap gap-1 mt-2"> |
| {activeGarments.map((gId) => { |
| const g = LINGERIE_GARMENTS.find((x) => x.id === gId); |
| const sel = selections[gId]; |
| const styleLabel = sel?.style && LINGERIE_STYLES[gId]?.find((s) => s.id === sel.style)?.label; |
| return (<span key={gId} className={[ |
| 'text-[9px] px-2 py-0.5 rounded-full border cursor-pointer transition-all', |
| viewingGarment === gId |
| ? 'border-rose-500/40 bg-rose-500/15 text-rose-200' |
| : 'border-white/10 bg-white/5 text-white/40 hover:text-white/60', |
| ].join(' ')} onClick={() => update({ lingerieType: gId })}> |
| {g?.icon} {g?.label}{styleLabel ? `: ${styleLabel}` : ''} |
| </span>); |
| })} |
| </div>)} |
| </div> |
| |
| {/* Style Picker β scoped to currently viewed garment */} |
| {viewingGarment && LINGERIE_STYLES[viewingGarment] && (<div> |
| <SectionLabel> |
| {viewingGarment === 'bottom' ? 'Panty Style' : viewingGarment === 'top' ? 'Bra Style' : viewingGarment === 'set' ? 'Set Style' : 'Bodysuit Style'} |
| </SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {LINGERIE_STYLES[viewingGarment].map((s) => (<PillButton key={s.id} label={s.label} selected={currentSel.style === s.id} accent="rose" onClick={() => { |
| const newSel = { ...currentSel, style: currentSel.style === s.id ? null : s.id }; |
| update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); |
| }}/>))} |
| </div> |
| </div>)} |
| |
| {/* Fabric β scoped to currently viewed garment */} |
| {viewingGarment && (<div> |
| <SectionLabel>Fabric</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {LINGERIE_FABRICS.map((f) => (<PillButton key={f.id} label={f.label} selected={currentSel.fabric === f.id} accent="rose" onClick={() => { |
| const newSel = { ...currentSel, fabric: currentSel.fabric === f.id ? null : f.id }; |
| update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); |
| }}/>))} |
| </div> |
| </div>)} |
| |
| {/* Brand Inspired β scoped to currently viewed garment */} |
| {viewingGarment && (<div> |
| <SectionLabel>Brand Inspired</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {LINGERIE_BRANDS.map((b) => (<PillButton key={b.id} label={b.label} selected={currentSel.brand === b.id} accent="rose" onClick={() => { |
| const newSel = { ...currentSel, brand: currentSel.brand === b.id ? null : b.id }; |
| update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); |
| }}/>))} |
| </div> |
| </div>)} |
| |
| {/* Lingerie Accessories β shared across all garments */} |
| <div> |
| <SectionLabel>Lingerie Accessories</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {LINGERIE_EXTRAS.map((e) => { |
| const active = (draft.lingerieExtras ?? []).includes(e.id); |
| return (<PillButton key={e.id} label={`${e.icon} ${e.label}`} selected={active} accent="rose" onClick={() => { |
| const prev = draft.lingerieExtras ?? []; |
| update({ lingerieExtras: active ? prev.filter((x) => x !== e.id) : [...prev, e.id] }); |
| }}/>); |
| })} |
| </div> |
| </div> |
| |
| {/* Primary & Secondary Color β inside lingerie builder for piece coloring */} |
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <SectionLabel>{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Top Color' : 'Primary Color'}</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitPrimaryColor === c.id} onClick={() => update({ outfitPrimaryColor: c.id })}/>))} |
| </div> |
| </div> |
| <div> |
| <SectionLabel>{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Bottom Color' : 'Secondary Color'}</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitSecondaryColor === c.id} onClick={() => update({ outfitSecondaryColor: c.id })}/>))} |
| </div> |
| </div> |
| </div> |
| </div>); |
| })()} |
| |
| {/* Primary & Secondary Color β shown only when NOT lingerie (lingerie has its own colors above) */} |
| {draft.outfitStyle !== 'lingerie' && draft.outfitStyle !== 'boudoir_wear' && (<div className="grid grid-cols-2 gap-4"> |
| <div> |
| <SectionLabel>Primary Color</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitPrimaryColor === c.id} onClick={() => update({ outfitPrimaryColor: c.id })}/>))} |
| </div> |
| </div> |
| <div> |
| <SectionLabel>Secondary Color</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {COLOR_PALETTE.map((c) => (<ColorSwatch key={c.id} color={c.color} label={c.label} selected={draft.outfitSecondaryColor === c.id} onClick={() => update({ outfitSecondaryColor: c.id })}/>))} |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Accessories β collapsible, hidden by default */} |
| <button onClick={() => setAdvancedOpen(!advancedOpen)} className="flex items-center gap-1.5 text-[10px] text-white/25 hover:text-white/45 font-medium uppercase tracking-wider transition-colors mt-2"> |
| <ChevronDown size={10} className={`transition-transform ${advancedOpen ? 'rotate-0' : '-rotate-90'}`}/> |
| Accessories |
| {draft.accessories.length > 0 && (<span className="px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 text-[9px] font-bold"> |
| {draft.accessories.length} |
| </span>)} |
| </button> |
| {advancedOpen && (<div className="flex flex-wrap gap-2 mt-2"> |
| {ACCESSORIES.map((a) => { |
| const active = draft.accessories.includes(a.id); |
| return (<PillButton key={a.id} label={a.label} selected={active} onClick={() => { |
| const next = active ? draft.accessories.filter((x) => x !== a.id) : [...draft.accessories, a.id]; |
| update({ accessories: next }); |
| }}/>); |
| })} |
| </div>)} |
| |
| {/* ββ NSFW customization (18+ gated) ββ */} |
| {nsfwEnabled && vibeTab === 'spicy' && (<div className="mt-6 pt-5 border-t border-rose-500/20"> |
| <div className="text-xs text-rose-400 font-bold uppercase tracking-wider mb-5 flex items-center gap-2"> |
| <Flame size={14}/> Adult Content Controls |
| <span className="text-[8px] px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold ml-1">18+</span> |
| </div> |
| |
| <div className="space-y-6"> |
| {/* Exposure Level β 6-tier granular selector */} |
| <div> |
| <div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Nudity / Exposure Level</div> |
| <div className="grid grid-cols-3 gap-2"> |
| {([ |
| { id: 'suggestive', label: 'Suggestive', desc: 'Tasteful hints' }, |
| { id: 'clothed_revealing', label: 'Clothed Revealing', desc: 'Clothed but revealing' }, |
| { id: 'partial_nudity', label: 'Partial Nudity', desc: 'Some exposed skin' }, |
| { id: 'topless', label: 'Topless', desc: 'Topless / implied nude' }, |
| { id: 'full_nude', label: 'Full Nude', desc: 'Artistic full nude' }, |
| { id: 'explicit', label: 'Explicit', desc: 'Explicit adult content' }, |
| ]).map((e) => (<button key={e.id} onClick={() => update({ nsfwExposure: e.id })} className={[ |
| 'flex flex-col items-center gap-1 px-3 py-3 rounded-xl border text-center transition-all', |
| draft.nsfwExposure === e.id |
| ? 'border-rose-500/40 bg-rose-500/15 text-rose-200 shadow-[0_0_12px_rgba(244,63,94,0.15)]' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/40 hover:bg-white/[0.05] hover:text-white/60', |
| ].join(' ')}> |
| <span className="text-xs font-semibold">{e.label}</span> |
| <span className="text-[9px] text-white/30">{e.desc}</span> |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Intensity slider */} |
| <SliderField label="Explicitness Intensity" value={draft.nsfwIntensity ?? 5} min={0} max={10} onChange={(v) => update({ nsfwIntensity: v })}/> |
| |
| {/* Sensual Pose β unified flat grid of all 9 poses */} |
| <div> |
| <div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Sensual Pose</div> |
| <div className="grid grid-cols-3 gap-2"> |
| {([ |
| { id: 'subtle', label: 'Subtle Tease' }, |
| { id: 'confident', label: 'Confident Display' }, |
| { id: 'intimate', label: 'Intimate Close' }, |
| ...NSFW_POSES.map((p) => ({ id: p.id, label: p.label })), |
| ]).map((p) => (<PillButton key={p.id} label={p.label} selected={draft.nsfwPose === p.id} accent="rose" onClick={() => update({ nsfwPose: p.id })}/>))} |
| </div> |
| </div> |
| |
| {/* Dominance / Power dynamic */} |
| <div> |
| <div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Power Dynamic</div> |
| <div className="flex gap-2"> |
| {([ |
| { id: 'soft', label: 'Soft & Romantic' }, |
| { id: 'balanced', label: 'Balanced' }, |
| { id: 'strong', label: 'Dominant & Bold' }, |
| ]).map((d) => (<PillButton key={d.id} label={d.label} selected={draft.nsfwDominanceStyle === d.id} accent="rose" onClick={() => update({ nsfwDominanceStyle: d.id })}/>))} |
| </div> |
| </div> |
| |
| {/* Fantasy Tone */} |
| <div> |
| <div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Fantasy Tone</div> |
| <div className="flex gap-2"> |
| {([ |
| { id: 'romantic', label: 'Romantic & Tender' }, |
| { id: 'seductive', label: 'Seductive & Alluring' }, |
| { id: 'dramatic', label: 'Dramatic & Intense' }, |
| ]).map((t) => (<PillButton key={t.id} label={t.label} selected={draft.nsfwFantasyTone === t.id} accent="rose" onClick={() => update({ nsfwFantasyTone: t.id })}/>))} |
| </div> |
| </div> |
| |
| {/* NSFW Scene / Background */} |
| <div> |
| <div className="text-[10px] text-rose-400/70 font-semibold uppercase tracking-wider mb-3">Scene / Setting</div> |
| <div className="grid grid-cols-3 gap-1.5"> |
| {NSFW_BACKGROUNDS.map((b) => (<PillButton key={b.id} label={b.label} selected={draft.background === b.id} accent="rose" onClick={() => update({ background: b.id })}/>))} |
| </div> |
| </div> |
| </div> |
| </div>)} |
| </div>); |
| } |
| |
| |
| |
| case 6: { |
| const needsRef = draft.generationMode === 'studio_reference' || draft.generationMode === 'studio_faceswap'; |
| const canGen = !gen.loading && (!needsRef || !!draft.referenceUrl); |
| return (<div className="space-y-6"> |
| {/* ββ Generation Mode tabs ββ */} |
| <SectionLabel>Generation Mode</SectionLabel> |
| <div className="flex items-center gap-2" role="radiogroup"> |
| {[ |
| { mode: 'studio_random', label: 'Design Character', icon: <Sparkles size={13}/> }, |
| { mode: 'studio_reference', label: 'From Reference', icon: <User size={13}/> }, |
| { mode: 'studio_faceswap', label: 'Face + Style', icon: <Palette size={13}/> }, |
| ].map((m) => (<button key={m.mode} onClick={() => update({ generationMode: m.mode })} className={[ |
| 'flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium transition-all border', |
| draft.generationMode === m.mode |
| ? 'bg-white/10 text-white border-white/20 shadow-[0_0_12px_rgba(255,255,255,0.05)]' |
| : 'text-white/40 hover:text-white/60 hover:bg-white/[0.04] border-transparent', |
| ].join(' ')}> |
| {m.icon} {m.label} |
| </button>))} |
| </div> |
| |
| {/* ββ Reference upload (for ref/faceswap) ββ */} |
| {needsRef && (<div> |
| <SectionLabel>Upload a Face</SectionLabel> |
| <div className={[ |
| 'flex items-center gap-3 px-4 py-3 rounded-2xl border transition-all', |
| 'bg-white/[0.04] border-white/10', |
| draft.referencePreview ? 'border-purple-500/30' : 'hover:border-white/15', |
| ].join(' ')}> |
| {draft.referencePreview ? (<div className="relative flex-shrink-0"> |
| <div className="w-10 h-10 rounded-full overflow-hidden border-2 border-purple-500/40"> |
| <img src={draft.referencePreview} alt="Reference" className="w-full h-full object-cover"/> |
| </div> |
| <button onClick={() => update({ referenceUrl: undefined, referencePreview: undefined })} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500/80 flex items-center justify-center text-white hover:bg-red-500 transition-colors"> |
| <X size={8}/> |
| </button> |
| </div>) : (<button onClick={() => fileInputRef.current?.click()} className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-white/20 hover:text-white/50 hover:bg-white/5 transition-all border border-dashed border-white/10"> |
| <Camera size={18}/> |
| </button>)} |
| <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) |
| handleFileUpload(f); e.target.value = ''; }}/> |
| <div className="flex-1 min-w-0"> |
| {draft.referencePreview ? (<span className="text-sm text-white/60">Reference photo attached</span>) : (<button onClick={() => fileInputRef.current?.click()} className="text-sm text-white/25 hover:text-white/40 transition-colors cursor-pointer text-left"> |
| Click to upload a reference photo |
| </button>)} |
| </div> |
| </div> |
| </div>)} |
| |
| {/* ββ Style / Vibe selection (for reference/faceswap OR design character) ββ */} |
| {draft.generationMode === 'studio_random' ? (<> |
| {/* Standard / Spicy tabs β only when NSFW globally enabled */} |
| {nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit"> |
| <button onClick={() => setVibeTab('standard')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| <Star size={12}/> Standard |
| </button> |
| <button onClick={() => setVibeTab('spicy')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'spicy' |
| ? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm' |
| : 'text-white/40 hover:text-rose-300/60', |
| ].join(' ')}> |
| <Flame size={12}/> Romance & Roleplay |
| <span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span> |
| </button> |
| </div>)} |
| |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> |
| {charStyles.map((s) => { |
| const active = draft.outfitStyle === s.id || false; |
| return (<button key={s.id} onClick={() => update({ outfitStyle: s.id })} className={[ |
| 'flex items-center gap-2.5 px-3.5 py-3 rounded-xl text-left transition-all border', |
| active |
| ? vibeTab === 'spicy' |
| ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' |
| : 'border-purple-500/30 bg-purple-500/10 text-purple-200' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.04]', |
| ].join(' ')}> |
| <span className="text-base leading-none">{s.icon}</span> |
| <span className="text-xs font-medium">{s.label}</span> |
| </button>); |
| })} |
| </div> |
| </>) : (<> |
| {/* Vibe grid for reference/faceswap β tab bar only when NSFW enabled */} |
| {nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit"> |
| <button onClick={() => setVibeTab('standard')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| <Star size={12}/> Standard |
| </button> |
| <button onClick={() => setVibeTab('spicy')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'spicy' |
| ? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm' |
| : 'text-white/40 hover:text-rose-300/60', |
| ].join(' ')}> |
| <Flame size={12}/> Romance & Roleplay |
| <span className="text-[8px] px-1 py-0.5 rounded bg-rose-500/20 text-rose-300 font-bold">18+</span> |
| </button> |
| </div>)} |
| |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> |
| {vibes.map((v) => (<button key={v.id} onClick={() => update({ outfitStyle: v.id })} className={[ |
| 'flex items-center gap-2.5 px-3.5 py-3 rounded-xl text-left transition-all border', |
| draft.outfitStyle === v.id |
| ? 'border-purple-500/40 bg-purple-500/10 text-white ring-1 ring-purple-500/20' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/50 hover:bg-white/[0.04]', |
| ].join(' ')}> |
| <span className="text-base leading-none">{v.icon}</span> |
| <span className="text-xs font-medium">{v.label}</span> |
| </button>))} |
| </div> |
| </>)} |
|
|
| {} |
| <SectionLabel>Portrait Type</SectionLabel> |
| <div className="flex gap-2 flex-wrap"> |
| {['headshot', 'half_body', 'mid_body', 'full_body'].map((t) => (<PillButton key={t} label={t === 'half_body' ? 'Half Body' : t === 'mid_body' ? 'Mid Body' : t === 'full_body' ? 'Full Body' : 'Headshot'} selected={draft.portraitType === t} onClick={() => update({ portraitType: t })}/>))} |
| </div> |
|
|
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <SectionLabel>Pose</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {POSES.map((p) => (<PillButton key={p.id} label={p.label} selected={draft.pose === p.id} onClick={() => update({ pose: p.id })}/>))} |
| </div> |
| </div> |
| <div> |
| <SectionLabel>Background</SectionLabel> |
| <div className="flex flex-wrap gap-1.5"> |
| {BACKGROUNDS.map((b) => (<PillButton key={b.id} label={b.label} selected={draft.background === b.id} onClick={() => update({ background: b.id })}/>))} |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| <AdvancedToggle open={advancedOpen} onToggle={() => setAdvancedOpen(!advancedOpen)}/> |
| {advancedOpen && (<div className="space-y-4 animate-fadeSlideIn"> |
| <SliderField label="Realism" value={draft.realism} min={0} max={100} onChange={(v) => update({ realism: v })}/> |
| <SliderField label="Detail Level" value={draft.detailLevel} min={0} max={100} onChange={(v) => update({ detailLevel: v })}/> |
| <SectionLabel>Lighting</SectionLabel> |
| <div className="flex flex-wrap gap-2"> |
| {LIGHTING_OPTIONS.map((l) => (<PillButton key={l.id} label={l.label} selected={draft.lighting === l.id} onClick={() => update({ lighting: l.id })}/>))} |
| </div> |
| </div>)} |
|
|
| {} |
| <div className="flex items-center justify-center gap-3 pt-4"> |
| <div className="relative flex items-stretch"> |
| <button onClick={onGenerate} disabled={!canGen} className={[ |
| 'flex items-center gap-2 pl-5 pr-3 py-2.5 rounded-l-xl text-sm font-semibold transition-all', |
| canGen |
| ? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-purple-500/20 hover:shadow-purple-500/30 hover:brightness-110 active:scale-[0.98]' |
| : 'bg-white/[0.06] text-white/25 cursor-not-allowed', |
| ].join(' ')}> |
| {gen.loading ? (<><Loader2 size={16} className="animate-spin"/> Generating...</>) : (<><Sparkles size={16}/> Generate ({count})</>)} |
| </button> |
| <div className="relative"> |
| <button onClick={() => setShowCountMenu(!showCountMenu)} className={[ |
| 'h-full px-2.5 rounded-r-xl border-l transition-all flex items-center', |
| canGen |
| ? 'bg-gradient-to-r from-pink-600 to-pink-700 border-white/10 text-white/80 hover:text-white' |
| : 'bg-white/[0.06] border-white/5 text-white/15 cursor-not-allowed', |
| ].join(' ')} disabled={!canGen && !gen.loading}> |
| <ChevronDown size={14}/> |
| </button> |
| {showCountMenu && (<> |
| <div className="fixed inset-0 z-30" onClick={() => setShowCountMenu(false)}/> |
| <div className="absolute right-0 top-full mt-1 bg-[#1a1a1a] border border-white/10 rounded-lg shadow-2xl z-40 overflow-hidden min-w-[80px]"> |
| {[1, 4, 8].map((n) => (<button key={n} onClick={() => { setCount(n); setShowCountMenu(false); }} className={[ |
| 'w-full px-4 py-2 text-left text-sm transition-colors', |
| count === n ? 'bg-purple-500/15 text-purple-300 font-medium' : 'text-white/60 hover:bg-white/5', |
| ].join(' ')}>{n} image{n > 1 ? 's' : ''}</button>))} |
| </div> |
| </>)} |
| </div> |
| </div> |
|
|
| {gen.loading && (<button onClick={gen.cancel} className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl text-sm font-medium border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-all"> |
| <X size={14}/> Cancel |
| </button>)} |
|
|
| {canGen && !gen.loading && (<span className="text-[10px] text-white/20 hidden sm:inline ml-1">Ctrl+Enter</span>)} |
| </div> |
|
|
| {} |
| {gen.loading && (<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"> |
| {Array.from({ length: count }).map((_, i) => (<div key={i} className="rounded-xl overflow-hidden border border-white/[0.06] bg-white/[0.02]"> |
| <div className="aspect-square bg-white/[0.03] animate-pulse flex items-center justify-center"> |
| <Loader2 size={20} className="animate-spin text-white/10"/> |
| </div> |
| </div>))} |
| </div>)} |
|
|
| {} |
| {gen.result?.results?.length ? (<div className="mt-4 animate-fadeSlideIn"> |
| <div className="text-xs text-white/30 mb-2 font-medium uppercase tracking-wider text-center"> |
| {gen.result.results.length === 1 ? 'Your Avatar' : 'Choose Your Avatar'} |
| </div> |
| <div className={`grid gap-3 ${gen.result.results.length === 1 ? 'grid-cols-1 max-w-xs mx-auto' : 'grid-cols-2 sm:grid-cols-4'}`}> |
| {gen.result.results.map((item, i) => { |
| const imgUrl = resolveFileUrl(item.url, backendUrl); |
| const blurred = isSpicy && !showNsfw; |
| const isSelected = selectedResultIndex === i; |
| const hasSelection = selectedResultIndex !== null; |
| return (<div key={i} className={[ |
| 'group relative rounded-xl overflow-hidden border-2 bg-white/[0.02] transition-all duration-300 cursor-pointer', |
| isSelected |
| ? 'border-purple-400 shadow-[0_0_20px_rgba(168,85,247,0.3)] scale-[1.02] z-10' |
| : hasSelection |
| ? 'border-white/[0.04] opacity-50 grayscale-[30%] hover:opacity-70 hover:grayscale-0' |
| : 'border-white/[0.06] hover:border-white/20 hover:shadow-lg', |
| ].join(' ')} onClick={() => { |
| if (blurred) { |
| setShowNsfw(true); |
| return; |
| } |
| setSelectedResultIndex(isSelected ? null : i); |
| }}> |
| <div className="aspect-square bg-white/[0.03] relative"> |
| <img src={imgUrl} alt={`Avatar ${i + 1}`} className={`w-full h-full object-cover transition-all duration-300 ${blurred ? 'blur-xl scale-110' : ''}`} loading="lazy"/> |
| {blurred && (<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/30"> |
| <EyeOff size={20} className="text-white/40 mb-1"/> |
| <span className="text-[10px] text-white/40 font-medium">Click to reveal</span> |
| </div>)} |
| {isSelected && !blurred && (<div className="absolute top-2 right-2 w-7 h-7 rounded-full bg-purple-500 flex items-center justify-center shadow-lg animate-scaleIn"> |
| <CheckCircle2 size={16} className="text-white"/> |
| </div>)} |
| </div> |
| </div>); |
| })} |
| </div> |
| |
| {/* ββ Create Avatar button ββ */} |
| {selectedResultIndex !== null && gen.result.results[selectedResultIndex] && (<div className="flex flex-col items-center mt-6 animate-fadeSlideIn"> |
| <button onClick={handleCreateAvatar} className="flex items-center gap-3 px-8 py-3.5 rounded-2xl bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold text-sm shadow-[0_0_30px_rgba(168,85,247,0.25)] hover:shadow-[0_0_40px_rgba(168,85,247,0.4)] hover:scale-[1.03] active:scale-[0.98] transition-all duration-200"> |
| <User size={18}/> |
| Create Avatar |
| </button> |
| <p className="text-[10px] text-white/20 mt-2"> |
| Saves avatar to your gallery β export as persona later |
| </p> |
| </div>)} |
| </div>) : !gen.loading && !gen.result ? (<div className="flex flex-col items-center justify-center py-12 text-white/15"> |
| <Sparkles size={36} strokeWidth={1}/> |
| <p className="mt-3 text-sm text-white/25">Your avatars will appear here</p> |
| <p className="mt-1 text-[10px] text-white/15">Configure your character above, then click Generate</p> |
| </div>) : null} |
| </div>); |
| } |
| default: |
| return null; |
| } |
| } |
| |
| |
| |
| function renderQuickCreate() { |
| const canGen = !gen.loading; |
| return (<div className="max-w-md mx-auto space-y-6"> |
| <div className="text-center mb-2"> |
| <h3 className="text-lg font-semibold text-white/90">Quick Create</h3> |
| <p className="text-xs text-white/30 mt-1">Gender, profession, style β done in 60 seconds</p> |
| </div> |
| |
| <SectionLabel>Gender</SectionLabel> |
| <div className="flex gap-2"> |
| {GENDER_OPTIONS.map((g) => (<PillButton key={g.id} label={g.label} icon={g.icon} selected={draft.gender === g.id} onClick={() => update({ gender: g.id })}/>))} |
| </div> |
| |
| <SectionLabel>Profession</SectionLabel> |
| <div className="space-y-1.5"> |
| {PROFESSIONS.filter((p) => p.id !== 'custom').slice(0, 5).map((p) => (<button key={p.id} onClick={() => applyProfession(p.id)} className={[ |
| 'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-all text-xs', |
| draft.professionId === p.id |
| ? 'border-purple-500/30 bg-purple-500/10 text-purple-200' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/45 hover:bg-white/[0.05]', |
| ].join(' ')}> |
| <span>{p.icon}</span> |
| <span className="font-medium">{p.label}</span> |
| {p.recommended && <span className="text-[7px] px-1 py-0.5 rounded bg-purple-500/20 text-purple-300 font-bold ml-auto">Recommended</span>} |
| </button>))} |
| </div> |
| |
| {/* Standard / Romance & Roleplay / 18+ tabs β only when NSFW globally enabled */} |
| {nsfwEnabled && (<div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06] w-fit"> |
| <button onClick={() => setVibeTab('standard')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'standard' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| Standard |
| </button> |
| <button onClick={() => setVibeTab('spicy')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'spicy' |
| ? 'bg-gradient-to-r from-rose-500/20 to-orange-500/20 text-rose-300 border border-rose-500/20 shadow-sm' |
| : 'text-white/40 hover:text-rose-300/60', |
| ].join(' ')}> |
| <Flame size={12}/> Romance & Roleplay |
| </button> |
| <button onClick={() => setVibeTab('spicy')} className={[ |
| 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all', |
| vibeTab === 'spicy' |
| ? 'bg-gradient-to-r from-red-500/20 to-rose-500/20 text-red-300 border border-red-500/20 shadow-sm' |
| : 'text-white/40 hover:text-red-300/60', |
| ].join(' ')}> |
| 18+ |
| </button> |
| </div>)} |
| |
| {/* Style vibe β switches between standard & spicy presets */} |
| <SectionLabel>Style Vibe</SectionLabel> |
| <div className={`grid gap-1.5 ${vibeTab === 'spicy' && nsfwEnabled ? 'grid-cols-4' : 'grid-cols-4'}`}> |
| {CHARACTER_STYLE_PRESETS |
| .filter((s) => vibeTab === 'spicy' && nsfwEnabled ? s.category === 'spicy' : s.category === 'standard') |
| .slice(0, vibeTab === 'spicy' && nsfwEnabled ? 8 : 4) |
| .map((s) => (<button key={s.id} onClick={() => update({ outfitStyle: s.id })} className={[ |
| 'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-[11px] font-medium border transition-all', |
| draft.outfitStyle === s.id |
| ? vibeTab === 'spicy' && nsfwEnabled |
| ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' |
| : 'border-purple-500/30 bg-purple-500/10 text-purple-200' |
| : 'border-white/[0.06] bg-white/[0.02] text-white/40 hover:bg-white/[0.04]', |
| ].join(' ')}> |
| <span>{s.icon}</span> {s.label} |
| </button>))} |
| </div> |
| |
| {/* Randomize + Reset + Generate */} |
| <div className="flex justify-center gap-3 pt-4"> |
| <button onClick={resetToDefaults} className="flex items-center gap-2 px-4 py-3 rounded-2xl text-sm font-medium text-white/30 hover:text-white/55 hover:bg-white/[0.04] border border-white/[0.06] transition-all"> |
| <RotateCcw size={14}/> Reset |
| </button> |
| <button onClick={randomizeAppearance} className="flex items-center gap-2 px-5 py-3 rounded-2xl text-sm font-medium text-white/45 hover:text-white/70 hover:bg-white/[0.06] border border-white/[0.08] transition-all"> |
| <Shuffle size={14}/> Randomize |
| </button> |
| <button onClick={async () => { |
| try { |
| const result = await gen.run({ |
| mode: 'studio_random', |
| count: 4, |
| prompt: prompt || undefined, |
| truncation: 0.7, |
| checkpoint_override: checkpoint, |
| }); |
| if (result?.results?.length) { |
| setWizardMode('studio'); |
| setStep(6); |
| showToast(`${result.results.length} avatars generated β pick your favourite`, 'success'); |
| } |
| } |
| catch { |
| showToast('Generation failed. Try again.', 'error'); |
| } |
| }} disabled={gen.loading} className={[ |
| 'flex items-center gap-2.5 px-8 py-3 rounded-2xl text-sm font-semibold transition-all', |
| !gen.loading |
| ? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-purple-500/20 hover:shadow-purple-500/30 hover:brightness-110 active:scale-[0.98]' |
| : 'bg-white/[0.06] text-white/25 cursor-not-allowed', |
| ].join(' ')}> |
| {gen.loading ? <Loader2 size={16} className="animate-spin"/> : <Zap size={16}/>} |
| {gen.loading ? 'Generating...' : 'Generate & Choose'} |
| </button> |
| </div> |
| </div>); |
| } |
| |
| |
| |
| |
| return (<div className="flex flex-col h-full bg-[#0a0a0f] text-white"> |
| {/* βββββββββββ HEADER βββββββββββ */} |
| <div className="px-6 pt-5 pb-4 border-b border-white/[0.06] flex-shrink-0"> |
| <div className="flex items-center justify-between max-w-6xl mx-auto"> |
| <div className="flex items-center gap-3"> |
| <button onClick={onClose} className="flex items-center gap-2 text-white/40 hover:text-white/70 transition-colors text-sm" title="Back to Gallery"> |
| <ChevronLeft size={16}/> |
| </button> |
| <div className="flex items-center gap-2.5"> |
| <Sparkles size={18} className="text-purple-400"/> |
| <h1 className="text-base font-semibold tracking-tight">Avatar Studio</h1> |
| </div> |
| </div> |
| |
| {/* Quick / Studio toggle */} |
| <div className="flex items-center gap-1 p-1 rounded-xl bg-white/[0.03] border border-white/[0.06]"> |
| <button onClick={() => setWizardMode('quick')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| wizardMode === 'quick' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| <Zap size={12}/> Quick Create |
| </button> |
| <button onClick={() => setWizardMode('studio')} className={[ |
| 'flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all', |
| wizardMode === 'studio' ? 'bg-white/10 text-white shadow-sm' : 'text-white/40 hover:text-white/60', |
| ].join(' ')}> |
| <Layers size={12}/> Studio |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* βββββββββββ BODY βββββββββββ */} |
| {wizardMode === 'quick' ? (<div className="flex-1 overflow-y-auto min-h-0 px-6 py-8"> |
| {renderQuickCreate()} |
| </div>) : (<div className="flex-1 flex min-h-0"> |
| {/* ββ Sidebar ββ */} |
| <nav className="w-48 flex-shrink-0 border-r border-white/[0.06] py-6 px-3 overflow-y-auto" aria-label="Wizard steps"> |
| {WIZARD_STEPS.map((s, i) => { |
| const active = step === i; |
| const completed = i < step; |
| return (<button key={s.key} onClick={() => setStep(i)} aria-current={active ? 'step' : undefined} aria-label={`Step ${i + 1}: ${s.label}${completed ? ' (completed)' : ''}`} className={[ |
| 'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-xs font-medium transition-all mb-1', |
| active |
| ? 'bg-purple-500/10 text-purple-300 border border-purple-500/20' |
| : completed |
| ? 'text-white/50 hover:bg-white/[0.04] border border-transparent' |
| : 'text-white/25 hover:bg-white/[0.03] border border-transparent', |
| ].join(' ')}> |
| <span className={[ |
| 'w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold flex-shrink-0', |
| active ? 'bg-purple-500/25 text-purple-300' |
| : completed ? 'bg-white/10 text-white/50' : 'bg-white/[0.05] text-white/20', |
| ].join(' ')}> |
| {completed ? <Check size={10}/> : i + 1} |
| </span> |
| <span className="flex-1 text-left">{s.label}</span> |
| {!active && !completed && !isStepCustomized(i, draft) && i > 0 && (<span className="text-[8px] text-white/15 font-medium">default</span>)} |
| </button>); |
| })} |
| |
| {/* Randomize / Reset buttons */} |
| <div className="mt-4 pt-4 border-t border-white/[0.06] space-y-1.5"> |
| <button onClick={randomizeAppearance} className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl text-xs font-medium text-white/35 hover:text-white/60 hover:bg-white/[0.04] border border-white/[0.06] transition-all"> |
| <Shuffle size={12}/> Randomize Look |
| </button> |
| <button onClick={resetToDefaults} className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-xl text-[10px] font-medium text-white/20 hover:text-white/45 hover:bg-white/[0.03] transition-all"> |
| <RotateCcw size={10}/> Reset Defaults |
| </button> |
| </div> |
| </nav> |
| |
| {/* ββ Main step content ββ */} |
| <div className="flex-1 overflow-y-auto min-h-0 px-8 py-6"> |
| <div className="max-w-2xl"> |
| {/* Step header */} |
| <div className="mb-6"> |
| <div className="text-xs text-white/20 mb-1">Step {step + 1} of 7</div> |
| <h2 className="text-lg font-semibold text-white/90">{WIZARD_STEPS[step].label}</h2> |
| </div> |
| |
| {renderStep()} |
| |
| {/* Navigation (not on Generate step β it has its own buttons) */} |
| {step < 6 && (<div className="flex items-center justify-between mt-8 pt-6 border-t border-white/[0.06]"> |
| <button onClick={goBack} disabled={step === 0} className={[ |
| 'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all', |
| step > 0 ? 'text-white/50 hover:text-white/70 hover:bg-white/[0.04]' : 'text-white/15 cursor-not-allowed', |
| ].join(' ')}> |
| <ChevronLeft size={14}/> Back |
| </button> |
| <button onClick={goNext} disabled={!canProceed} className={[ |
| 'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all', |
| canProceed |
| ? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-md hover:shadow-lg hover:brightness-110 active:scale-[0.98]' |
| : 'bg-white/[0.06] text-white/25 cursor-not-allowed', |
| ].join(' ')}> |
| Next <ChevronRight size={14}/> |
| </button> |
| </div>)} |
| </div> |
| </div> |
| |
| {/* Preview sidebar removed β avatar-only flow */} |
| </div>)} |
| |
| {/* βββββββββββ TOAST βββββββββββ */} |
| {toast && (<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-toastSlideUp"> |
| <div className={[ |
| 'flex items-center gap-2.5 px-5 py-3 rounded-xl shadow-2xl backdrop-blur-md border text-sm font-medium', |
| toast.type === 'error' ? 'bg-red-500/15 border-red-500/20 text-red-300' |
| : toast.type === 'success' ? 'bg-green-500/15 border-green-500/20 text-green-300' |
| : 'bg-white/10 border-white/10 text-white/70', |
| ].join(' ')}> |
| {toast.type === 'success' && <Sparkles size={16}/>} |
| <span>{toast.message}</span> |
| <button onClick={() => setToast(null)} className="ml-2 text-white/30 hover:text-white/60"><X size={14}/></button> |
| </div> |
| </div>)} |
| |
| <style>{` |
| @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } |
| .animate-fadeSlideIn { animation: fadeSlideIn 0.35s ease-out; } |
| @keyframes toastSlideUp { from { opacity: 0; transform: translate(-50%, 16px); } to { opacity: 1; transform: translate(-50%, 0); } } |
| .animate-toastSlideUp { animation: toastSlideUp 0.25s ease-out; } |
| @keyframes scaleIn { from { opacity: 0; transform: scale(0.5); } to { opacity: 1; transform: scale(1); } } |
| .animate-scaleIn { animation: scaleIn 0.2s ease-out; } |
| `}</style> |
| </div>); |
| } |
|
|