Reorganizes equipment list into by-slot so you can tag BiS items
This commit is contained in:
parent
7556fade3d
commit
faf5ee8a2d
1
package-lock.json
generated
1
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
.item {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itemLink {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -7,3 +7,7 @@
|
||||
.itemList {
|
||||
grid-column: 1 / span 4;
|
||||
}
|
||||
|
||||
.visibleQuality {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
12
src/components/TagList.module.css
Normal file
12
src/components/TagList.module.css
Normal 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;
|
||||
}
|
||||
18
src/components/TagList.tsx
Normal file
18
src/components/TagList.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user