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 = { 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') 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( firstItem?.id ?? null, ) const [selectedSlot, setSelectedSlot] = useState(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(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( firstRecipe?.id ?? null, ) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) const selectedRecipeRequiresUpgrade = selectedRecipe ? profile.craftingRecipes.some((recipe) => recipe.sourceEncounterId === selectedRecipe.sourceEncounterId && recipe.item.slot === selectedRecipe.item.slot && recipe.item.itemLevel < selectedRecipe.item.itemLevel, ) : false const selectedItemRecipe = selectedItem ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) : undefined const upgradeRecipe = selectedItem && selectedItemRecipe ? profile.craftingRecipes .filter((recipe) => recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId && recipe.item.slot === selectedItem.slot && recipe.item.itemLevel > selectedItem.itemLevel, ) .sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0] : 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('all') const [levelFilter, setLevelFilter] = useState(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], ) 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).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

Select an item to inspect it.

} if (selectedItem.slot === 'component') { return

Used in crafting.

} return ( <> {upgradeRecipe && ( )} {(!selectedItem.equipped || selectedItem.quantity > 1) && ( )} ) } const workshopState = useMemo(() => { 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 && (

Character Loadout

Equipment

)}
{profile.character.className[0]}

{profile.character.className}

{profile.character.name}

{showModeTabs && ( )} {equipmentTab === 'equipment' ? ( <>
{selectedItem ? ( selectedItem.slot === 'component' ? ( <> ) : ( <>
vs
{comparisonItem && comparisonItem.id !== selectedItem.id ? ( ) : (

Comparison

{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}

)} ) ) : (

Select an item to inspect it.

)}
{renderEquipmentActions()}
{profile.equipmentSlots.map((slot) => { const item = equippedBySlot.get(slot) return ( ) })}
{selectedSlot && ( )}
{inventoryPageItems.map((item) => ( ))} {visibleInventory.length === 0 && (

No {SLOT_LABELS[selectedSlot ?? 'component'].toLowerCase()} items owned.

)}
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && ( setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))} onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))} nextDisabled={inventoryPage >= inventoryPageCount - 1} previousDisabled={inventoryPage <= 0} /> )}
) : (
{filteredRecipes.length === 0 ? (

No recipes match filters.

) : (
{recipePageItems.map((recipe) => ( ))}
)} {filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && ( setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))} onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))} nextDisabled={recipePage >= recipePageCount - 1} previousDisabled={recipePage <= 0} /> )}
{selectedRecipe ? (

Materials

{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}
{selectedRecipe.components.map((component) => (
= component.quantity ? 'ready' : 'missing'} key={component.item.id} > {component.item.glyph} {component.item.name} {component.owned}/{component.quantity}
))}
) : (

Select a recipe.

)}
)} {equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (

Set Bonuses

Raid Sets

{showSetBonuses && (
{profile.setBonuses.map((bonus) => (
{bonus.requiredPieces} pieces {bonus.description} {bonus.equippedPieces}/{bonus.requiredPieces}
))}
)}
)}
{message || 'Equipment changes are saved immediately.'}
) if (embedded) { return
{content}
} return (
{content}
) } function ListPager({ label, nextDisabled, previousDisabled, onNext, onPrevious, }: { label: string nextDisabled: boolean previousDisabled: boolean onNext: () => void onPrevious: () => void }) { return (
{label}
) } function GearStat({ value, label }: { value: string; label: string }) { return (
{value} {label}
) } function EquipmentHeading({ eyebrow, title, detail, }: { eyebrow: string title: string detail?: string }) { return (

{eyebrow}

{title}

{detail && {detail}}
) } function ItemDetail({ title, item }: { title: string; item: Item }) { return (

{title}

{item.glyph}

{item.name}

{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}

{item.description}

{item.quantity > 1 &&

Owned: {item.quantity}

} {item.slot !== 'component' && (
  • +{item.healingPower} Healing Power
  • +{item.maxResourceBonus} Max Resource
)}
) } function ComparisonDelta({ selected, equipped, }: { selected: Item equipped?: Item }) { const healingDelta = selected.healingPower - (equipped?.healingPower ?? 0) const resourceDelta = selected.maxResourceBonus - (equipped?.maxResourceBonus ?? 0) return (
= 0 ? 'positive' : 'negative'}> {healingDelta >= 0 ? '+' : ''}{healingDelta} Healing = 0 ? 'positive' : 'negative'}> {resourceDelta >= 0 ? '+' : ''}{resourceDelta} Resource
) }