Initial I Want to Heal app
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user