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

This commit is contained in:
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": { "dependencies": {
"@headlessui/react": "^2.2.7", "@headlessui/react": "^2.2.7",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
"clsx": "^2.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"

View File

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

View File

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

View File

@@ -1,24 +1,35 @@
.equipedItems { .equipedItems {
display: grid; display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
grid-template-columns: 10em 1fr 1fr 5em;
align-items: stretch; align-items: stretch;
} }
.itemList {
display: grid;
grid-template-columns: 1fr 10em 5em;
}
.gridItem { .gridItem {
padding: 4px 8px; padding: 4px 8px;
border: 1px solid #666; border: 1px solid #666;
} }
.slot { .slot {
grid-column: 1; font-size: 120%;
background-color: #666;
padding: 4px 8px;
} }
.item { .item {
grid-column: 2; grid-column: 1;
} }
.quality { .quality {
grid-column: 2;
}
.bis {
grid-column: 3; 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 type { State } from "../lib/state";
import { Slots, type Item, type Slot } from "../lib/types"; import { Slots, type Item, type Slot } from "../lib/types";
import { ItemLink } from "./ItemLink"; import { ItemLink } from "./ItemLink";
import { ItemTypeahead } from "./ItemTypeahead"; import { ItemTypeahead } from "./ItemTypeahead";
import styles from "./Equipment.module.css"; import styles from "./Equipment.module.css";
import { QualitySelector } from "./QualitySelector"; import { QualitySelector } from "./QualitySelector";
import { useState } from "react";
import { useAppState } from "../lib/context/StateContext";
export type Props = { export type Props = {
state: State; state: State;
@@ -19,14 +21,7 @@ export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
<div className={styles.equipedItems}> <div className={styles.equipedItems}>
{Slots.map((slot) => { {Slots.map((slot) => {
if (itemsPerSlot(slot, state.weaponConfig) > 0) { if (itemsPerSlot(slot, state.weaponConfig) > 0) {
return ( return <Slot slot={slot} onUnequip={onUnequip} key={slot} />;
<Slot
state={state}
slot={slot}
onUnequip={onUnequip}
key={slot}
/>
);
} }
return null; return null;
})} })}
@@ -36,42 +31,64 @@ export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
}; };
type SlotProps = { type SlotProps = {
state: State;
slot: Slot; slot: Slot;
onUnequip: (item: Item) => void; onUnequip: (item: Item) => void;
}; };
const Slot = ({ state, slot, onUnequip }: SlotProps) => { const Slot = ({ slot, onUnequip }: SlotProps) => {
const items = state.equipedItems const [isOpen, setIsOpen] = useState(false);
const { state, dispatch } = useAppState();
const equipedItems = state.equipedItems
.map((item) => ({ ...item, item: ItemsById[item.id] })) .map((item) => ({ ...item, item: ItemsById[item.id] }))
.filter((item) => item && item.item.slot === slot); .filter((item) => item && item.item.slot === slot);
const allItems = itemsForSlot(slot);
return ( return (
<> <>
<div className={`${styles.slot} ${styles.gridItem}`}>{slot}</div> <div className={`${styles.slot}`} onClick={() => setIsOpen(!isOpen)}>
{ {slot}
// Show placeholder if there are no items </div>
items.length === 0 && ( {isOpen && (
<> <div className={`${styles.itemList}`}>
<div className={`${styles.gridItem} ${styles.item}`}></div> {allItems.map((item) => {
<div className={`${styles.gridItem} ${styles.quality}`}></div> const equiped = equipedItems.find((e) => e.item.id === item.id);
<div className={`${styles.gridItem} ${styles.actions}`}></div> const isBis = state.bisList.includes(item.id);
</> return (
)
}
{items.map((item) => (
<> <>
<div className={`${styles.gridItem} ${styles.item}`}> <div className={`${styles.gridItem} ${styles.item}`}>
<ItemLink item={item.item} /> <ItemLink item={item} />
</div> </div>
<div className={`${styles.gridItem} ${styles.quality}`}> <div className={`${styles.gridItem} ${styles.quality}`}>
<QualitySelector item={item} /> <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>
<div className={`${styles.gridItem} ${styles.actions}`}> <div className={`${styles.gridItem} ${styles.actions}`}>
<button onClick={() => onUnequip(item.item)}>X</button> {equiped && (
<button onClick={() => onUnequip(item)}>X</button>
)}
</div> </div>
</> </>
))} );
})}
</div>
)}
</> </>
); );
}; };

View File

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

View File

@@ -1,11 +1,13 @@
import type { Item } from "../lib/types"; 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 = { export type Props = {
item: Item; item: Item;
}; };
export const ItemLink = ({ item }: Props) => ( export const ItemLink = ({ item }: Props) => (
<span className={styles.item}>
<a <a
href={`https://www.wowhead.com/item=${item.id}`} href={`https://www.wowhead.com/item=${item.id}`}
target="_blank" target="_blank"
@@ -19,4 +21,6 @@ export const ItemLink = ({ item }: Props) => (
)} )}
{item.name} {item.name}
</a> </a>
{item.tags && <TagList tags={item.tags} />}
</span>
); );

View File

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

View File

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

View File

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

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 = [ export const ManaforgeOmega = [
{ {
@@ -6,36 +6,42 @@ export const ManaforgeOmega = [
name: "Half-Mask of Fallen Storms", name: "Half-Mask of Fallen Storms",
slot: "head", slot: "head",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Tier],
}, },
{ {
id: 237674 as ItemId, id: 237674 as ItemId,
name: "Grasp of Fallen Storms", name: "Grasp of Fallen Storms",
slot: "hands", slot: "hands",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Tier],
}, },
{ {
id: 237676 as ItemId, id: 237676 as ItemId,
name: "Gi of Fallen Storms", name: "Gi of Fallen Storms",
slot: "chest", slot: "chest",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Tier],
}, },
{ {
id: 237672 as ItemId, id: 237672 as ItemId,
name: "Legwraps of Fallen Storms", name: "Legwraps of Fallen Storms",
slot: "legs", slot: "legs",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Tier],
}, },
{ {
id: 237671 as ItemId, id: 237671 as ItemId,
name: "Glyphs of Fallen Storms", name: "Glyphs of Fallen Storms",
slot: "shoulders", slot: "shoulders",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Tier],
}, },
{ {
id: 235799 as ItemId, id: 235799 as ItemId,
name: "Reshii Wraps", name: "Reshii Wraps",
slot: "back", slot: "back",
source: "manaforge omega", source: "manaforge omega",
tags: [Tag.Quest],
}, },
{ {
id: 242395, id: 242395,

View File

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

View File

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

View File

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