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
+537
View File
@@ -0,0 +1,537 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
breakdownItem,
craftItem,
equipItem,
loadProfile,
type CharacterProfile,
type EquipmentSlot,
type Item,
} from '../profile'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
helmet: 'Helmet',
chest: 'Chest',
gloves: 'Gloves',
boots: 'Boots',
pants: 'Pants',
ring: 'Ring',
necklace: 'Necklace',
trinket: 'Trinket',
component: 'Component',
}
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
)
const firstItem = profile.inventory.find((item) => !item.equipped)
?? profile.inventory[0]
const [selectedItemId, setSelectedItemId] = useState<number | null>(
firstItem?.id ?? null,
)
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null)
const [equipping, setEquipping] = useState(false)
const [breakingDown, setBreakingDown] = useState(false)
const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
?? profile.craftingRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const equippedBySlot = useMemo(
() => new Map(
profile.inventory
.filter((item) => item.equipped)
.map((item) => [item.slot, item]),
),
[profile.inventory],
)
const comparisonItem = selectedItem
? equippedBySlot.get(selectedItem.slot)
: undefined
const visibleInventory = useMemo(
() => selectedSlot
? profile.inventory.filter((item) => item.slot === selectedSlot)
: profile.inventory,
[profile.inventory, selectedSlot],
)
const visibleItemCount = visibleInventory.reduce(
(total, item) => total + item.quantity,
0,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes],
)
const filteredRecipes = useMemo(
() => {
let result = [...profile.craftingRecipes]
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
return result
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
}
}, [equipmentTab])
function saveScroll() {
scrollRef.current = window.scrollY
}
async function equipSelected() {
if (!selectedItem || selectedItem.equipped) return
saveScroll()
setEquipping(true)
setMessage('')
try {
const updated = await equipItem(selectedItem.id)
onUpdated(updated)
setMessage(`${selectedItem.name} equipped.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to equip item.')
} finally {
setEquipping(false)
}
}
async function breakdownSelected() {
if (!selectedItem) return
saveScroll()
setBreakingDown(true)
setMessage('')
try {
const updated = await breakdownItem(selectedItem.id)
onUpdated(updated)
setMessage(
selectedItem.quantity > 1
? `One duplicate ${selectedItem.name} broken down into components.`
: `${selectedItem.name} broken down into components.`,
)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to break down item.')
} finally {
setBreakingDown(false)
}
}
async function craftSelected() {
if (!selectedRecipe) return
saveScroll()
setCrafting(true)
setMessage('')
try {
const updated = await craftItem(selectedRecipe.id)
onUpdated(updated)
setSelectedItemId(selectedRecipe.item.id)
setMessage(`${selectedRecipe.item.name} crafted.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to craft item.')
} finally {
setCrafting(false)
}
}
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
<p>Select an item to inspect it.</p>
)}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
<div className="equipment-slots">
{profile.equipmentSlots.map((slot) => {
const item = equippedBySlot.get(slot)
return (
<button
className={`${item ? `rarity-${item.rarity}` : 'empty'} ${selectedSlot === slot ? 'selected-slot' : ''}`}
key={slot}
onClick={() => {
setSelectedSlot(slot)
const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot,
)
setSelectedItemId(item?.id ?? firstSlotItem?.id ?? null)
}}
type="button"
>
<span>{item?.glyph ?? '-'}</span>
<div>
<strong>{item?.name ?? SLOT_LABELS[slot]}</strong>
<small>{SLOT_LABELS[slot]}{item ? ` - iLvl ${item.itemLevel}` : ' - Empty'}</small>
</div>
<div className="item-status">
{item && <i>Equipped</i>}
</div>
</button>
)
})}
</div>
</section>
<section className="inventory-panel">
<EquipmentHeading
eyebrow="Owned Items"
title={selectedSlot ? `${SLOT_LABELS[selectedSlot]} Inventory` : 'Inventory'}
detail={selectedSlot
? `${visibleItemCount} items - ${visibleInventory.length} types`
: `${totalItemCount} items - ${profile.inventory.length} types`}
/>
{selectedSlot && (
<button
className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)}
type="button"
>
Show All Items
</button>
)}
<div className="inventory-list">
{visibleInventory.map((item) => (
<button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.name}</strong>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
<div className="item-status">
{item.equipped && <i>Equipped</i>}
{item.quantity > 1 && <i className="item-quantity">x{item.quantity}</i>}
</div>
</button>
))}
{visibleInventory.length === 0 && (
<p className="inventory-empty">
No {SLOT_LABELS[selectedSlot ?? 'component'].toLowerCase()} items owned.
</p>
)}
</div>
</section>
</div>
</>
) : (
<section className="crafting-panel">
<EquipmentHeading
eyebrow="Crafting"
title="Recipes"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
/>
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
{filteredRecipes.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
onClick={() => setSelectedRecipeId(recipe.id)}
type="button"
>
<span>{recipe.item.glyph}</span>
<div>
<strong>{recipe.item.name}</strong>
<small>
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small>
</div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button>
))}
</div>
{selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-components">
{selectedRecipe.components.map((component) => (
<div
className={component.owned >= component.quantity ? 'ready' : 'missing'}
key={component.item.id}
>
<span>{component.item.glyph}</span>
<strong>{component.item.name}</strong>
<i>{component.owned}/{component.quantity}</i>
</div>
))}
</div>
<button
className="primary-button"
disabled={!selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : 'Craft Item'}
</button>
</div>
)}
</div>
)}
</section>
)}
{profile.setBonuses.length > 0 && (
<section className="set-bonus-panel">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Set Bonuses</p>
<h2>Raid Sets</h2>
</div>
<button
className="text-button"
onClick={() => setShowSetBonuses((current) => !current)}
type="button"
>
{showSetBonuses ? 'Hide Raid Sets' : 'Show Raid Sets'}
</button>
</div>
{showSetBonuses && (
<div className="set-bonus-list">
{profile.setBonuses.map((bonus) => (
<div className={bonus.active ? 'active' : ''} key={`${bonus.setId}-${bonus.requiredPieces}`}>
<strong>{bonus.requiredPieces} pieces</strong>
<span>{bonus.description}</span>
<i>{bonus.equippedPieces}/{bonus.requiredPieces}</i>
</div>
))}
</div>
)}
</section>
)}
<footer className="equipment-footer">
{message || 'Equipment changes are saved immediately.'}
</footer>
</>
)
if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div>
}
return (
<section className="content-screen equipment-screen">
{content}
</section>
)
}
function GearStat({ value, label }: { value: string; label: string }) {
return (
<div className="gear-stat">
<strong>{value}</strong>
<span>{label}</span>
</div>
)
}
function EquipmentHeading({
eyebrow,
title,
detail,
}: {
eyebrow: string
title: string
detail?: string
}) {
return (
<div className="equipment-heading">
<div><p className="eyebrow">{eyebrow}</p><h2>{title}</h2></div>
{detail && <span>{detail}</span>}
</div>
)
}
function ItemDetail({ title, item }: { title: string; item: Item }) {
return (
<article className={`item-detail rarity-${item.rarity}`}>
<p className="eyebrow">{title}</p>
<div className="item-title">
<span>{item.glyph}</span>
<div>
<h2>{item.name}</h2>
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
</div>
</div>
<p>{item.description}</p>
{item.quantity > 1 && <p className="owned-quantity">Owned: {item.quantity}</p>}
{item.slot !== 'component' && (
<ul>
<li>+{item.healingPower} Healing Power</li>
<li>+{item.maxResourceBonus} Max Resource</li>
</ul>
)}
</article>
)
}
function ComparisonDelta({
selected,
equipped,
}: {
selected: Item
equipped?: Item
}) {
const healingDelta = selected.healingPower - (equipped?.healingPower ?? 0)
const resourceDelta = selected.maxResourceBonus - (equipped?.maxResourceBonus ?? 0)
return (
<div className="comparison-delta">
<span className={healingDelta >= 0 ? 'positive' : 'negative'}>
{healingDelta >= 0 ? '+' : ''}{healingDelta} Healing
</span>
<span className={resourceDelta >= 0 ? 'positive' : 'negative'}>
{resourceDelta >= 0 ? '+' : ''}{resourceDelta} Resource
</span>
</div>
)
}