forked from phenom/healer-man
104 lines
7.8 KiB
TypeScript
104 lines
7.8 KiB
TypeScript
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
|
||
import { useShellStore } from "../app/shellStore";
|
||
import { useGameStore } from "../game/store";
|
||
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
|
||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
|
||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||
import { ControllerButton } from "./ControllerButton";
|
||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||
import "./bossCards.css";
|
||
|
||
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
|
||
const PAGE_SIZE = 8;
|
||
|
||
export function BossCardCollection({ onClose, collection }: {
|
||
readonly onClose: () => void;
|
||
readonly collection: Collection;
|
||
}) {
|
||
useContentManagerSnapshot();
|
||
const [earnedOnly, setEarnedOnly] = useState(false);
|
||
const [grouping, setGrouping] = useState<BossCardGrouping>("all");
|
||
const [category, setCategory] = useState<"all" | "wow" | "rom">("all");
|
||
const [search, setSearch] = useState("");
|
||
const [page, setPage] = useState(0);
|
||
const groups = useMemo(() => groupBossCards(collection, { earnedOnly, grouping, search, category }), [collection, earnedOnly, grouping, search, category]);
|
||
const rows = useMemo(() => groups.flatMap((group) => group.rows.map((row) => ({ ...row, group }))), [groups]);
|
||
const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||
const currentPage = Math.min(page, pageCount - 1);
|
||
const visibleRows = rows.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
|
||
const summary = useMemo(() => {
|
||
const counts = BOSS_CARD_CATALOG.flatMap((boss) => BOSS_CARD_RARITIES.map((rarity) => collection.counts[boss.key]?.[rarity] ?? 0));
|
||
return { earned: counts.filter((copies) => copies > 0).length, copies: counts.reduce((sum, copies) => sum + copies, 0), total: counts.length };
|
||
}, [collection]);
|
||
const filter = (earned: boolean) => { setEarnedOnly(earned); setPage(0); };
|
||
const groupBy = (value: BossCardGrouping) => { setGrouping(value); setPage(0); };
|
||
const actions: MenuAction[] = [
|
||
{ id: "cards-close", run: onClose },
|
||
{ id: "cards-all", run: () => filter(false) },
|
||
{ id: "cards-earned", run: () => filter(true) },
|
||
{ id: "cards-boss", run: () => groupBy("all") },
|
||
{ id: "cards-dungeon", run: () => groupBy("dungeon") },
|
||
{ id: "cards-rarity", run: () => groupBy("rarity") },
|
||
{ id: "cards-prev", enabled: currentPage > 0, run: () => setPage(currentPage - 1) },
|
||
{ id: "cards-next", enabled: currentPage + 1 < pageCount, run: () => setPage(currentPage + 1) },
|
||
];
|
||
const controller = useMenuController(actions, { initialId: "cards-all", onBack: onClose });
|
||
const controlProps = { selectedId: controller.selectedId, select: controller.select };
|
||
|
||
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
|
||
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
|
||
<header className="boss-collection__header">
|
||
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
|
||
</header>
|
||
<div className="boss-collection__summary">
|
||
<strong>{summary.earned} / {summary.total} cards earned</strong><span>{summary.copies.toLocaleString()} total copies</span>
|
||
<p>Every boss awards Normal. Rare, Epic, and Legendary roll independently as bonus cards.</p>
|
||
</div>
|
||
<div className="boss-collection__tools">
|
||
<div role="group" aria-label="Collection filter">
|
||
<ControllerButton {...controlProps} controlId="cards-all" aria-pressed={!earnedOnly} onClick={() => filter(false)}>All</ControllerButton>
|
||
<ControllerButton {...controlProps} controlId="cards-earned" aria-pressed={earnedOnly} onClick={() => filter(true)}>Earned</ControllerButton>
|
||
</div>
|
||
<div role="group" aria-label="Group cards">
|
||
<ControllerButton {...controlProps} controlId="cards-boss" aria-pressed={grouping === "all"} onClick={() => groupBy("all")}>By boss</ControllerButton>
|
||
<ControllerButton {...controlProps} controlId="cards-dungeon" aria-pressed={grouping === "dungeon"} onClick={() => groupBy("dungeon")}>Group by dungeon</ControllerButton>
|
||
<ControllerButton {...controlProps} controlId="cards-rarity" aria-pressed={grouping === "rarity"} onClick={() => groupBy("rarity")}>Group by rarity</ControllerButton>
|
||
</div>
|
||
<label className="boss-collection__search">Search<input type="search" placeholder="Boss or dungeon name" value={search} onChange={(event) => { setSearch(event.target.value); setPage(0); }} /></label>
|
||
<label>World<select value={category} onChange={(event) => { setCategory(event.target.value as typeof category); setPage(0); }}><option value="all">All worlds</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
|
||
</div>
|
||
<div className="boss-collection__pages" aria-label="Collection pages">
|
||
<ControllerButton {...controlProps} controlId="cards-prev" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous</ControllerButton>
|
||
<span role="status">Page {currentPage + 1} / {pageCount} · {rows.length} card{rows.length === 1 ? "" : "s"}{grouping === "dungeon" ? " across dungeons" : ""}</span>
|
||
<ControllerButton {...controlProps} controlId="cards-next" disabled={currentPage + 1 >= pageCount} onClick={() => setPage(currentPage + 1)}>Next</ControllerButton>
|
||
</div>
|
||
<div className="boss-collection__scroll">
|
||
{visibleRows.length ? <div className="boss-collection__grid">
|
||
{visibleRows.map((row, index) => {
|
||
const rarity = BOSS_CARD_RARITY_INFO[row.rarity];
|
||
return <div className="boss-collection__entry" key={`${row.group.key}:${row.boss.key}:${row.rarity}`}>
|
||
{grouping !== "all" && (index === 0 || visibleRows[index - 1].group.key !== row.group.key) && <h3 className="boss-collection__group-title">{row.group.title}</h3>}
|
||
<article className={`boss-card ${row.copies ? "boss-card--earned" : "boss-card--unearned"}`} style={{ "--card-color": rarity.color } as CSSProperties}>
|
||
<header><span>{rarity.label}</span><small>{rarity.odds}</small></header>
|
||
<Suspense fallback={<span className="boss-card__model-status">Loading boss model…</span>}><BossCardPortrait model={row.boss.model} name={row.boss.name} installed={row.boss.dungeons.some((entry) => dungeonContentIsUsable(entry.id))} /></Suspense>
|
||
<div className="boss-card__details"><h3>{row.boss.name}</h3><small>{row.boss.category === "wow" ? "World of Warcraft" : "Runes of Magic"}</small>
|
||
<p className="boss-card__dungeons">{row.boss.dungeons.map((entry) => entry.title).join(" · ")}</p>
|
||
</div>
|
||
<footer><strong>{row.copies.toLocaleString()} {row.copies === 1 ? "copy" : "copies"}</strong><span>{row.copies ? "Earned" : "Not earned"}</span></footer>
|
||
</article>
|
||
</div>;
|
||
})}
|
||
</div> : <p className="boss-collection__empty">{earnedOnly ? "No earned cards match these filters. Defeat a boss to begin your collection." : "No boss cards match your search."}</p>}
|
||
</div>
|
||
</section>
|
||
</div>;
|
||
}
|
||
|
||
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
|
||
const collection = useShellStore((state) => state.bossCards);
|
||
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
|
||
collection={collection} />;
|
||
}
|