Reorganizes equipment list into by-slot so you can tag BiS items

This commit is contained in:
Drew Haven 2025-08-22 14:16:44 -07:00
parent 7556fade3d
commit faf5ee8a2d
16 changed files with 245 additions and 111 deletions

1
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@headlessui/react": "^2.2.7",
"@types/lodash": "^4.17.20",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"react": "^19.1.1",
"react-dom": "^19.1.1"

View File

@ -12,6 +12,7 @@
"dependencies": {
"@headlessui/react": "^2.2.7",
"@types/lodash": "^4.17.20",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"react": "^19.1.1",
"react-dom": "^19.1.1"

View File

@ -2,4 +2,5 @@
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
width: 100%;
}

View File

@ -1,24 +1,35 @@
.equipedItems {
display: grid;
display: flex;
flex-direction: column;
gap: 8px;
grid-template-columns: 10em 1fr 1fr 5em;
align-items: stretch;
}
.itemList {
display: grid;
grid-template-columns: 1fr 10em 5em;
}
.gridItem {
padding: 4px 8px;
border: 1px solid #666;
}
.slot {
grid-column: 1;
font-size: 120%;
background-color: #666;
padding: 4px 8px;
}
.item {
grid-column: 2;
grid-column: 1;
}
.quality {
grid-column: 2;
}
.bis {
grid-column: 3;
}

View File

@ -1,10 +1,12 @@
import { ItemsById, itemsPerSlot } from "../lib/items";
import { ItemsById, itemsForSlot, itemsPerSlot } from "../lib/items";
import type { State } from "../lib/state";
import { Slots, type Item, type Slot } from "../lib/types";
import { ItemLink } from "./ItemLink";
import { ItemTypeahead } from "./ItemTypeahead";
import styles from "./Equipment.module.css";
import { QualitySelector } from "./QualitySelector";
import { useState } from "react";
import { useAppState } from "../lib/context/StateContext";
export type Props = {
state: State;
@ -19,14 +21,7 @@ export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
<div className={styles.equipedItems}>
{Slots.map((slot) => {
if (itemsPerSlot(slot, state.weaponConfig) > 0) {
return (
<Slot
state={state}
slot={slot}
onUnequip={onUnequip}
key={slot}
/>
);
return <Slot slot={slot} onUnequip={onUnequip} key={slot} />;
}
return null;
})}
@ -36,42 +31,64 @@ export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
};
type SlotProps = {
state: State;
slot: Slot;
onUnequip: (item: Item) => void;
};
const Slot = ({ state, slot, onUnequip }: SlotProps) => {
const items = state.equipedItems
const Slot = ({ slot, onUnequip }: SlotProps) => {
const [isOpen, setIsOpen] = useState(false);
const { state, dispatch } = useAppState();
const equipedItems = state.equipedItems
.map((item) => ({ ...item, item: ItemsById[item.id] }))
.filter((item) => item && item.item.slot === slot);
const allItems = itemsForSlot(slot);
return (
<>
<div className={`${styles.slot} ${styles.gridItem}`}>{slot}</div>
{
// Show placeholder if there are no items
items.length === 0 && (
<>
<div className={`${styles.gridItem} ${styles.item}`}></div>
<div className={`${styles.gridItem} ${styles.quality}`}></div>
<div className={`${styles.gridItem} ${styles.actions}`}></div>
</>
)
}
{items.map((item) => (
<>
<div className={`${styles.gridItem} ${styles.item}`}>
<ItemLink item={item.item} />
</div>
<div className={`${styles.gridItem} ${styles.quality}`}>
<QualitySelector item={item} />
</div>
<div className={`${styles.gridItem} ${styles.actions}`}>
<button onClick={() => onUnequip(item.item)}>X</button>
</div>
</>
))}
<div className={`${styles.slot}`} onClick={() => setIsOpen(!isOpen)}>
{slot}
</div>
{isOpen && (
<div className={`${styles.itemList}`}>
{allItems.map((item) => {
const equiped = equipedItems.find((e) => e.item.id === item.id);
const isBis = state.bisList.includes(item.id);
return (
<>
<div className={`${styles.gridItem} ${styles.item}`}>
<ItemLink item={item} />
</div>
<div className={`${styles.gridItem} ${styles.quality}`}>
<QualitySelector
itemId={item.id}
quality={equiped?.quality}
/>
</div>
<div className={`${styles.gridItem} ${styles.bis}`}>
<input
type="checkbox"
checked={isBis}
onClick={() =>
dispatch({
action: "setBis",
itemId: item.id,
isBis: !isBis,
})
}
/>
</div>
<div className={`${styles.gridItem} ${styles.actions}`}>
{equiped && (
<button onClick={() => onUnequip(item)}>X</button>
)}
</div>
</>
);
})}
</div>
)}
</>
);
};

View File

@ -1,3 +1,10 @@
.item {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.itemLink {
display: inline-flex;
flex-direction: row;

View File

@ -1,22 +1,26 @@
import type { Item } from "../lib/types";
import * as styles from "./ItemLink.module.css";
import styles from "./ItemLink.module.css";
import { TagList } from "./TagList";
export type Props = {
item: Item;
};
export const ItemLink = ({ item }: Props) => (
<a
href={`https://www.wowhead.com/item=${item.id}`}
target="_blank"
rel="noopener noreferrer"
className={styles.itemLink}
>
{item.icon && (
<img
src={`https://wow.zamimg.com/images/wow/icons/medium/${item.icon}.jpg`}
/>
)}
{item.name}
</a>
<span className={styles.item}>
<a
href={`https://www.wowhead.com/item=${item.id}`}
target="_blank"
rel="noopener noreferrer"
className={styles.itemLink}
>
{item.icon && (
<img
src={`https://wow.zamimg.com/images/wow/icons/medium/${item.icon}.jpg`}
/>
)}
{item.name}
</a>
{item.tags && <TagList tags={item.tags} />}
</span>
);

View File

@ -1,22 +1,24 @@
import { useAppState } from "../lib/context/StateContext";
import { Quality, type EquipedItem } from "../lib/types";
import { Quality, type ItemId } from "../lib/types";
export type Props = {
item: EquipedItem;
itemId: ItemId;
quality?: Quality;
};
export const QualitySelector = ({ item }: Props) => {
export const QualitySelector = ({ itemId, quality }: Props) => {
const { dispatch } = useAppState();
const handleOnChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
dispatch({
action: "changeQuality",
itemId: item.id,
itemId: itemId,
quality: event.target.value as Quality,
});
};
return (
<select onChange={handleOnChange} value={item.quality}>
<select onChange={handleOnChange} value={quality}>
<option value=""></option>
{Object.values(Quality).map((quality) => (
<option value={quality}>
{quality.charAt(0).toUpperCase() + quality.slice(1)}

View File

@ -7,3 +7,7 @@
.itemList {
grid-column: 1 / span 4;
}
.visibleQuality {
font-weight: bold;
}

View File

@ -10,13 +10,15 @@ import {
} from "../lib/types";
import { ItemLink } from "./ItemLink";
import styles from "./SourceList.module.css";
import { clsx } from "clsx";
import { TagList } from "./TagList";
export type Props = {
state: State;
};
export const SourceList = ({ state }: Props) => {
const upgrades = getUpgrades(state.equipedItems, state.weaponConfig);
const upgrades = getUpgrades(state);
const upgradesBySource = _.groupBy(
upgrades,
@ -43,61 +45,77 @@ type SourceInfoProps = {
items: Upgrade[];
};
const QualitiesInGrid = [
UpgradeType.Champion,
UpgradeType.Hero,
UpgradeType.Myth,
] as UpgradeType[];
const SourceInfo = ({ source, items }: SourceInfoProps) => {
const [upgradeTypeShown, setUpgradeTypeShown] = useState<UpgradeType | null>(
null,
);
const upgrades = {
[UpgradeType.Champion]: items.filter(hasUpgradeType(UpgradeType.Champion)),
[UpgradeType.Hero]: items.filter(hasUpgradeType(UpgradeType.Hero)),
[UpgradeType.Myth]: items.filter(hasUpgradeType(UpgradeType.Myth)),
};
const championUpgrades = items.filter(hasUpgradeType(UpgradeType.Champion));
const heroUpgrades = items.filter(hasUpgradeType(UpgradeType.Hero));
const mythUpgrades = items.filter(hasUpgradeType(UpgradeType.Myth));
const numBiS = {
[UpgradeType.Champion]: upgrades[UpgradeType.Champion].filter(
hasUpgradeType(UpgradeType.BiS),
).length,
[UpgradeType.Hero]: upgrades[UpgradeType.Hero].filter(
hasUpgradeType(UpgradeType.BiS),
).length,
[UpgradeType.Myth]: upgrades[UpgradeType.Myth].filter(
hasUpgradeType(UpgradeType.BiS),
).length,
};
const toggleIsOpen = (upgradeType: UpgradeType) => {
if (upgradeTypeShown === upgradeType) {
setUpgradeTypeShown(null);
} else {
setUpgradeTypeShown(upgradeType);
}
setUpgradeTypeShown(upgradeType);
};
return (
<>
<div>{source}</div>
<div onClick={() => toggleIsOpen(UpgradeType.Champion)}>
{championUpgrades.length}
</div>
<div onClick={() => toggleIsOpen(UpgradeType.Hero)}>
{heroUpgrades.length}
</div>
<div onClick={() => toggleIsOpen(UpgradeType.Myth)}>
{mythUpgrades.length}
</div>
{upgradeTypeShown === UpgradeType.Champion && (
<div className={styles.itemList}>
{championUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} /> ({upgrade.item.slot})
</div>
))}
{QualitiesInGrid.map((quality) => (
<div
key={quality}
className={clsx({
[styles.visibleQuality]: upgradeTypeShown === quality,
})}
onClick={() => toggleIsOpen(quality)}
>
{upgrades[quality].length}{" "}
{numBiS[quality] > 0 && `(${numBiS[quality]})`}
</div>
)}
{upgradeTypeShown === UpgradeType.Hero && (
<div className={styles.itemList}>
{heroUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} />
</div>
))}
</div>
)}
{upgradeTypeShown === UpgradeType.Myth && (
<div className={styles.itemList}>
{mythUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} />
</div>
))}
</div>
)}
))}
{QualitiesInGrid.map((quality) => {
if (upgradeTypeShown !== quality) {
return null;
}
return (
<div className={styles.itemList} key={quality}>
{upgrades[quality].map((upgrade) => (
<div>
<ItemLink item={upgrade.item} />{" "}
<TagList
tags={[
upgrade.item.slot,
upgrade.upgradeTypes.includes(UpgradeType.BiS) && "BiS",
]}
/>
</div>
))}
</div>
);
})}
</>
);
};

View File

@ -0,0 +1,12 @@
.tagList {
display: inline-flex;
flex-direction: row;
gap: 4px;
font-size: 14px;
}
.tag {
display: inline-block;
border: 1px solid #666;
padding: 2px 6px;
}

View File

@ -0,0 +1,18 @@
import styles from "./TagList.module.css";
export type Props = {
tags: (string | undefined | null | false)[];
};
export const TagList = ({ tags }: Props) => {
const validTags = tags.filter((tag): tag is string => Boolean(tag));
return (
validTags.length > 0 && (
<span className={styles.tagList}>
{validTags.map((tag) => (
<span className={styles.tag}>{tag}</span>
))}
</span>
)
);
};

View File

@ -1,4 +1,4 @@
import type { ItemId } from "../types";
import { Tag, type ItemId } from "../types";
export const ManaforgeOmega = [
{
@ -6,36 +6,42 @@ export const ManaforgeOmega = [
name: "Half-Mask of Fallen Storms",
slot: "head",
source: "manaforge omega",
tags: [Tag.Tier],
},
{
id: 237674 as ItemId,
name: "Grasp of Fallen Storms",
slot: "hands",
source: "manaforge omega",
tags: [Tag.Tier],
},
{
id: 237676 as ItemId,
name: "Gi of Fallen Storms",
slot: "chest",
source: "manaforge omega",
tags: [Tag.Tier],
},
{
id: 237672 as ItemId,
name: "Legwraps of Fallen Storms",
slot: "legs",
source: "manaforge omega",
tags: [Tag.Tier],
},
{
id: 237671 as ItemId,
name: "Glyphs of Fallen Storms",
slot: "shoulders",
source: "manaforge omega",
tags: [Tag.Tier],
},
{
id: 235799 as ItemId,
name: "Reshii Wraps",
slot: "back",
source: "manaforge omega",
tags: [Tag.Quest],
},
{
id: 242395,

View File

@ -1,15 +1,15 @@
import _ from "lodash";
import { AllItems } from "./drops";
import type { State } from "./state";
import {
UpgradeType,
WeaponConfig,
type EquipedItem,
type Item,
type ItemId,
type Quality,
type Slot,
type Upgrade,
} from "./types";
import { AllItems } from "./drops";
export const ItemsById: Record<string, Item> = AllItems.reduce(
(db, item) => {
@ -66,25 +66,23 @@ export const upgradesForItem = ({ quality }: { quality: Quality }) => {
}
};
export const getUpgrades = (
equipedItemIds: EquipedItem[],
weaponConfig: WeaponConfig,
): Upgrade[] => {
export const getUpgrades = ({
equipedItems,
bisList,
weaponConfig,
}: State): Upgrade[] => {
return Object.entries(ItemsBySlot).flatMap(([slot, items]) => {
const equipedItems = equipedItemIds.flatMap(({ id, quality }) => ({
const equipedItemsHydrated = equipedItems.flatMap(({ id, quality }) => ({
item: ItemsById[id],
quality,
}));
const equipedInSlot = equipedItems.filter(
const equipedInSlot = equipedItemsHydrated.filter(
(item) => item.item.slot === slot,
);
const itemsForSlot = itemsPerSlot(slot as Slot, weaponConfig);
if (itemsForSlot === 0) {
console.log(
`Skipping ${slot} because it has no items with ${weaponConfig}`,
);
return [];
}
@ -97,7 +95,20 @@ export const getUpgrades = (
numMyth >= itemsForSlot ? [] : [UpgradeType.Myth],
].flat();
return items.map((item) => {
const isBis = bisList.includes(item.id);
const equiped = equipedInSlot.find((i) => i.item.id === item.id);
// Bis items are always desired at any better quality
if (isBis) {
return {
item,
upgradeTypes: equiped
? [...upgradesForItem(equiped), UpgradeType.BiS]
: Object.values(UpgradeType),
};
}
// If it's equiped, only show higher upgrades
if (equiped) {
const upgradesForThisItem = upgradesForItem(equiped);
return {

View File

@ -27,6 +27,11 @@ export type Action =
itemId: ItemId;
quality: Quality;
}
| {
action: "setBis";
itemId: ItemId;
isBis: boolean;
}
| {
action: "changeWeaponConfig";
weaponConfig: WeaponConfig;
@ -65,6 +70,14 @@ export const reducer = (state: State, action: Action): State => {
return item;
}),
};
case "setBis":
return {
...state,
bisList: [
...state.bisList.filter((id) => id !== action.itemId),
...(action.isBis ? [action.itemId] : []),
],
};
case "changeWeaponConfig":
return {
...state,

View File

@ -48,6 +48,7 @@ export type Item = {
name: string;
source: Source;
icon?: string; // Wowhead icon name (not ID)
tags?: Tag[];
};
export type EquipedItem = {
@ -74,3 +75,10 @@ export const WeaponConfig = {
};
export type WeaponConfig = (typeof WeaponConfig)[keyof typeof WeaponConfig];
export const Tag = {
Tier: "tier",
Quest: "quest",
};
export type Tag = (typeof Tag)[keyof typeof Tag];