Initial I Want to Heal app

This commit is contained in:
Warren H
2026-06-17 20:04:36 -04:00
parent 3880db1b58
commit 3c90998a61
109 changed files with 32775 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
type Props = {
profile: CharacterProfile
onBack: () => void
onSaved: (profile: CharacterProfile) => void
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
function saveScroll() {
scrollRef.current = window.scrollY
}
function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5)
.map((ability) => ability.id)
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setMessage('')
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
return
}
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? abilityId : spellId),
)
setMessage('')
}
function clearSlot() {
setSlots((current) =>
current.map((spellId, index) => index === selectedSlot ? null : spellId),
)
}
async function persistChanges() {
saveScroll()
setSaving(true)
setMessage('')
try {
const updated = await saveProfile(classId, slots)
onSaved(updated)
setMessage('Character saved.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to save character.')
} finally {
setSaving(false)
}
}
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</div>
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'talents' && (
<TalentScreen
embedded
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'class' && (
<div className="customize-layout">
<aside className="class-picker">
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
<div>
<strong>{candidate.name}</strong>
<small>{candidate.resourceName}</small>
</div>
</button>
))}
</aside>
<div className="loadout-editor">
<div className="class-detail">
<div
className="class-portrait"
style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}
>
{gameClass.name[0]}
</div>
<div>
<p className="eyebrow">Level {profile.character.level} Healer</p>
<h2>{gameClass.name}</h2>
<p>{gameClass.description}</p>
</div>
</div>
<div className="loadout-heading">
<div>
<p className="eyebrow">Active Loadout</p>
<h2>Ability Bar</h2>
</div>
<span>Select a slot, then choose an ability.</span>
</div>
<div className="ability-slots">
{slots.map((abilityId, index) => {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
key={index}
onClick={() => setSelectedSlot(index)}
type="button"
>
<kbd>{index + 1}</kbd>
<span>{ability?.glyph ?? '-'}</span>
<strong>{ability?.name ?? 'Empty Slot'}</strong>
</button>
)
})}
</div>
<div className="ability-library-heading">
<div>
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
</div>
<i>{locked ? `Level ${ability.unlockLevel}` : equipped ? 'Equipped' : `${ability.cost} ${gameClass.resourceName}`}</i>
</button>
)
})}
</div>
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
disabled={saving}
onClick={persistChanges}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
</button>
</div>
</div>
</div>
)}
</section>
)
}