Files
healer-man/src/ui/BossCardCollection.tsx
T
2026-09-14 13:20:20 -04:00

104 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />;
}