Add deck analysis panel with mana production/consumption bars and oracle text search

Threads a new produced_mana/produces_colorless field through the whole card
read path (ingest, library, filters, decks, search) so the deck view can
show what colors a deck's manabase actually produces, alongside its existing
color identity. Also adds a mana consumption bar (colored casting costs of
nonland spells) and stacks the mana curve histogram by color. Adds an
oracle-text substring filter to the library search, mirroring the existing
location filter. Rearranges the deck sidebar list into two rows (name, then
format chip + actions) so long names and action buttons no longer collide,
and removes the drop shadows from the commander hero panel/image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:54:47 -05:00
parent 4ac536caa2
commit e3f8a299de
17 changed files with 1054 additions and 161 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
import {
CardThumb,
CardTile,
@@ -10,6 +10,7 @@ import {
LocationField,
PromptDialog,
QtyStepper,
TagInput,
ViewToggle,
groupCards,
usePersistedState,
@@ -21,6 +22,8 @@ import {
// user a window to undo an accidental removal by clicking + again.
const REMOVAL_GRACE_MS = 5000;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/** Browse & filter the owned library by name and card criteria. */
export function LibraryBrowser() {
const [text, setText] = useState("");
@@ -28,11 +31,13 @@ export function LibraryBrowser() {
const [colorsInclude, setColorsInclude] = useState(0);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [type, setType] = useState("");
const [subtype, setSubtype] = useState("");
const [keyword, setKeyword] = useState("");
const [types, setTypes] = useState<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
@@ -53,6 +58,12 @@ export function LibraryBrowser() {
api.listDecks().then(setDecks);
}, []);
// Refetch whenever refreshKey bumps too, so newly-owned cards' types
// become suggestible without restarting the app.
useEffect(() => {
api.getLibraryFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
}, [refreshKey]);
// Cancel any in-flight removal timers on unmount so they don't fire against
// an unmounted component.
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
@@ -81,11 +92,12 @@ export function LibraryBrowser() {
colors_include: colorsInclude || null,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types: splitCsv(type),
subtypes: splitCsv(subtype),
keywords: splitCsv(keyword),
types,
subtypes,
keywords,
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
@@ -97,11 +109,12 @@ export function LibraryBrowser() {
colorsInclude,
cmcMin,
cmcMax,
type,
subtype,
keyword,
types,
subtypes,
keywords,
inDeck,
location,
oracleText,
refreshKey,
]);
@@ -446,15 +459,25 @@ export function LibraryBrowser() {
</div>
<div className="field">
<label>Type(s)</label>
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="Creature, Legendary" />
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
</div>
<div className="field">
<label>Subtype(s)</label>
<input value={subtype} onChange={(e) => setSubtype(e.target.value)} placeholder="Elf" />
<TagInput
values={subtypes}
onChange={setSubtypes}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="Flying" />
<TagInput
values={keywords}
onChange={setKeywords}
suggestions={facets.keywords}
placeholder="Flying…"
/>
</div>
<div className="field">
<label>In deck</label>
@@ -475,6 +498,14 @@ export function LibraryBrowser() {
placeholder="e.g. Box 1"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</div>
</div>
{rows.length === 0 ? (
@@ -496,10 +527,3 @@ export function LibraryBrowser() {
</div>
);
}
function splitCsv(s: string): string[] {
return s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}