888 lines
32 KiB
TypeScript
888 lines
32 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
breakdownItem,
|
|
craftItem,
|
|
equipItem,
|
|
loadProfile,
|
|
upgradeItem,
|
|
type CharacterProfile,
|
|
type EquipmentSlot,
|
|
type Item,
|
|
} from '../profile'
|
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
|
|
|
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',
|
|
}
|
|
|
|
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
|
const CRAFTING_LIST_PAGE_SIZE = 3
|
|
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
|
.filter((slot) => slot !== 'component')
|
|
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
|
type CraftingRecipe = CharacterProfile['craftingRecipes'][number]
|
|
|
|
function selectUpgradeRecipe(
|
|
paths: CharacterProfile['gearUpgradePaths'],
|
|
recipes: CraftingRecipe[],
|
|
item: Pick<Item, 'id' | 'slot' | 'itemLevel'>,
|
|
) {
|
|
const path = paths.find((candidate) => candidate.fromItemId === item.id)
|
|
if (path) {
|
|
const pathRecipe = recipes.find((recipe) => recipe.item.id === path.toItemId)
|
|
if (pathRecipe) return pathRecipe
|
|
}
|
|
const candidates = recipes.filter((recipe) =>
|
|
recipe.item.slot === item.slot
|
|
&& recipe.item.itemLevel > item.itemLevel
|
|
)
|
|
const nextItemLevel = Math.min(...candidates.map((recipe) => recipe.item.itemLevel))
|
|
if (!Number.isFinite(nextItemLevel)) return undefined
|
|
return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel)
|
|
}
|
|
|
|
type Props = {
|
|
profile: CharacterProfile
|
|
onBack?: () => void
|
|
onUpdated: (profile: CharacterProfile) => void
|
|
embedded?: boolean
|
|
mode?: 'equipment' | 'crafting'
|
|
showModeTabs?: boolean
|
|
}
|
|
|
|
export function EquipmentScreen({
|
|
profile,
|
|
onBack,
|
|
onUpdated,
|
|
embedded = false,
|
|
mode,
|
|
showModeTabs = true,
|
|
}: Props) {
|
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
|
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 [upgrading, setUpgrading] = useState(false)
|
|
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
|
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
|
const [inventoryPage, setInventoryPage] = useState(0)
|
|
const [recipePage, setRecipePage] = useState(0)
|
|
const [message, setMessage] = useState('')
|
|
const scrollRef = useRef<number>(0)
|
|
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
|
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
|
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
|
)
|
|
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
|
?? craftableRecipes[0]
|
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
|
firstRecipe?.id ?? null,
|
|
)
|
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
|
const selectedRecipeRequiresUpgrade = selectedRecipe
|
|
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
|
: false
|
|
const selectedItemRecipe = selectedItem
|
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
|
: undefined
|
|
const upgradeRecipe = selectedItem && selectedItemRecipe
|
|
? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, selectedItem)
|
|
: undefined
|
|
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 inventoryPageCount = Math.max(
|
|
1,
|
|
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
|
|
)
|
|
const inventoryPageItems = visibleInventory.slice(
|
|
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
|
|
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
|
|
)
|
|
|
|
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
|
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
|
const availableLevels = useMemo(
|
|
() => [...new Set(profile.craftingRecipes
|
|
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
|
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
|
[profile.craftingRecipes],
|
|
)
|
|
const filteredRecipes = useMemo(
|
|
() => {
|
|
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
|
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],
|
|
)
|
|
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
|
|
const slotRecipeCounts = useMemo(
|
|
() => new Map(
|
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
|
slot,
|
|
profile.craftingRecipes.filter((recipe) =>
|
|
recipe.item.slot === slot
|
|
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
|
).length,
|
|
]),
|
|
),
|
|
[profile.craftingRecipes],
|
|
)
|
|
const recipePageCount = Math.max(
|
|
1,
|
|
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
|
)
|
|
const recipePageItems = filteredRecipes.slice(
|
|
recipePage * CRAFTING_LIST_PAGE_SIZE,
|
|
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
|
|
)
|
|
|
|
useEffect(() => {
|
|
window.scrollTo(0, scrollRef.current)
|
|
}, [profile])
|
|
|
|
useEffect(() => {
|
|
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
|
|
}, [inventoryPageCount])
|
|
|
|
useEffect(() => {
|
|
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
|
}, [recipePageCount])
|
|
|
|
useEffect(() => {
|
|
if (filteredRecipes.length === 0) {
|
|
setSelectedRecipeId(null)
|
|
return
|
|
}
|
|
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
|
setSelectedRecipeId(filteredRecipes[0].id)
|
|
}
|
|
}, [filteredRecipes, selectedRecipeId])
|
|
|
|
useEffect(() => {
|
|
if (equipmentTab === 'crafting') {
|
|
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
|
}
|
|
}, [equipmentTab])
|
|
|
|
useEffect(() => {
|
|
if (mode) setEquipmentTab(mode)
|
|
}, [mode])
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
async function upgradeSelected() {
|
|
if (!selectedItem || !upgradeRecipe) return
|
|
saveScroll()
|
|
setUpgrading(true)
|
|
setMessage('')
|
|
try {
|
|
const updated = await upgradeItem(selectedItem.id)
|
|
onUpdated(updated)
|
|
setSelectedItemId(upgradeRecipe.item.id)
|
|
setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`)
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.')
|
|
} finally {
|
|
setUpgrading(false)
|
|
}
|
|
}
|
|
|
|
function renderEquipmentActions() {
|
|
if (!selectedItem) {
|
|
return <p>Select an item to inspect it.</p>
|
|
}
|
|
if (selectedItem.slot === 'component') {
|
|
return <p className="component-note">Used in crafting.</p>
|
|
}
|
|
return (
|
|
<>
|
|
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
|
<button
|
|
className="primary-button"
|
|
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
|
onClick={equipSelected}
|
|
type="button"
|
|
>
|
|
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
|
</button>
|
|
{upgradeRecipe && (
|
|
<button
|
|
className="primary-button"
|
|
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
|
onClick={upgradeSelected}
|
|
type="button"
|
|
>
|
|
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
|
</button>
|
|
)}
|
|
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
|
<button
|
|
className="breakdown-button"
|
|
disabled={equipping || breakingDown || upgrading}
|
|
onClick={breakdownSelected}
|
|
type="button"
|
|
>
|
|
{breakingDown
|
|
? 'Breaking Down...'
|
|
: selectedItem.quantity > 1
|
|
? 'Break Down Duplicate'
|
|
: 'Break Down'}
|
|
</button>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
|
if (equipmentTab === 'crafting') {
|
|
if (!selectedRecipe) {
|
|
return {
|
|
mode: 'crafting',
|
|
title: 'Craft Output',
|
|
subtitle: 'No recipe selected',
|
|
items: [],
|
|
}
|
|
}
|
|
return {
|
|
mode: 'crafting',
|
|
title: selectedRecipe.item.name,
|
|
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
|
summary: selectedRecipe.item.description,
|
|
items: [
|
|
{
|
|
glyph: selectedRecipe.item.glyph,
|
|
title: 'Craft Output',
|
|
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
|
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
|
},
|
|
...selectedRecipe.components.map((component) => ({
|
|
glyph: component.item.glyph,
|
|
title: component.item.name,
|
|
meta: `Item Level ${component.item.itemLevel}`,
|
|
status: `${component.owned}/${component.quantity}`,
|
|
})),
|
|
],
|
|
}
|
|
}
|
|
if (!selectedItem) {
|
|
return {
|
|
mode: 'equipment',
|
|
title: 'Equipment Detail',
|
|
subtitle: 'No item selected',
|
|
items: [],
|
|
}
|
|
}
|
|
return {
|
|
mode: 'equipment',
|
|
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
|
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
|
summary: selectedItem.description,
|
|
items: selectedItem.slot === 'component'
|
|
? [{
|
|
glyph: selectedItem.glyph,
|
|
title: selectedItem.name,
|
|
meta: `Owned: ${selectedItem.quantity}`,
|
|
status: 'Component',
|
|
}]
|
|
: [
|
|
{
|
|
glyph: selectedItem.glyph,
|
|
title: selectedItem.name,
|
|
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
|
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
|
},
|
|
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
|
? [{
|
|
glyph: comparisonItem.glyph,
|
|
title: comparisonItem.name,
|
|
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
|
status: 'Currently Equipped',
|
|
}]
|
|
: [{
|
|
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
|
status: 'Comparison',
|
|
}]),
|
|
...(upgradeRecipe
|
|
? [
|
|
{
|
|
glyph: upgradeRecipe.item.glyph,
|
|
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
|
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
|
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
|
},
|
|
...upgradeRecipe.components.map((component) => ({
|
|
glyph: component.item.glyph,
|
|
title: component.item.name,
|
|
meta: `Required for upgrade`,
|
|
status: `${component.owned}/${component.quantity}`,
|
|
})),
|
|
]
|
|
: []),
|
|
],
|
|
}
|
|
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
|
|
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
|
|
|
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>
|
|
|
|
{showModeTabs && (
|
|
<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} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
)}
|
|
</>
|
|
)
|
|
) : (
|
|
<p>Select an item to inspect it.</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="equipment-action-strip">
|
|
{renderEquipmentActions()}
|
|
</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)
|
|
setInventoryPage(0)
|
|
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)
|
|
setInventoryPage(0)
|
|
}}
|
|
type="button"
|
|
>
|
|
Show All Items
|
|
</button>
|
|
)}
|
|
<div className="inventory-list">
|
|
{inventoryPageItems.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>
|
|
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
|
|
<ListPager
|
|
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
|
|
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
|
|
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
|
|
nextDisabled={inventoryPage >= inventoryPageCount - 1}
|
|
previousDisabled={inventoryPage <= 0}
|
|
/>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<section className="crafting-panel">
|
|
<EquipmentHeading
|
|
eyebrow="Crafting"
|
|
title="Workbench"
|
|
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
|
|
/>
|
|
<div className="crafting-layout">
|
|
<aside className="crafting-filters">
|
|
<EquipmentHeading
|
|
eyebrow="Slots"
|
|
title="Gear Slots"
|
|
detail={slotFilter === 'all' ? 'All' : SLOT_LABELS[slotFilter]}
|
|
/>
|
|
<div>
|
|
<div className="crafting-filter-grid">
|
|
<button
|
|
className={slotFilter === 'all' ? 'active' : ''}
|
|
onClick={() => {
|
|
setSlotFilter('all')
|
|
setRecipePage(0)
|
|
}}
|
|
type="button"
|
|
>
|
|
<strong>All</strong>
|
|
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
|
</button>
|
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
|
<button
|
|
className={slotFilter === slot ? 'active' : ''}
|
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
|
key={slot}
|
|
onClick={() => {
|
|
setSlotFilter(slot)
|
|
setRecipePage(0)
|
|
}}
|
|
type="button"
|
|
>
|
|
<strong>{SLOT_LABELS[slot]}</strong>
|
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="eyebrow">Item Level</p>
|
|
<div className="crafting-level-row">
|
|
<button
|
|
className={levelFilter === null ? 'active' : ''}
|
|
onClick={() => {
|
|
setLevelFilter(null)
|
|
setRecipePage(0)
|
|
}}
|
|
type="button"
|
|
>
|
|
All
|
|
</button>
|
|
{availableLevels.map((level) => (
|
|
<button
|
|
className={levelFilter === level ? 'active' : ''}
|
|
key={level}
|
|
onClick={() => {
|
|
setLevelFilter(level)
|
|
setRecipePage(0)
|
|
}}
|
|
type="button"
|
|
>
|
|
{level}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="crafting-available-panel">
|
|
<section className="crafting-list-panel">
|
|
<EquipmentHeading
|
|
eyebrow="Available Gear"
|
|
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
|
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
|
/>
|
|
{filteredRecipes.length === 0 ? (
|
|
<p className="inventory-empty">No recipes match filters.</p>
|
|
) : (
|
|
<div className="crafting-list">
|
|
{recipePageItems.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 className={recipe.canCraft ? 'ready' : 'missing'}>
|
|
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
|
</i>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
|
<ListPager
|
|
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
|
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
|
|
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
|
|
nextDisabled={recipePage >= recipePageCount - 1}
|
|
previousDisabled={recipePage <= 0}
|
|
/>
|
|
)}
|
|
<div className="crafting-action-row">
|
|
<button
|
|
className="primary-button"
|
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
|
onClick={craftSelected}
|
|
type="button"
|
|
>
|
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="crafting-detail-panel">
|
|
{selectedRecipe ? (
|
|
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
|
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
|
<div className="crafting-detail-heading">
|
|
<p className="eyebrow">Materials</p>
|
|
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
|
</div>
|
|
<div className="crafting-components">
|
|
{selectedRecipe.components.length === 0 && (
|
|
<p className="inventory-empty">No materials configured. Crafting disabled.</p>
|
|
)}
|
|
{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>
|
|
</div>
|
|
) : (
|
|
<p className="inventory-empty">Select a recipe.</p>
|
|
)}
|
|
</section>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{equipmentTab === 'equipment' && 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 ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
|
}
|
|
|
|
return (
|
|
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
|
{content}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ListPager({
|
|
label,
|
|
nextDisabled,
|
|
previousDisabled,
|
|
onNext,
|
|
onPrevious,
|
|
}: {
|
|
label: string
|
|
nextDisabled: boolean
|
|
previousDisabled: boolean
|
|
onNext: () => void
|
|
onPrevious: () => void
|
|
}) {
|
|
return (
|
|
<div className="list-pager">
|
|
<button disabled={previousDisabled} onClick={onPrevious} type="button">Prev</button>
|
|
<span>{label}</span>
|
|
<button disabled={nextDisabled} onClick={onNext} type="button">Next</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|