Files
i-want-to-heal/src/App.tsx
T

863 lines
33 KiB
TypeScript

import { useEffect, useState } from 'react'
import './App.css'
import { CombatScreen } from './components/CombatScreen'
import { AuthScreen } from './components/AuthScreen'
import { CustomizeScreen } from './components/CustomizeScreen'
import { EquipmentScreen } from './components/EquipmentScreen'
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
import { TalentScreen } from './components/TalentScreen'
import { SettingsScreen } from './components/SettingsScreen'
import {
loadCpuPvpLeaderboard,
type CpuPvpLeaderboardEntry,
type PvpContentType,
} from './pvpRoguelike'
import {
loadAuthSession,
logoutAccount,
type Account,
type AuthSession,
type CharacterProfile,
} from './profile'
import {
getCloudSyncStatus,
getGameMode,
syncCloudSave,
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
type Screen =
| 'menu'
| 'dungeons'
| 'combat'
| 'raids'
| 'roguelike'
| 'pvp'
| 'customize'
| 'equipment'
| 'talents'
| 'settings'
const MENU_ITEMS: Array<{
screen: Screen
label: string
glyph: string
description: string
}> = [
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
]
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
function activityInitials(name: string) {
return name
.split(/\s+/)
.filter((word) => /^[A-Za-z0-9]/.test(word))
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join('')
}
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeVariant = 'pve' | 'pvp'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
function App() {
const [screen, setScreen] = useState<Screen>('menu')
const [account, setAccount] = useState<Account | null>(null)
const [profile, setProfile] = useState<CharacterProfile | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
const [serverMessage, setServerMessage] = useState('')
const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => {
const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY))
return Number.isFinite(saved) && saved > 0 ? saved : 1
})
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(2)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1)
const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false)
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
const [showLeaderboard, setShowLeaderboard] = useState(false)
const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
useEffect(() => {
loadAuthSession()
.then((session) => {
setAccount(session.account)
setProfile(session.profile)
})
.catch((reason: unknown) => {
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => setAuthChecked(true))
}, [])
useEffect(() => {
const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail
setGameMode(nextMode)
}
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
return () => {
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
}
}, [])
useEffect(() => {
if (screen === 'combat') return
window.requestAnimationFrame(() => {
focusFirstControl()
})
}, [screen])
useEffect(() => {
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
}, [selectedDifficultyId])
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
useEffect(() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
}, [pvpContentType, screen, roguelikeVariant])
function acceptSession(session: AuthSession) {
setAccount(session.account)
setProfile(session.profile)
setGameMode(getGameMode())
setScreen('menu')
setError('')
setServerMessage('')
}
async function signOut() {
try {
await logoutAccount()
setAccount(null)
setProfile(null)
setGameMode(getGameMode())
setScreen('menu')
setSyncMessage('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
}
async function syncSaveNow() {
setSyncingCloud(true)
setSyncMessage('')
try {
const updated = await syncCloudSave()
setProfile(updated)
setGameMode(getGameMode())
setSyncMessage('Cloud save updated.')
} catch (reason) {
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
} finally {
setSyncingCloud(false)
}
}
if (error) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Database Error</p>
<h1>Character Unavailable</h1>
<p>{error}</p>
</section>
</main>
)
}
if (!authChecked) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!account || !profile) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (screen === 'combat') {
const dungeon = combatContentId < 0
? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0]
: profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0]
const difficulty = dungeon.difficulties.find(
(candidate) => candidate.id === selectedDifficultyId,
) ?? dungeon.difficulties[0]
const roguelikePool = profile.dungeons
.filter((candidate) => candidate.contentType === roguelikeKind)
.flatMap((candidate) => candidate.encounters)
const startPart = selectedPart
return (
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={startPart}
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onProfileUpdated={setProfile}
/>
)
}
if (screen === 'pvp') {
const pvpPool = profile.dungeons
.filter((candidate) => candidate.contentType === pvpContentType)
.flatMap((candidate) => candidate.encounters)
return (
<PvPRoguelikeScreen
contentType={pvpContentType}
encounterPool={pvpPool}
gameMode={gameMode}
onExit={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
)
}
const levelStart = profile.character.currentLevelExperience
const levelEnd = profile.character.nextLevelExperience
const experienceIntoLevel = profile.character.experience - levelStart
const experienceForLevel = Math.max(1, levelEnd - levelStart)
const experiencePercent = profile.character.level >= profile.maxLevel
? 100
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon')
const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid')
const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId)
?? dungeonOptions[0]!
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0]
const activity = screen === 'raids' && raid ? raid : dungeon
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const selectedDifficulty = activity.difficulties.find(
(candidate) => candidate.id === selectedDifficultyId,
) ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
const completedSections = activity.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters]
.filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss'
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
: a.sequence - b.sequence)
return (
<main className="game-shell">
<header className="topbar app-header">
<button
className="brand-button"
onClick={() => setScreen('menu')}
type="button"
>
<strong>Menu</strong>
</button>
<div className="character-summary">
<strong>{profile.character.name}</strong>
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
</button>
</div>
</header>
{screen === 'menu' && (
<section className="menu-screen">
<div className="main-menu-grid">
{canShowCloudSync && (
<div className="menu-card cloud-sync-card">
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
<div>
<strong>Cloud Save</strong>
<small>
{cloudSync.dirty
? 'Local progress waiting. Upload when you want to refresh the server copy.'
: 'Server copy matches this device.'}
</small>
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
</div>
<button
className="text-button"
disabled={syncingCloud || !cloudSync.dirty}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
</button>
</div>
)}
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
key={item.screen}
onClick={() => {
if (item.screen === 'pvp') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
setScreen(item.screen)
}}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
{screen === 'roguelike' && (
<section className="content-screen">
<ScreenHeading
eyebrow="Endless Draft"
title="Roguelike"
onBack={() => setScreen('menu')}
/>
<div className="roguelike-variant-row">
<button
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''}`}
onClick={() => setRoguelikeVariant('pve')}
type="button"
>
PvE
</button>
<button
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''}`}
onClick={() => setRoguelikeVariant('pvp')}
type="button"
>
PvP
</button>
</div>
{roguelikeVariant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="roguelike-mode-grid">
<button
className="menu-card"
onClick={() => {
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>D</span>
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button>
</div>
</>
)}
{roguelikeVariant === 'pvp' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Match Type</p>
<h2>PvP Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''}`}
onClick={() => setPvpContentType('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''}`}
onClick={() => setPvpContentType('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div>
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
<small>
{gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
</small>
</div>
<button
className="text-button"
onClick={() => setScreen('pvp')}
type="button"
>
Start Match
</button>
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">CPU Leaderboard</p>
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
</div>
</div>
<div className="leaderboard-table">
<div className="leaderboard-header pvp-leaderboard-row">
<strong>Rank</strong>
<strong>Player</strong>
<strong>CPU</strong>
<strong>Clears</strong>
<strong>Result</strong>
</div>
{cpuLeaderboard.map((entry, index) => (
<div className="leaderboard-row pvp-leaderboard-row" key={`${entry.characterName}-${entry.completedAt}`}>
<strong>#{index + 1}</strong>
<span>{entry.characterName}</span>
<span>CPU {entry.cpuDifficulty}</span>
<span>{entry.encountersCleared}</span>
<span>{entry.result}</span>
</div>
))}
{cpuLeaderboard.length === 0 && (
<div className="leaderboard-empty">
No CPU runs recorded yet for this mode.
</div>
)}
</div>
</div>
)}
</>
)}
</section>
)}
{(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen">
<ScreenHeading
eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')}
/>
<article className="dungeon-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div>
<p className="eyebrow">{activity.locationName}</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
{activityOptions.length > 1 && (
<label className="activity-select">
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
<select
value={activity.id}
onChange={(event) => {
const nextActivityId = Number(event.target.value)
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
if (screen === 'raids') setSelectedRaidId(nextActivityId)
else setSelectedDungeonId(nextActivityId)
if (nextActivity?.difficulties[0]) {
setSelectedDifficultyId(nextActivity.difficulties[0].id)
}
}}
>
{activityOptions.map((candidate) => (
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
))}
</select>
</label>
)}
<div className="part-buttons">
{parts.map((p) => (
<button
key={p.part}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setCombatContentId(activity.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
))}
</div>
</article>
<div className="difficulty-section compact-difficulty-section">
<div className="difficulty-select-row">
<div>
<p className="eyebrow">Challenge Tier</p>
<h2>Difficulty</h2>
</div>
<label>
<span>Select</span>
<select
value={selectedDifficulty.id}
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
>
{activity.difficulties.map((difficulty, index) => (
<option
disabled={profile.character.level < difficulty.unlockLevel}
key={difficulty.id}
value={difficulty.id}
>
{index + 1}. {difficulty.name}
{profile.character.level < difficulty.unlockLevel
? ` - Level ${difficulty.unlockLevel}`
: ''}
</option>
))}
</select>
</label>
</div>
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Encounter Rewards</p>
<h2>{selectedDifficulty.name} Loot Tables</h2>
</div>
<button
className="text-button"
onClick={() => setShowLoot((current) => !current)}
type="button"
>
{showLoot ? 'Hide Loot' : 'View Loot'}
</button>
</div>
{showLoot && (
<>
<div className="loot-toolbar">
<label>
<span>Sort</span>
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
<option value="sequence">Encounter order</option>
<option value="boss">Boss name</option>
</select>
</label>
</div>
<p className="section-note">
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
{activity.completionItemLevel
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
: ''}
</p>
<div className="loot-preview-grid">
{lootPreviewEncounters.map((encounter) => {
const loot = encounter.lootTables.filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
)
return (
<article key={encounter.id}>
<div className="loot-encounter-title">
{encounter.isBoss ? (
<img className="loot-boss-icon" src={encounter.imageUrl} alt={`${encounter.enemyName} icon`} />
) : (
<span>{encounter.sequence}</span>
)}
<div>
<strong>{encounter.enemyName}</strong>
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
</div>
</div>
<div className="loot-items">
{loot.map((item) => (
<div className={`rarity-${item.rarity}`} key={item.id}>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{item.slot} - iLvl {item.itemLevel}</small>
</div>
<i>{item.dropWeight}% weight</i>
</div>
))}
</div>
</article>
)
})}
</div>
</>
)}
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Efficiency Rankings</p>
<h2>{selectedDifficulty.name} Leaderboard</h2>
</div>
<button
className="text-button"
onClick={() => setShowLeaderboard((current) => !current)}
type="button"
>
{showLeaderboard ? 'Hide Leaderboard' : 'View Leaderboard'}
</button>
</div>
{showLeaderboard && (
<>
<p className="section-note">
{gameMode === 'offline'
? 'Offline runs are not submitted'
: canShowCloudSync
? 'Manual save sync updates your cloud profile.'
: 'Lowest resource spent ranks first'}
</p>
<div className="leaderboard-tabs">
{([
{ key: 'part_1', label: `${sectionName} 1` },
{ key: 'part_2', label: `${sectionName} 2` },
{ key: 'part_3', label: `${sectionName} 3` },
{ key: 'full_run', label: 'Full Run' },
] as const).map((tab) => (
<button
key={tab.key}
className={`leaderboard-tab ${leaderboardCategory === tab.key ? 'active' : ''}`}
onClick={() => setLeaderboardCategory(tab.key)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className="leaderboard-table">
<div className="leaderboard-header">
<span>Rank</span>
<span>Healer</span>
<span>Class</span>
<span>Level</span>
<span>Item Level</span>
<span>Resource</span>
<span>Time</span>
</div>
{activity.leaderboards[leaderboardCategory]
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
.map((entry) => (
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
<strong>#{entry.rank}</strong>
<span>{entry.characterName}</span>
<span>{entry.className}</span>
<span>{entry.characterLevel}</span>
<span>{entry.averageItemLevel.toFixed(1)}</span>
<strong>{entry.resourceSpent}</strong>
<span>{entry.durationSeconds}s</span>
</div>
))}
{activity.leaderboards[leaderboardCategory].filter(
(entry) => entry.difficultyId === selectedDifficulty.id,
).length === 0 && (
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
: canShowCloudSync
? 'No leaderboard entries yet.'
: 'Complete this difficulty to claim the first ranking.'}
</div>
)}
</div>
</>
)}
</div>
)}
</section>
)}
{screen === 'customize' && (
<CustomizeScreen
profile={profile}
onBack={() => setScreen('menu')}
onSaved={setProfile}
/>
)}
{screen === 'talents' && (
<TalentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'equipment' && (
<EquipmentScreen
profile={profile}
onBack={() => setScreen('menu')}
onUpdated={setProfile}
/>
)}
{screen === 'settings' && (
<SettingsScreen onBack={() => setScreen('menu')} />
)}
</main>
)
}
function ScreenHeading({
eyebrow,
title,
onBack,
}: {
eyebrow: string
title: string
onBack: () => void
}) {
return (
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)
}
export default App