const { useState, useEffect, useRef } = React; const TRAITS = [ "Affectionate", "Opinionated", "Thoughtful/Curious", "Bold/Adventurous", "Compassionate/Empathetic", "Confident", "Deep Conversations/Intellectual", "Dramatic", "Expressive", "Flirty", "Innocent/Sweet", "Modest", "Outgoing", "Philosophical", "Playful/Teasing", "Quiet/Reserved", "Romantic", "Sarcastic", "Shy", "Stubborn", ]; const BODY_TYPES = [ { id: "slim", label: "Slim" }, { id: "average", label: "Average" }, { id: "athletic", label: "Athletic" }, { id: "curvy", label: "Curvy" }, ]; const DEFAULT_APPEARANCE = { country: "mixed", skin_tone: "medium", hair_color: "brown", hair_style: "medium", eye_color: "brown", outfit: "casual", framing: "face", age_appearance: "mid_20s", vibe: "natural", }; const api = { token: () => localStorage.getItem("token"), headers: () => ({ "Content-Type": "application/json", Authorization: `Bearer ${api.token()}`, }), async get(path) { const r = await fetch(path, { headers: api.headers() }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || "Request failed"); return d; }, async post(path, body) { const r = await fetch(path, { method: "POST", headers: api.headers(), body: JSON.stringify(body), }); const d = await r.json(); if (!r.ok) throw new Error(typeof d.detail === "string" ? d.detail : "Request failed"); return d; }, async patch(path, body) { const r = await fetch(path, { method: "PATCH", headers: api.headers(), body: JSON.stringify(body), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || "Request failed"); return d; }, }; function avatarUrl(seed, lookGender) { const style = lookGender === "male" ? "adventurer" : "lorelei"; return `https://api.dicebear.com/7.x/${style}/svg?seed=${encodeURIComponent(seed)}&backgroundColor=1a1a2e,2d1b4e`; } function portraitUrl(companion, cacheBust) { if (companion?.portrait_path && companion?.id && api.token()) { const base = `/api/companions/${companion.id}/portrait?token=${encodeURIComponent(api.token())}`; return cacheBust ? `${base}&v=${cacheBust}` : base; } const portraits = { female: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=600&fit=crop", male: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=600&fit=crop", nonbinary: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&h=600&fit=crop", }; return portraits[companion?.look_gender] || portraits.female; } const PORTRAIT_FALLBACKS = { female: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1200&h=1600&fit=crop", male: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1200&h=1600&fit=crop", nonbinary: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1200&h=1600&fit=crop", }; function PortraitLightbox({ src, alt, onClose }) { useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [onClose]); return (
{alt} e.stopPropagation()} />
); } function MagnifyIcon() { return ( ); } function CompanionPortraitFrame({ companion, portraitVersion, alt, className, onOpenFull }) { const src = portraitUrl(companion, portraitVersion); return (
{alt} { e.target.onerror = null; e.target.src = PORTRAIT_FALLBACKS[companion?.look_gender] || PORTRAIT_FALLBACKS.female; }} />
); } function Silhouette({ gender, bodyType }) { const widths = { slim: 28, average: 36, athletic: 40, curvy: 44 }; const w = widths[bodyType] || 36; const isMale = gender === "male"; const isNB = gender === "nonbinary"; return ( {isMale ? ( <> ) : isNB ? ( <> ) : ( <> )} ); } function AuthScreen({ config, onAuth, mode, setMode }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); async function submit(e) { e.preventDefault(); setError(""); setLoading(true); try { const path = mode === "login" ? "/api/auth/login" : "/api/auth/register"; const data = await api.post(path, { email, password }); localStorage.setItem("token", data.token); onAuth(); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (
{config.site_name}
{config.site_tagline}
{error &&
{error}
}
setEmail(e.target.value)} required />
setPassword(e.target.value)} minLength={6} required />
{mode === "login" ? ( <>New here? ) : ( <>Already have an account? )}
); } function ProfileScreen({ onNext, onBack }) { const [displayName, setDisplayName] = useState(""); const [gender, setGender] = useState(""); const [age, setAge] = useState(""); const [policy, setPolicy] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); async function submit(e) { e.preventDefault(); if (!policy) { setError("You must accept the terms to continue."); return; } setLoading(true); setError(""); try { await api.patch("/api/me/profile", { display_name: displayName, gender, age: parseInt(age, 10), policy_accepted: true, }); onNext({ displayName, gender, age: parseInt(age, 10) }); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (
Tell us about you
{error &&
{error}
}
setDisplayName(e.target.value)} required placeholder="What should we call you?" />
{["male", "female", "nonbinary"].map((g) => (
setGender(g)}> {g.charAt(0).toUpperCase() + g.slice(1)}
))}
setAge(e.target.value)} required placeholder="18+" />
); } function PillSelect({ label, options, value, onChange }) { return (
{options.map((o) => (
onChange(o.id)} > {o.label}
))}
); } function AppearancePicker({ appearance, setAppearance, options, compact }) { if (!options) return
Loading look options…
; const set = (key) => (id) => setAppearance((a) => ({ ...a, [key]: id })); return (
); } function LookScreen({ onNext, onBack }) { const [lookGender, setLookGender] = useState("female"); const [bodyType, setBodyType] = useState("average"); const [appearance, setAppearance] = useState({ ...DEFAULT_APPEARANCE }); const [options, setOptions] = useState(null); useEffect(() => { api.get("/api/appearance/options").then(setOptions).catch(() => {}); }, []); return (
Choose Your Companion's Look

These choices shape their AI-generated portrait — country, colors, outfit, and how much of their body you see.

{["female", "male", "nonbinary"].map((g) => (
setLookGender(g)}> {g.charAt(0).toUpperCase() + g.slice(1)}
))}
{BODY_TYPES.map((b) => (
setBodyType(b.id)} > {b.label}
))}

Face, style & photo

); } function PortraitStudioModal({ companion, options, quota, onClose, onUpdated }) { const [appearance, setAppearance] = useState( companion?.appearance ? { ...DEFAULT_APPEARANCE, ...companion.appearance } : { ...DEFAULT_APPEARANCE } ); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const remaining = quota?.remaining ?? 0; async function regenerate() { if (!quota?.can_regenerate) { setError(quota?.upgrade_message || "Daily limit reached."); return; } setLoading(true); setError(""); try { const data = await api.post(`/api/companions/${companion.id}/regenerate-portrait`, { appearance }); onUpdated(data.companion, data.portrait_quota); onClose(); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (
e.stopPropagation()}>

New portrait for {companion.name}

{remaining > 0 ? `${remaining} of ${quota?.limit || 3} free generations left today.` : quota?.upgrade_message}

{error &&
{error}
} {!quota?.can_regenerate && ( )}
); } function SetupScreen({ config, wizard, onNext, onBack }) { const [name, setName] = useState(""); const [relationship, setRelationship] = useState("friendship"); const [traits, setTraits] = useState([]); const [customTrait, setCustomTrait] = useState(""); const [showBackstory, setShowBackstory] = useState(false); const [backstory, setBackstory] = useState(""); const [error, setError] = useState(""); const maxBackstory = 1000; function toggleTrait(t) { setTraits((prev) => prev.includes(t) ? prev.filter((x) => x !== t) : prev.length < 7 ? [...prev, t] : prev ); } async function generateName() { try { const data = await api.post("/api/companions/generate-name", { look_gender: wizard.lookGender, }); setName(data.name); } catch { setName(["Luna", "Alex", "River"][Math.floor(Math.random() * 3)]); } } function submit() { if (!name.trim()) { setError("Give your companion a name."); return; } if (relationship !== "custom" && traits.length < 3) { setError("Pick at least 3 traits."); return; } setError(""); onNext({ name: name.trim(), relationship, traits, backstory: backstory.trim() }); } useEffect(() => { if (relationship === "custom") setShowBackstory(true); }, [relationship]); return (
Get Ready to Meet {name.trim() ? name : "Your Companion"}!
{error &&
{error}
}
setName(e.target.value)} placeholder="Name" />
{[ { id: "friendship", label: "Friendship" }, { id: "romantic", label: "Romantic" }, { id: "mentor", label: "Mentor" }, { id: "custom", label: "Custom" }, ].map((r) => ( ))}
{relationship !== "custom" && ( <>
Please choose between 3 and 7 key traits.
{TRAITS.map((t) => (
toggleTrait(t)} > {traits.includes(t) && } {t}
))}
setCustomTrait(e.target.value)} placeholder="Add your own…" onKeyDown={(e) => { if (e.key === "Enter" && customTrait.trim() && traits.length < 7) { toggleTrait(customTrait.trim()); setCustomTrait(""); } }} />
{!showBackstory && ( )} )} {(showBackstory || relationship === "custom") && (
{relationship === "custom" && (
Enter details about background, personality, interests. Write in third person.
)}