/* ============================================================ DESIGN: Dark Baroque meets Celestial Codex - Asymmetric two-column layout (portrait left, content right) - Deep midnight navy, silver, pale blue, warm gold palette - Cinzel Decorative for display, EB Garamond for body, Courier Prime for data - Manuscript-inspired with rune dividers and corner ornaments ============================================================ */ import { useEffect, useRef, useState, useCallback } from "react"; import StarCanvas from "@/components/StarCanvas"; import StatRadarChart from "@/components/StatRadarChart"; import { trpc } from "@/lib/trpc"; import { useAuth } from "@/_core/hooks/useAuth"; import { getLoginUrl } from "@/const"; const PORTRAIT_URL = "https://d2xsxph8kpxj0f.cloudfront.net/310519663392130193/2fZBTokfc7TnqrVZUwLhM2/lyra_portrait_e0cebace.png"; const BANNER_URL = "https://d2xsxph8kpxj0f.cloudfront.net/310519663392130193/2fZBTokfc7TnqrVZUwLhM2/lyra_banner_6f01d4ce.png"; // ── Stat bar ──────────────────────────────────────────────── function StatBar({ label, value, max = 100, color = "blue" }: { label: string; value: number; max?: number; color?: "blue" | "gold" }) { const [width, setWidth] = useState(0); const ref = useRef(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setWidth((value / max) * 100); }, { threshold: 0.3 } ); if (ref.current) observer.observe(ref.current); return () => observer.disconnect(); }, [value, max]); return (
{label} {value}
); } // ── Rune divider ───────────────────────────────────────────── function RuneDivider({ label }: { label?: string }) { return (
{label && ( {label} )}
); } // ── Corner ornaments ───────────────────────────────────────── function CornerOrnaments() { return ( <>
); } // ── Section wrapper with reveal animation ──────────────────── function RevealSection({ children, delay = 0, style }: { children: React.ReactNode; delay?: number; style?: React.CSSProperties }) { const ref = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setVisible(true); }, { threshold: 0.1 } ); if (ref.current) observer.observe(ref.current); return () => observer.disconnect(); }, []); return (
{children}
); } // ── Ability card ───────────────────────────────────────────── function AbilityCard({ icon, name, description, type }: { icon: string; name: string; description: string; type: string }) { return (
{icon}
{name} {type}

{description}

); } // ── Equipment item ──────────────────────────────────────────── function EquipmentItem({ icon, name, description }: { icon: string; name: string; description: string }) { return (
{icon}
{name}

{description}

); } // ── Personality trait ───────────────────────────────────────── function TraitBadge({ label }: { label: string }) { return ( ✦ {label} ); } // ── Main page ───────────────────────────────────────────────── export default function Home() { const { user, isAuthenticated } = useAuth(); const [activeTab, setActiveTab] = useState<"lore" | "stats" | "abilities" | "equipment" | "relationships" | "generate">("lore"); // ── Saved Characters (database) state ─────────────────────────── const [saveSessionTag, setSaveSessionTag] = useState(""); const [savedFilter, setSavedFilter] = useState<"all" | "forge" | "randomiser" | "pinned">("all"); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [saveSuccessId, setSaveSuccessId] = useState(null); const [randSaveSuccess, setRandSaveSuccess] = useState(false); const utils = trpc.useUtils(); const { data: savedChars, isLoading: savedLoading } = trpc.character.listSavedCharacters.useQuery( undefined, { enabled: isAuthenticated } ); const saveCharMutation = trpc.character.saveCharacter.useMutation({ onSuccess: (data) => { setSaveSuccessId(data.id); setTimeout(() => setSaveSuccessId(null), 3000); utils.character.listSavedCharacters.invalidate(); }, }); const saveRandMutation = trpc.character.saveCharacter.useMutation({ onSuccess: () => { setRandSaveSuccess(true); setTimeout(() => setRandSaveSuccess(false), 3000); utils.character.listSavedCharacters.invalidate(); }, }); const deleteCharMutation = trpc.character.deleteCharacter.useMutation({ onSuccess: () => { setDeleteConfirmId(null); utils.character.listSavedCharacters.invalidate(); }, }); const togglePinMutation = trpc.character.togglePin.useMutation({ onSuccess: () => utils.character.listSavedCharacters.invalidate(), }); // ── Rune / Grace fluctuating ticker ──────────────────────── const [runeCount, setRuneCount] = useState(() => Math.floor(Math.random() * 80000) + 20000); const [graceCount, setGraceCount] = useState(() => Math.floor(Math.random() * 12) + 3); const [runeFlash, setRuneFlash] = useState<"up" | "down" | null>(null); useEffect(() => { // Runes fluctuate every 3-7 seconds with a random gain/loss const fluctuate = () => { const delta = Math.floor(Math.random() * 4800) - 1800; // -1800 to +3000 setRuneCount(prev => Math.max(0, prev + delta)); setRuneFlash(delta >= 0 ? "up" : "down"); setTimeout(() => setRuneFlash(null), 800); }; const id = setInterval(fluctuate, Math.random() * 4000 + 3000); return () => clearInterval(id); }, []); useEffect(() => { // Grace count occasionally increments (discovery) or rarely decrements const graceFluctuate = () => { const delta = Math.random() < 0.75 ? 1 : -1; setGraceCount(prev => Math.max(1, Math.min(30, prev + delta))); }; const id = setInterval(graceFluctuate, Math.random() * 12000 + 8000); return () => clearInterval(id); }, []); // ── Generate Character state ─────────────────────────────── const [genGender, setGenGender] = useState<"female" | "male" | "any">("any"); const [genAge, setGenAge] = useState("18-40"); const [genFaction, setGenFaction] = useState(""); const [genArchetype, setGenArchetype] = useState(""); const [genTone, setGenTone] = useState<"heroic" | "tragic" | "mysterious" | "neutral">("neutral"); const [genContext, setGenContext] = useState(""); const [generatedChar, setGeneratedChar] = useState>(null); const [genHistory, setGenHistory] = useState>>([]); const [historyIndex, setHistoryIndex] = useState(-1); // New generation controls const [genBackstoryDepth, setGenBackstoryDepth] = useState<"brief" | "standard" | "detailed" | "epic">("standard"); const [genRelationshipSeed, setGenRelationshipSeed] = useState(""); const [genNarrativeSeed, setGenNarrativeSeed] = useState(""); const [genVariantCount, setGenVariantCount] = useState(1); const [genVariants, setGenVariants] = useState>>([]); const [activeVariantIndex, setActiveVariantIndex] = useState(0); // Portrait style const [portraitStyle, setPortraitStyle] = useState<"oil_painting" | "ink_sketch" | "stained_glass" | "manuscript">("oil_painting"); // Pinned/favourite characters const [pinnedChars, setPinnedChars] = useState>(() => { try { return new Set(JSON.parse(localStorage.getItem("lyra_pinned_chars") ?? "[]") as string[]); } catch { return new Set(); } }); // Regenerate field mutation const regenFieldMutation = trpc.character.regenerateField.useMutation({ onSuccess: (data) => { if (!generatedChar) return; const updated = { ...generatedChar, [data.field]: data.value }; setGeneratedChar(updated); setActiveCharacter(updated); setGenHistory(prev => prev.map((c, i) => i === historyIndex ? updated : c)); }, }); // ── Active display character (null = show Lyra, set = show generated) ── const [activeCharacter, setActiveCharacter] = useState>(null); // ── Cooldown state (5 min, persisted in localStorage) ────── const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes const COOLDOWN_KEY = "lyra_forge_cooldown_until"; const getCooldownRemaining = () => { const until = parseInt(localStorage.getItem(COOLDOWN_KEY) ?? "0", 10); return Math.max(0, until - Date.now()); }; const [cooldownRemaining, setCooldownRemaining] = useState(() => getCooldownRemaining()); // Tick the cooldown down every second useEffect(() => { if (cooldownRemaining <= 0) return; const id = setInterval(() => { const remaining = getCooldownRemaining(); setCooldownRemaining(remaining); if (remaining <= 0) clearInterval(id); }, 1000); return () => clearInterval(id); }, [cooldownRemaining]); const formatCooldown = (ms: number) => { const totalSec = Math.ceil(ms / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}:${String(s).padStart(2, "0")}`; }; // ── Portrait generation state ────────────────────────────── const [generatedPortraitUrl, setGeneratedPortraitUrl] = useState(null); const [portraitGenerating, setPortraitGenerating] = useState(false); const [portraitError, setPortraitError] = useState(null); // Track which character the portrait belongs to (by name) to avoid re-generating const [portraitForChar, setPortraitForChar] = useState(null); // Ref to track if portrait generation has been triggered for the current cooldown cycle const portraitTriggeredRef = useRef(false); const portraitMutation = trpc.character.generatePortrait.useMutation({ onSuccess: (data) => { setGeneratedPortraitUrl(data.portraitUrl); setPortraitGenerating(false); setPortraitError(null); }, onError: (err) => { setPortraitGenerating(false); setPortraitError(err.message); }, }); // ── Randomiser token-bucket state ──────────────────────── const RAND_TOKEN_KEY = "lyra_rand_tokens"; const RAND_REFILL_KEY = "lyra_rand_last_refill"; const MAX_RAND_TOKENS = 3; const RAND_REFILL_MS = 60 * 60 * 1000; // 1 hour per token const getTokenState = () => { const stored = parseInt(localStorage.getItem(RAND_TOKEN_KEY) ?? String(MAX_RAND_TOKENS), 10); const lastRefill = parseInt(localStorage.getItem(RAND_REFILL_KEY) ?? String(Date.now()), 10); const elapsed = Date.now() - lastRefill; const tokensToAdd = Math.floor(elapsed / RAND_REFILL_MS); const tokens = Math.min(MAX_RAND_TOKENS, stored + tokensToAdd); const newLastRefill = lastRefill + tokensToAdd * RAND_REFILL_MS; if (tokensToAdd > 0) { localStorage.setItem(RAND_TOKEN_KEY, String(tokens)); localStorage.setItem(RAND_REFILL_KEY, String(newLastRefill)); } const msUntilNext = RAND_REFILL_MS - (Date.now() - newLastRefill); return { tokens, msUntilNext: tokens < MAX_RAND_TOKENS ? Math.max(0, msUntilNext) : 0 }; }; const [randTokens, setRandTokens] = useState(() => getTokenState().tokens); const [randNextRefill, setRandNextRefill] = useState(() => getTokenState().msUntilNext); const [randResult, setRandResult] = useState(null); const [randContext, setRandContext] = useState(""); // Tick refill timer useEffect(() => { const id = setInterval(() => { const { tokens, msUntilNext } = getTokenState(); setRandTokens(tokens); setRandNextRefill(msUntilNext); }, 5000); return () => clearInterval(id); }, []); const formatRefill = (ms: number) => { const totalSec = Math.ceil(ms / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}:${String(s).padStart(2, "0")}`; }; const randomiseMutation = trpc.character.randomise.useMutation({ onSuccess: (data) => { setRandResult(data); const newTokens = Math.max(0, randTokens - 1); localStorage.setItem(RAND_TOKEN_KEY, String(newTokens)); if (newTokens < MAX_RAND_TOKENS && parseInt(localStorage.getItem(RAND_REFILL_KEY) ?? "0", 10) === 0) { localStorage.setItem(RAND_REFILL_KEY, String(Date.now())); } const { tokens, msUntilNext } = getTokenState(); setRandTokens(tokens); setRandNextRefill(msUntilNext); }, }); const generateMutation = trpc.character.generate.useMutation({ onSuccess: (data) => { const variants = data.variants ?? [data.character]; setGenVariants(variants); setActiveVariantIndex(0); setGeneratedChar(data.character); setGenHistory(prev => [data.character, ...prev].slice(0, 10)); setHistoryIndex(0); // Set active display to the generated character setActiveCharacter(data.character); // Reset portrait state for the new character setGeneratedPortraitUrl(null); setPortraitError(null); setPortraitForChar(null); portraitTriggeredRef.current = false; // Start cooldown const until = Date.now() + COOLDOWN_MS; localStorage.setItem(COOLDOWN_KEY, String(until)); setCooldownRemaining(COOLDOWN_MS); }, }); // Auto-trigger portrait generation when cooldown expires and we have a generated character useEffect(() => { if ( cooldownRemaining <= 0 && generatedChar && !portraitTriggeredRef.current && portraitForChar !== String(generatedChar.name) ) { portraitTriggeredRef.current = true; setPortraitGenerating(true); setPortraitForChar(String(generatedChar.name)); portraitMutation.mutate({ name: String(generatedChar.name), race: String(generatedChar.race), gender: String(generatedChar.gender), archetype: String(generatedChar.archetype), faction: String(generatedChar.faction), appearance: String(generatedChar.appearance), age: Number(generatedChar.age), style: portraitStyle, }); } }, [cooldownRemaining, generatedChar, portraitForChar]); // ── Relationships state ─────────────────────────────────── type RelType = "ally" | "rival" | "neutral" | "unknown"; interface Relationship { id: string; name: string; type: RelType; faction: string; trustLevel: number; // 0–100 firstMet: string; notes: string; isExpanded: boolean; } const defaultRelationships: Relationship[] = [ { id: "r1", name: "Serath the Wanderer", type: "ally", faction: "Tarnished", trustLevel: 72, firstMet: "Session 1 — Limgrave crossroads", notes: "A grizzled Tarnished warrior who shared his campfire with Lyra on her first night on the surface. He taught her the difference between a real sunrise and the false dawn of Nokstella. She has given him a Starlight Shard.", isExpanded: false, }, { id: "r2", name: "Mireth of the Glintstone", type: "ally", faction: "Academy of Raya Lucaria", trustLevel: 55, firstMet: "Session 2 — Liurnia of the Lakes", notes: "A young Academy sorcerer fascinated by Nox night sorceries. She and Lyra exchange knowledge cautiously — Mireth wants Lyra's secrets, and Lyra wants access to the Academy's star charts. A transactional friendship that may deepen.", isExpanded: false, }, { id: "r3", name: "The Pale Confessor", type: "rival", faction: "Golden Order", trustLevel: 8, firstMet: "Session 2 — Church of Irith", notes: "A Golden Order Confessor who recognized Lyra's Nox Mirrorhelm as a symbol of heresy against the Greater Will. He has vowed to 'correct her path.' His methods are cold and methodical. Lyra suspects he is following her.", isExpanded: false, }, ]; const [relationships, setRelationships] = useState(defaultRelationships); const [showAddForm, setShowAddForm] = useState(false); const [editingId, setEditingId] = useState(null); const emptyForm = { name: "", type: "neutral" as RelType, faction: "", trustLevel: 50, firstMet: "", notes: "" }; const [formData, setFormData] = useState(emptyForm); const openAdd = () => { setFormData(emptyForm); setEditingId(null); setShowAddForm(true); }; const openEdit = (r: Relationship) => { setFormData({ name: r.name, type: r.type, faction: r.faction, trustLevel: r.trustLevel, firstMet: r.firstMet, notes: r.notes }); setEditingId(r.id); setShowAddForm(true); }; const closeForm = () => { setShowAddForm(false); setEditingId(null); }; const saveForm = useCallback(() => { if (!formData.name.trim()) return; if (editingId) { setRelationships(prev => prev.map(r => r.id === editingId ? { ...r, ...formData } : r)); } else { const newR: Relationship = { ...formData, id: `r${Date.now()}`, isExpanded: false }; setRelationships(prev => [...prev, newR]); } closeForm(); }, [formData, editingId]); const deleteRel = (id: string) => setRelationships(prev => prev.filter(r => r.id !== id)); const toggleExpand = (id: string) => setRelationships(prev => prev.map(r => r.id === id ? { ...r, isExpanded: !r.isExpanded } : r)); // ── Mini-Relationships state ───────────────────────────────────────── const [miniRelOpen, setMiniRelOpen] = useState(false); const [miniRelFigures, setMiniRelFigures] = useState>([]); const [miniRelSentIds, setMiniRelSentIds] = useState>(new Set()); const miniRelMutation = trpc.character.generateMiniRelationships.useMutation({ onSuccess: (data) => { setMiniRelFigures(data.figures); }, }); const sendToRelationships = (fig: typeof miniRelFigures[0], idx: number) => { const newRel: Relationship = { id: `mini_${Date.now()}_${idx}`, name: fig.name, type: fig.relationshipType === "ally" ? "ally" : fig.relationshipType === "rival" ? "rival" : "neutral", faction: fig.faction, trustLevel: fig.trustLevel, firstMet: fig.firstMet, notes: fig.hook, isExpanded: false, }; setRelationships(prev => [...prev, newRel]); setMiniRelSentIds(prev => new Set(Array.from(prev).concat(idx))); }; return (
{/* ── HERO BANNER ─────────────────────────────────────── */}
Elden Ring & Nightreign Character Codex {/* Gradient overlay */}
{/* Title overlay */}
{activeCharacter ? "Elden Ring · Character Codex" : "Elden Ring & Nightreign"}

{activeCharacter ? String(activeCharacter.name) : "Character Codex"}

{activeCharacter ? `${String(activeCharacter.archetype)} · ${String(activeCharacter.faction)}` : "Forge · Randomise · Archive"}
{/* ── Rune / Grace ticker ────────────────────────────────── */}
{/* Runes */}
💎
Runes
{runeFlash === "up" ? "+" : runeFlash === "down" ? "−" : ""}{runeCount.toLocaleString()}
{/* Sites of Grace */}
Sites of Grace
{graceCount}
{activeCharacter && ( )}
{/* ── MAIN CONTENT ────────────────────────────────────── */}
{/* ── TWO-COLUMN LAYOUT ─────────────────────────────── */}
{/* ── LEFT COLUMN: Portrait + Quick Info ─────────── */}
{/* Portrait */}
{/* Portrait display: generated > loading shimmer > Lyra default */} {activeCharacter && generatedPortraitUrl ? ( {String(activeCharacter.name)} ) : activeCharacter && portraitGenerating ? (
Painting portrait...
) : ( // No active character — show a welcome placeholder
No character
loaded
Generate or load a
character to begin
)} {/* Badge */} {activeCharacter && (
{generatedPortraitUrl ? "AI Portrait" : portraitGenerating ? "Painting..." : "Generated"}
)} {/* Portrait error */} {portraitError && activeCharacter && (
Portrait unavailable
)} {/* Portrait gradient overlay */}
{/* Quick Stats Card — dynamic */}
Identity
{(activeCharacter ? [ ["Age", String(activeCharacter.age)], ["Origin", String(activeCharacter.origin)], ["Race", String(activeCharacter.race)], ["Role", String(activeCharacter.archetype)], ["Allegiance", String(activeCharacter.faction)], ["Gender", String(activeCharacter.gender)], ] : [ ["Age", "—"], ["Origin", "—"], ["Race", "—"], ["Role", "—"], ["Allegiance", "—"], ["Status", "—"], ]).map(([key, val]) => (
{key} {val}
))}
{/* Personality Traits — dynamic */}
Traits
{(activeCharacter && Array.isArray(activeCharacter.traits) ? (activeCharacter.traits as string[]) : [] ).length === 0 && !activeCharacter ? (
No character loaded
) : null} {(activeCharacter && Array.isArray(activeCharacter.traits) ? (activeCharacter.traits as string[]) : [] ).map(t => ( ))}
{/* ── RIGHT COLUMN: Tabs + Content ───────────────── */}
{/* Tab navigation */}
{(["lore", "stats", "abilities", "equipment", "relationships", "generate"] as const).map(tab => ( ))}
{/* ── LORE TAB ──────────────────────────────────── */} {activeTab === "lore" && (
{activeCharacter ? ( <>

Origin · {String(activeCharacter.origin)}

{String(activeCharacter.backstory)}

Appearance

{String(activeCharacter.appearance)}

Personality

{String(activeCharacter.personality)}

{String(activeCharacter.definingFlaw)}

Motivation

{String(activeCharacter.motivation)}

{String(activeCharacter.connectionToLyra ?? activeCharacter.connectionToParty ?? "")}

) : ( <>

No Character Loaded

The Codex awaits a soul to chronicle. Head to the Generate tab to forge a new character from the lore of the Lands Between and Nightreign, or load one from your Codex Archive.

"Arise, Tarnished. Thy grace long lost, thou art yet called."
— The Two Fingers

)}
)} {/* ── STATS TAB ─────────────────────────────────── */} {activeTab === "stats" && (

Attribute Spread

{activeCharacter ? `${String(activeCharacter.name)}'s attributes` : "Hover over the chart to inspect individual values."}

{activeCharacter && activeCharacter.stats && typeof activeCharacter.stats === "object" ? ( (() => { const statsObj = activeCharacter.stats as Record; const statKeys = Object.keys(statsObj); return statKeys.map(stat => (
{stat}
{Number(statsObj[stat])}
)); })() ) : ( )}

Primary Attributes

{activeCharacter && activeCharacter.stats && typeof activeCharacter.stats === "object" ? ( (() => { const s = activeCharacter.stats as Record; return [ ["DEXTERITY", "dexterity"], ["INTELLIGENCE", "intelligence"], ["MIND", "mind"], ["ENDURANCE", "endurance"], ["STRENGTH", "strength"], ["FAITH", "faith"], ].map(([label, key]) => ( )); })() ) : ( <> )}

Derived Values

{[ ["HP", activeCharacter ? "—" : "1,240"], ["FP (Focus)", activeCharacter ? "—" : "860"], ["Stamina", activeCharacter ? "—" : "720"], ["Equip Load", activeCharacter ? "—" : "Light"], ["Discovery", activeCharacter ? "—" : "High"], ["Poise", activeCharacter ? "—" : "Low"], ].map(([k, v]) => (
{k} {v}
))}
)} {/* ── ABILITIES TAB ─────────────────────────────── */} {activeTab === "abilities" && (
Combat Arts
Sorceries
Innate Gifts
)} {/* ── EQUIPMENT TAB ─────────────────────────────── */} {activeTab === "equipment" && (

Armaments

Armor

Keepsakes & Tools

)} {/* ── RELATIONSHIPS TAB ──────────────────────── */} {activeTab === "relationships" && (
{/* Header row */}
Known Figures
{relationships.filter(r => r.type === "ally").length} allies · {relationships.filter(r => r.type === "rival").length} rivals · {relationships.filter(r => r.type === "neutral" || r.type === "unknown").length} others
{/* Add / Edit form */} {showAddForm && (
{editingId ? "Edit Figure" : "New Figure"}
{/* Name */}
setFormData(p => ({ ...p, name: e.target.value }))} placeholder="Character name..." style={formInputStyle} />
{/* Type */}
{/* Faction */}
setFormData(p => ({ ...p, faction: e.target.value }))} placeholder="e.g. Golden Order..." style={formInputStyle} />
{/* Trust level */}
setFormData(p => ({ ...p, trustLevel: Number(e.target.value) }))} style={{ width: "100%", accentColor: "oklch(0.72 0.12 240)", cursor: "pointer" }} />
{/* First met */}
setFormData(p => ({ ...p, firstMet: e.target.value }))} placeholder="Session X — location..." style={formInputStyle} />
{/* Notes */}