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

@@ -2,7 +2,14 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
import { HoverPreviewProvider, IconButton, Icons, usePersistedState } from "./components";
import {
ColorDistributionBar,
HoverPreviewProvider,
IconButton,
Icons,
StatTile,
usePersistedState,
} from "./components";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { DeckView } from "./views/DeckView";
@@ -284,49 +291,15 @@ export default function App() {
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="lib-stats-numbers">
<div className="lib-stat">
<span className="lib-stat-value">
{libStats.unique_cards.toLocaleString()}
</span>
<span className="lib-stat-label">unique</span>
</div>
<div className="lib-stat">
<span className="lib-stat-value">
{libStats.total_copies.toLocaleString()}
</span>
<span className="lib-stat-label">copies</span>
</div>
<div className="lib-stat">
<span className="lib-stat-value">
{libStats.deck_count.toLocaleString()}
</span>
<span className="lib-stat-label">decks</span>
</div>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
{libStats.total_copies > 0 && (
<div className="lib-color-bar">
{(
[
["W", libStats.color_counts[0]],
["U", libStats.color_counts[1]],
["B", libStats.color_counts[2]],
["R", libStats.color_counts[3]],
["G", libStats.color_counts[4]],
["C", libStats.colorless_count],
] as [string, number][]
).map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`lib-color-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()} copies`}
/>
) : null,
)}
</div>
)}
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">

View File

@@ -12,6 +12,8 @@ export interface CardRef {
type_line: string;
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C}
}
export interface LibraryCard extends CardRef {
@@ -47,6 +49,7 @@ export interface LibraryFilter {
keywords?: string[];
in_deck?: number | null;
location?: string | null;
oracle_text?: string | null;
}
export interface Status {
@@ -65,6 +68,12 @@ export interface LibraryStats {
colorless_count: number;
}
export interface LibraryFacets {
types: string[];
subtypes: string[];
keywords: string[];
}
export interface IngestStageInfo {
id: string;
label: string;
@@ -115,6 +124,7 @@ export const api = {
searchLibrary: (filter: LibraryFilter) =>
invoke<LibraryCard[]>("search_library", { filter }),
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
listDecks: () => invoke<Deck[]>("list_decks"),
createDeck: (name: string, commanderOracleId: string | null) =>

View File

@@ -1,5 +1,6 @@
import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
@@ -341,27 +342,27 @@ export function CardThumb({ card }: { card: CardRef }) {
* A search input plus a results dropdown with keyboard navigation:
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
* `value`/`onChange` are controlled by the caller (which runs the search).
* Forwards a ref to the underlying `<input>` so callers can focus it
* imperatively (e.g. redirecting a stray keystroke into the search box).
*/
export function CardSearchBox({
placeholder,
value,
onChange,
results,
onPick,
autoFocus,
big,
}: {
placeholder: string;
value: string;
onChange: (v: string) => void;
results: CardRef[];
onPick: (card: CardRef) => void;
autoFocus?: boolean;
/** Larger, more prominent input (used for the primary Add Cards field). */
big?: boolean;
}) {
export const CardSearchBox = forwardRef<
HTMLInputElement,
{
placeholder: string;
value: string;
onChange: (v: string) => void;
results: CardRef[];
onPick: (card: CardRef) => void;
autoFocus?: boolean;
/** Larger, more prominent input (used for the primary Add Cards field). */
big?: boolean;
}
>(function CardSearchBox(
{ placeholder, value, onChange, results, onPick, autoFocus, big },
forwardedRef,
) {
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => setSel(0), [results]);
// Keep focus in the box after a pick so cards can be added in a rapid streak.
@@ -386,7 +387,11 @@ export function CardSearchBox({
return (
<>
<input
ref={inputRef}
ref={(el) => {
inputRef.current = el;
if (typeof forwardedRef === "function") forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
}}
autoFocus={autoFocus}
style={{
width: "100%",
@@ -416,6 +421,110 @@ export function CardSearchBox({
)}
</>
);
});
/**
* A chip-based multi-value text filter with autocomplete: selected values
* render as removable chips, and typing filters `suggestions` into a
* dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the
* typed text verbatim, so it's not limited to only-suggested values).
* Backspace on an empty input quick-removes the last chip.
*/
export function TagInput({
values,
onChange,
suggestions,
placeholder,
}: {
values: string[];
onChange: (values: string[]) => void;
suggestions: string[];
placeholder?: string;
}) {
const [text, setText] = useState("");
const [sel, setSel] = useState(0);
const query = text.trim().toLowerCase();
const filtered = query
? suggestions
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
.filter((s) => s.toLowerCase().includes(query))
.slice(0, 8)
: [];
useEffect(() => setSel(0), [text]);
function addValue(v: string) {
const trimmed = v.trim();
if (!trimmed) return;
if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) {
onChange([...values, trimmed]);
}
setText("");
}
function removeValue(v: string) {
onChange(values.filter((x) => x !== v));
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.min(s + 1, filtered.length - 1));
} else if (e.key === "ArrowUp" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[sel]) addValue(filtered[sel]);
else if (text.trim()) addValue(text);
} else if (e.key === "Backspace" && text === "" && values.length > 0) {
onChange(values.slice(0, -1));
} else if (e.key === "Escape") {
setText("");
}
}
return (
<div className="tag-input">
<div className="tag-chips">
{values.map((v) => (
<span key={v} className="tag-chip">
{v}
<button
type="button"
className="tag-chip-remove"
onClick={() => removeValue(v)}
title={`Remove ${v}`}
>
×
</button>
</span>
))}
<input
className="tag-chip-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder={values.length === 0 ? placeholder : ""}
/>
</div>
{filtered.length > 0 && (
<ul className="result-list tag-suggestions">
{filtered.map((s, i) => (
<li
key={s}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => addValue(s)}
>
{s}
</li>
))}
</ul>
)}
</div>
);
}
// --- Hover + click preview -------------------------------------------------
@@ -666,6 +775,98 @@ export function GroupBySelect({
);
}
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
export function StatTile({ value, label }: { value: string | number; label: string }) {
return (
<div className="stat-tile">
<span className="stat-tile-value">{value}</span>
<span className="stat-tile-label">{label}</span>
</div>
);
}
/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */
export function ColorDistributionBar({
colorCounts,
colorlessCount,
}: {
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}) {
const segs: [string, number][] = [
["W", colorCounts[0]],
["U", colorCounts[1]],
["B", colorCounts[2]],
["R", colorCounts[3]],
["G", colorCounts[4]],
["C", colorlessCount],
];
if (segs.every(([, count]) => count === 0)) return null;
return (
<div className="color-bar">
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`color-bar-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
);
}
/** A mana-value histogram (bar height proportional to the largest bucket),
* each bar segmented by color like {@link ColorDistributionBar}. */
export function ManaCurve({
buckets,
}: {
buckets: {
label: string;
count: number;
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}[];
}) {
const max = Math.max(1, ...buckets.map((b) => b.count));
return (
<div className="mana-curve">
{buckets.map((b) => {
const segs: [string, number][] = [
["W", b.colorCounts[0]],
["U", b.colorCounts[1]],
["B", b.colorCounts[2]],
["R", b.colorCounts[3]],
["G", b.colorCounts[4]],
["C", b.colorlessCount],
];
return (
<div key={b.label} className="mana-curve-col">
<span className="mana-curve-count">{b.count || ""}</span>
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`mana-curve-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
<span className="mana-curve-label">{b.label}</span>
</div>
);
})}
</div>
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives

View File

@@ -349,12 +349,14 @@ input[type="checkbox"] {
color: var(--faint);
padding-bottom: 10px;
}
.lib-stats-numbers {
/* Generic numeric-stat tile row + segmented color bar — used by both the
sidebar "Your library" panel and the per-deck analysis panel. */
.stat-tiles-row {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.lib-stat {
.stat-tile {
flex: 1;
min-width: 0;
display: flex;
@@ -367,25 +369,25 @@ input[type="checkbox"] {
border-radius: var(--radius);
transition: border-color 0.15s, transform 0.15s;
}
.lib-stat:hover {
.stat-tile:hover {
border-color: var(--accent-glow);
transform: translateY(-1px);
}
.lib-stat-value {
.stat-tile-value {
font-size: 17px;
font-weight: 800;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
text-shadow: 0 0 14px var(--accent-glow);
}
.lib-stat-label {
.stat-tile-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
}
.lib-color-bar {
.color-bar {
display: flex;
height: 8px;
border-radius: 999px;
@@ -393,16 +395,63 @@ input[type="checkbox"] {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
margin-bottom: 9px;
}
.lib-color-seg {
.color-bar-seg {
min-width: 3px;
transition: flex-grow 0.4s ease;
}
.lib-color-seg.W { background: var(--mtg-w); }
.lib-color-seg.U { background: var(--mtg-u); }
.lib-color-seg.B { background: var(--mtg-b); }
.lib-color-seg.R { background: var(--mtg-r); }
.lib-color-seg.G { background: var(--mtg-g); }
.lib-color-seg.C { background: var(--mtg-c); }
.color-bar-seg.W { background: var(--mtg-w); }
.color-bar-seg.U { background: var(--mtg-u); }
.color-bar-seg.B { background: var(--mtg-b); }
.color-bar-seg.R { background: var(--mtg-r); }
.color-bar-seg.G { background: var(--mtg-g); }
.color-bar-seg.C { background: var(--mtg-c); }
/* Mana-value curve histogram (per-deck analysis panel). */
.mana-curve {
display: flex;
align-items: flex-end;
gap: 6px;
flex: 1;
min-height: 84px;
margin-top: 4px;
}
.mana-curve-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
gap: 3px;
}
.mana-curve-count {
font-size: 10px;
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.mana-curve-bar {
width: 100%;
min-height: 2px;
border-radius: 3px 3px 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: height 0.25s ease;
}
.mana-curve-seg {
width: 100%;
min-height: 1px;
}
.mana-curve-seg.W { background: var(--mtg-w); }
.mana-curve-seg.U { background: var(--mtg-u); }
.mana-curve-seg.B { background: var(--mtg-b); }
.mana-curve-seg.R { background: var(--mtg-r); }
.mana-curve-seg.G { background: var(--mtg-g); }
.mana-curve-seg.C { background: var(--mtg-c); }
.mana-curve-label {
font-size: 10px;
color: var(--faint);
}
.lib-stats-sub {
display: flex;
flex-direction: column;
@@ -863,6 +912,26 @@ li:focus-within .row-actions {
font-weight: 600;
color: var(--text-strong);
}
/* Deck list: name+format on one row, actions on their own row below, so
* long names don't push the action icons off the edge of the sidebar. */
.result-list.deck-list li {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.deck-list-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.deck-list-row .card-name {
overflow-wrap: break-word;
}
.deck-list-actions-row {
justify-content: space-between;
}
.card-type {
color: var(--muted);
font-size: 13px;
@@ -1022,6 +1091,83 @@ tbody tr:hover {
max-width: 160px;
}
/* ============================================================
TagInput: chip-based multi-value filter with autocomplete
(type/subtype/keyword filters in the Library tab)
============================================================ */
.tag-input {
position: relative;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-height: 34px;
padding: 5px 8px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color 0.12s, box-shadow 0.12s;
}
.tag-chips:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--accent-subtle);
color: var(--accent-hover);
border-radius: 999px;
padding: 2px 4px 2px 10px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: inherit;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.25);
}
.tag-chip-input {
flex: 1;
min-width: 70px;
border: none;
background: none;
padding: 3px 2px;
font-size: 14px;
}
.tag-chip-input:focus {
outline: none;
border: none;
box-shadow: none;
}
.tag-suggestions {
position: absolute;
z-index: 50;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
}
/* ============================================================
Card images: thumbnails, hover preview, tiles
============================================================ */
@@ -1301,3 +1447,103 @@ tbody tr:hover {
min-width: 280px;
flex: 1;
}
/* Prominent commander card display, above the fold on a Commander/Brawl deck. */
.commander-hero {
display: flex;
align-items: center;
gap: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.commander-hero-img {
width: 84px;
height: 117px;
flex-shrink: 0;
object-fit: cover;
border-radius: 8px;
cursor: zoom-in;
}
.commander-hero-noimg {
width: 84px;
height: 117px;
flex-shrink: 0;
border: 1px dashed var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 8px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.commander-hero-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.commander-hero-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-hover);
}
.commander-hero-name {
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
}
.commander-hero-type {
font-size: 12px;
color: var(--muted);
}
/* Per-deck analysis panel. */
.deck-stats {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.deck-stats-head {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
margin-bottom: 10px;
}
.deck-stats-body {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.deck-stats-numbers {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 220px;
}
.deck-stats-curve {
flex: 1;
min-width: 220px;
display: flex;
flex-direction: column;
}
.deck-stats-curve-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
margin-bottom: 2px;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, CardRef } from "../api";
import { CardSearchBox, QtyStepper } from "../components";
@@ -11,11 +11,38 @@ export function CardEntry() {
>([]);
// Persisted so a "box" location sticks while entering a stack of cards.
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
localStorage.setItem("add-location", location);
}, [location]);
// Typing anywhere on this tab — without having clicked into another field
// first — jumps focus to the search box and keeps the keystroke that
// triggered it, instead of it going nowhere.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const target = e.target;
const alreadyTyping =
target instanceof HTMLElement &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable);
if (alreadyTyping) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key.length !== 1) return; // printable characters only
// Let Space still activate a focused button rather than hijacking it.
if (target instanceof HTMLElement && target.tagName === "BUTTON" && e.key === " ") return;
e.preventDefault();
setQuery((q) => q + e.key);
searchInputRef.current?.focus();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Debounced fuzzy search.
useEffect(() => {
const q = query.trim();
@@ -63,6 +90,7 @@ export function CardEntry() {
</div>
<CardSearchBox
ref={searchInputRef}
big
autoFocus
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"

View File

@@ -1,19 +1,24 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { api, CardRef, Deck, DeckEntry } from "../api";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
import {
CardSearchBox,
CardThumb,
CardTile,
ColorDistributionBar,
ColorPips,
ConfirmDialog,
GroupBySelect,
GroupMode,
IconButton,
Icons,
ManaCurve,
PromptDialog,
QtyStepper,
StatTile,
ViewToggle,
groupCards,
useClickPreview,
useHoverPreview,
usePersistedState,
useViewMode,
} from "../components";
@@ -29,6 +34,140 @@ function friendlyDeckError(err: unknown): string {
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
}
interface DeckStats {
totalCards: number;
uniqueCards: number;
landCount: number;
avgManaValue: number;
curve: {
label: string;
count: number;
colorCounts: [number, number, number, number, number];
colorlessCount: number;
}[];
colorCounts: [number, number, number, number, number];
colorlessCount: number;
manaProduction: [number, number, number, number, number];
colorlessProduction: number;
manaConsumption: [number, number, number, number, number];
colorlessConsumption: number;
}
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) — matches the usual "99 + commander" framing even for
* non-Commander decks, where it's just "everything but the flagged card".
*/
function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
const avgManaValue = nonlandCount
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
: 0;
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
const curveBuckets = new Map(
bucketLabels.map((l) => [
l,
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
]),
);
for (const e of nonlands) {
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
const bucket = curveBuckets.get(label)!;
bucket.count += e.quantity;
if (e.color_identity === 0) {
bucket.colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
});
}
}
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessCount = 0;
for (const e of cards) {
if (e.color_identity === 0) {
colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
});
}
}
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessProduction = 0;
for (const e of cards) {
if (e.produces_colorless) colorlessProduction += e.quantity;
COLORS.forEach((c, i) => {
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
});
}
// Consumption: colored cost requirements of the spells actually cast —
// lands are excluded since they have no colored casting cost.
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessConsumption = 0;
for (const e of nonlands) {
if (e.colors === 0) {
colorlessConsumption += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
});
}
}
return {
totalCards,
uniqueCards: cards.length,
landCount,
avgManaValue,
curve,
colorCounts,
colorlessCount,
manaProduction,
colorlessProduction,
manaConsumption,
colorlessConsumption,
};
}
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
<div className="commander-hero">
{card.image_uri ? (
<img
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card)}
{...handlers(card)}
/>
) : (
<div className="commander-hero-noimg">{card.name}</div>
)}
<div className="commander-hero-info">
<span className="commander-hero-label">Commander</span>
<span className="commander-hero-name">{card.name}</span>
<span className="commander-hero-type">{card.type_line}</span>
<ColorPips mask={card.color_identity} />
</div>
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
</div>
);
}
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
// before actually dropping out of the deck — same grace-period pattern as
// the Library tab, so a mistaken removal can be undone with +.
@@ -289,10 +428,8 @@ export function DeckView() {
const owned = contents.filter((e) => e.quantity_owned > 0);
const unowned = contents.filter((e) => e.quantity_owned === 0);
const commanderName =
deck?.commander_oracle_id &&
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
deck.commander_oracle_id);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
function deckTiles(entries: DeckEntry[]) {
return (
@@ -409,45 +546,48 @@ export function DeckView() {
+
</button>
</div>
<ul className="result-list">
<ul className="result-list deck-list">
{decks.map((d) => (
<li
key={d.id}
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
onClick={() => setSelected(d.id)}
>
<span className="card-name">{d.name}</span>
{d.format && <span className="deck-chip">{d.format}</span>}
<span className="spacer" />
<span className="row-actions">
<IconButton
icon={Icons.pencil}
label="Rename deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "rename", deck: d });
}}
/>
<IconButton
icon={Icons.copy}
label="Duplicate deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "duplicate", deck: d });
}}
/>
<IconButton
icon={Icons.trash}
label="Delete deck"
danger
onClick={(e) => {
e.stopPropagation();
setDialog({ kind: "delete", deck: d });
}}
/>
</span>
<div className="deck-list-row">
<span className="card-name">{d.name}</span>
</div>
<div className="deck-list-row deck-list-actions-row">
{d.format ? <span className="deck-chip">{d.format}</span> : <span />}
<span className="row-actions">
<IconButton
icon={Icons.pencil}
label="Rename deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "rename", deck: d });
}}
/>
<IconButton
icon={Icons.copy}
label="Duplicate deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "duplicate", deck: d });
}}
/>
<IconButton
icon={Icons.trash}
label="Delete deck"
danger
onClick={(e) => {
e.stopPropagation();
setDialog({ kind: "delete", deck: d });
}}
/>
</span>
</div>
</li>
))}
{decks.length === 0 && (
@@ -500,6 +640,45 @@ export function DeckView() {
</div>
)}
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<CommanderHero card={commanderEntry} onClear={clearCommander} />
)}
{contents.length > 0 && (
<div className="deck-stats">
<div className="deck-stats-head">Deck analysis</div>
<div className="deck-stats-body">
<div className="deck-stats-numbers">
<div className="stat-tiles-row">
<StatTile value={deckStats.totalCards} label="cards" />
<StatTile value={deckStats.uniqueCards} label="unique" />
<StatTile value={deckStats.landCount} label="lands" />
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
</div>
<div className="deck-stats-curve-label">Color identity</div>
<ColorDistributionBar
colorCounts={deckStats.colorCounts}
colorlessCount={deckStats.colorlessCount}
/>
<div className="deck-stats-curve-label">Mana production</div>
<ColorDistributionBar
colorCounts={deckStats.manaProduction}
colorlessCount={deckStats.colorlessProduction}
/>
<div className="deck-stats-curve-label">Mana consumption</div>
<ColorDistributionBar
colorCounts={deckStats.manaConsumption}
colorlessCount={deckStats.colorlessConsumption}
/>
</div>
<div className="deck-stats-curve">
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
<ManaCurve buckets={deckStats.curve} />
</div>
</div>
</div>
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
@@ -515,15 +694,13 @@ export function DeckView() {
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
<div className="row" style={{ gap: 8 }}>
<span className="muted">Commander:</span>
<strong>{commanderName || "none"}</strong>
{commanderName && (
<IconButton icon={Icons.close} label="Clear commander" onClick={clearCommander} />
)}
</div>
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder="Search to set the commander…"
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}

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);
}