Files
mtgc/src/views/DeckView.tsx
Garrett Mills 81b1df8c9f Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
2026-10-16 21:44:55 -05:00

1284 lines
46 KiB
TypeScript

import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, libraryKey, PreviewContext } from "../api";
import {
CardSearchBox,
CardThumb,
CardTile,
ColorDistributionBar,
ColorPips,
ConfirmDialog,
GroupBySelect,
GroupMode,
IconButton,
Icons,
InfoDialog,
ManaCurve,
PromptDialog,
QtyStepper,
SampleHandModal,
StatTile,
TileColumnsControl,
ViewToggle,
groupCards,
useClickPreview,
useHoverPreview,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
import { SuggestionsModal } from "../SuggestionsModal";
import { useBackButton } from "../backButton";
/** Whether a CSS media query currently matches, kept live across resizes. */
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
onChange();
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
function boardToggleProps(e: DeckEntry) {
return e.is_maybeboard
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
}
type DeckDialog =
| { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck }
| { kind: "duplicate"; deck: Deck }
| { kind: "import-name"; suggestedName: string; path: string }
| { kind: "import-result"; result: DeckImportResult }
| null;
function friendlyDeckError(err: unknown): string {
const msg = String(err);
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) and the maybeboard (cards under consideration, not
* actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/
function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
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,
preview,
}: {
card: DeckEntry;
onClear: () => void;
preview?: PreviewContext;
}) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
<div className="panel commander-hero">
{card.image_uri ? (
<img
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card, preview)}
{...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 +.
const REMOVAL_GRACE_MS = 5000;
const STARTING_HAND_SIZE = 7;
const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
const COMMANDER_DECK_SIZE = 100;
const CONSTRUCTED_MIN_DECK_SIZE = 60;
const CONSTRUCTED_MAX_COPIES = 4;
/**
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
}
const reasons: string[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
}
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 1) {
reasons.push(`More than one card is marked as commander.`);
}
const commander = commanders[0];
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
}
}
if (commander) {
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~commander.color_identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the commander's color identity.`);
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
} else {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
* groupable by card attribute; "Combine sections" merges these two into one
* list. The maybeboard is always shown as its own separate section. Deck-level
* format + commander metadata sit at the top.
*
* `refreshSignal` is bumped by `App` when something outside this view's own
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
* from the command palette while this view is already mounted — so the
* card list, mana curve, and legality tile recompute immediately instead of
* only on the next mount.
*/
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [decks, setDecks] = useState<Deck[]>([]);
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
const [newName, setNewName] = useState("");
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
// On narrow screens the deck list and the deck-building interface are
// separate "screens" (only one visible at a time) rather than stacked —
// this only matters below the .deck-layout stacking breakpoint; CSS keeps
// both panes visible side by side above it regardless of this flag.
const [mobileShowList, setMobileShowList] = useState(true);
// Android back from the deck-building screen returns to the deck list, like
// its ← button. Only while they're separate screens (the ≤720px
// `.deck-layout` breakpoint): side by side, flipping the flag changes
// nothing visible, so back would seem to do nothing.
const listIsSeparateScreen = useMediaQuery("(max-width: 720px)");
useBackButton(() => setMobileShowList(true), listIsSeparateScreen && !mobileShowList);
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState<CardRef[]>([]);
const [anyQuery, setAnyQuery] = useState("");
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const [maybeQuery, setMaybeQuery] = useState("");
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const [handOpen, setHandOpen] = useState(false);
const [hand, setHand] = useState<DeckEntry[]>([]);
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false);
const deck = decks.find((d) => d.id === selected) ?? null;
const loadDecks = useCallback(async () => {
const d = await api.listDecks();
setDecks(d);
// Keep the persisted selection if it still exists, else fall back to the first.
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
setSelected(d[0].id);
}
}, [selected, setSelected]);
useEffect(() => {
loadDecks();
}, [loadDecks]);
const loadContents = useCallback(async () => {
if (selected === null) {
setContents([]);
return;
}
setContents(await api.deckContents(selected));
}, [selected]);
useEffect(() => {
loadContents();
setSelectedCards(new Set());
setHandOpen(false);
setHand([]);
setLibraryStack([]);
setFilterQuery("");
}, [loadContents]);
// Refetch on an external refresh signal without resetting the UI state
// above (unlike a deck switch, this shouldn't clear the user's filter,
// selection, or open sample hand). Skips the first render so it doesn't
// duplicate the mount-time fetch above.
const skipFirstRefresh = useRef(true);
useEffect(() => {
if (skipFirstRefresh.current) {
skipFirstRefresh.current = false;
return;
}
loadContents();
}, [refreshSignal]);
// Cancel any in-flight removal timers on unmount so they don't fire against
// an unmounted component.
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
// Keep selection in sync with what's actually visible — drop ids that fall
// out of the deck (deck switched, or a card's qty hit 0 and its grace
// period expired).
useEffect(() => {
setSelectedCards((s) => {
const visible = new Set(contents.map((e) => e.oracle_id));
const next = new Set([...s].filter((id) => visible.has(id)));
return next.size === s.size ? s : next;
});
}, [contents]);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
}
}, [selectedCards, contents]);
useEffect(() => {
const q = libQuery.trim();
if (!q) return setLibResults([]);
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
return () => clearTimeout(t);
}, [libQuery]);
useEffect(() => {
const q = anyQuery.trim();
if (!q) return setAnyResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
return () => clearTimeout(t);
}, [anyQuery]);
useEffect(() => {
const q = maybeQuery.trim();
if (!q) return setMaybeResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
return () => clearTimeout(t);
}, [maybeQuery]);
useEffect(() => {
const q = cmdQuery.trim();
if (!q) return setCmdResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
return () => clearTimeout(t);
}, [cmdQuery]);
async function createDeck() {
if (!newName.trim()) return;
const d = await api.createDeck(newName.trim(), null);
setNewName("");
await loadDecks();
setSelected(d.id);
setMobileShowList(false);
}
async function exportDeck(d: Deck) {
const path = await save({
title: "Export deck",
defaultPath: `${d.name}.txt`,
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportDeck(d.id, path);
}
async function pickImportFile() {
const path = await open({
title: "Import deck",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path !== "string") return;
const base = path.split(/[/\\]/).pop() ?? "Imported deck";
const suggestedName = base.replace(/\.txt$/i, "");
setDialog({ kind: "import-name", suggestedName, path });
}
async function confirmImportName(name: string) {
if (dialog?.kind !== "import-name") return;
try {
const result = await api.importDeck(dialog.path, name);
setDialog({ kind: "import-result", result });
setDialogError(null);
await loadDecks();
setSelected(result.deck.id);
setMobileShowList(false);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function addToDeck(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false, false);
setLibQuery("");
setLibResults([]);
setAnyQuery("");
setAnyResults([]);
await loadContents();
}
async function addToMaybeboard(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false, true);
setMaybeQuery("");
setMaybeResults([]);
await loadContents();
}
/** Move a card between the mainboard and the maybeboard. Clears commander
* status (and the deck's commander, if this was it) when maybeboarding. */
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
if (selected === null) return;
const isCommander = isMaybeboard ? false : entry.is_commander;
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) {
await api.setDeckCommander(selected, null);
await loadDecks();
}
await loadContents();
}
/** Every mainboard copy (commander/maybeboard excluded), one entry per
* physical copy so a card at quantity N is N times as likely to be drawn,
* shuffled in place (Fisher-Yates). */
function shuffledLibrary(): DeckEntry[] {
const pool: DeckEntry[] = [];
for (const e of contents) {
if (e.is_commander || e.is_maybeboard) continue;
for (let i = 0; i < e.quantity; i++) pool.push(e);
}
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool;
}
function drawNewHand() {
const shuffled = shuffledLibrary();
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
setHandOpen(true);
}
function drawOneMore() {
if (libraryStack.length === 0) return;
setHand((h) => [...h, libraryStack[0]]);
setLibraryStack((stack) => stack.slice(1));
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
const clamped = Math.max(0, qty);
const existing = contents.find((e) => e.oracle_id === oracleId);
await api.setDeckCard(
selected,
oracleId,
clamped,
existing?.is_commander ?? false,
existing?.is_maybeboard ?? false,
);
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
// Re-adding during the grace period below cancels the pending removal.
const timer = removalTimers.current.get(oracleId);
if (timer) {
clearTimeout(timer);
removalTimers.current.delete(oracleId);
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
}
if (clamped <= 0) {
// deck_contents wouldn't include this card on a fresh fetch anymore —
// rather than dropping the row immediately, dim it and give the user a
// window to undo a mistaken decrement (via +) before it disappears.
setPendingRemoval((p) => new Set(p).add(oracleId));
removalTimers.current.set(
oracleId,
setTimeout(() => {
setContents((cs) => cs.filter((e) => e.oracle_id !== oracleId));
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
removalTimers.current.delete(oracleId);
}, REMOVAL_GRACE_MS),
);
}
}
function toggleCard(oracleId: string) {
setSelectedCards((s) => {
const next = new Set(s);
if (next.has(oracleId)) next.delete(oracleId);
else next.add(oracleId);
return next;
});
}
function toggleSelectAllCards() {
setSelectedCards((s) =>
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
);
}
async function bulkRemoveFromDeck() {
setBulkBusy(true);
try {
await Promise.all([...selectedCards].map((id) => setQty(id, 0)));
} finally {
setBulkBusy(false);
}
}
async function confirmDelete() {
if (dialog?.kind !== "delete") return;
await api.deleteDeck(dialog.deck.id);
if (selected === dialog.deck.id) {
setSelected(null);
setMobileShowList(true);
}
setDialog(null);
await loadDecks();
}
async function confirmRename(name: string) {
if (dialog?.kind !== "rename") return;
try {
await api.renameDeck(dialog.deck.id, name);
setDialog(null);
setDialogError(null);
await loadDecks();
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function confirmDuplicate(newName: string) {
if (dialog?.kind !== "duplicate") return;
try {
const d = await api.duplicateDeck(dialog.deck.id, newName);
setDialog(null);
setDialogError(null);
await loadDecks();
setSelected(d.id);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function changeFormat(format: string) {
if (selected === null) return;
await api.setDeckFormat(selected, format);
await loadDecks();
}
async function setCommander(card: CardRef) {
if (selected === null) return;
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
// commander — covers both "not in the deck yet" and "was maybeboarded".
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
await api.setDeckCommander(selected, card.oracle_id);
setCmdQuery("");
setCmdResults([]);
await loadContents();
await loadDecks();
}
async function clearCommander() {
if (selected === null) return;
await api.setDeckCommander(selected, null);
await loadContents();
await loadDecks();
}
// Quick-search over cards already in the deck/maybeboard — a display
// filter only, distinct from the CardSearchBox boxes below (which search
// the library/reference db to find NEW cards to add).
function matchesFilter(e: DeckEntry): boolean {
const q = filterQuery.trim().toLowerCase();
if (!q) return true;
return e.name.toLowerCase().includes(q) || e.type_line.toLowerCase().includes(q);
}
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
// Lets the click preview track copies and choose each slot's printing.
const preview: PreviewContext | undefined =
selected === null ? undefined : { deckId: selected, onChanged: loadContents };
function deckTiles(entries: DeckEntry[]) {
return (
<div {...tiles.gridProps}>
{entries.map((e) => (
<CardTile
key={e.oracle_id}
card={e}
preview={preview}
selected={selectedCards.has(e.oracle_id)}
cornerLeft={
<span className="tile-select">
<input
type="checkbox"
checked={selectedCards.has(e.oracle_id)}
onChange={() => toggleCard(e.oracle_id)}
/>
</span>
}
corner={
<QtyStepper
overlay
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
pending={pendingRemoval.has(e.oracle_id)}
/>
}
cornerBottomLeft={
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
/>
))}
</div>
);
}
function deckTable(entries: DeckEntry[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.oracle_id}>
<td className="cell-check">
<input
type="checkbox"
checked={selectedCards.has(e.oracle_id)}
onChange={() => toggleCard(e.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={e} preview={preview} />
</td>
<td>
<ColorPips mask={e.color_identity} />
</td>
<td className="card-name">
{e.is_commander && <span title="Commander">★ </span>}
{e.name}
</td>
<td className="card-type">{e.type_line}</td>
<td data-label="In deck">
<QtyStepper
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
pending={pendingRemoval.has(e.oracle_id)}
/>
</td>
<td data-label="Owned">{e.quantity_owned}</td>
<td>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</td>
</tr>
))}
</tbody>
</table>
);
}
function cardsGroup(entries: DeckEntry[]) {
if (entries.length === 0) return <p className="hint">None yet.</p>;
return groupCards(entries, groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h4>
)}
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
</div>
));
}
return (
<div className={`deck-layout ${mobileShowList ? "show-list" : "show-detail"}`}>
<div className="deck-sidebar">
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>Decks</h2>
<IconButton icon={Icons.upload} label="Import deck…" onClick={pickImportFile} />
</div>
<div className="row" style={{ marginBottom: 10 }}>
<input
value={newName}
placeholder="New deck name"
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createDeck()}
/>
<button className="primary" onClick={createDeck}>
+
</button>
</div>
<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);
setMobileShowList(false);
}}
>
<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 && (
<li style={{ cursor: "default" }} className="hint">
No decks yet.
</li>
)}
</ul>
</div>
<div className="deck-detail-pane">
{selected === null || !deck ? (
<p className="hint">Select or create a deck.</p>
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<div className="row" style={{ gap: 8 }}>
<span className="deck-back-btn">
<IconButton
icon={Icons.arrowLeftCircle}
label="Back to decks"
onClick={() => setMobileShowList(true)}
/>
</span>
<h2>{deck.name}</h2>
</div>
<div className="row" style={{ gap: 8 }}>
<button
onClick={() => setSuggestOpen(true)}
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
>
{Icons.cards}
Suggest cards
</button>
<button onClick={() => exportDeck(deck)}>
{Icons.download}
Export deck
</button>
<button
onClick={drawNewHand}
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
>
{Icons.cards}
Sample hand
</button>
</div>
</div>
<div className="deck-top-grid">
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<div className="deck-top-commander">
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
</div>
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-top-meta">
<div className="panel deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
</div>
)}
</div>
</div>
{contents.length > 0 && (
<div className="deck-top-stats">
<div className="panel deck-stats">
<div className="deck-stats-head section-label">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>
</div>
)}
{deck.format !== "Other" && (
<div className="deck-top-legality">
<div className={`legality-tile ${deckLegality.status}`}>
<div className="legality-tile-head">
<span className="legality-tile-icon">
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
</span>
<div className="legality-tile-info">
<span className="legality-tile-status">
{deckLegality.status === "legal"
? "Legal"
: deckLegality.status === "illegal"
? "Illegal"
: "Not checked"}
</span>
<span className="legality-tile-detail">
{deckLegality.status === "legal"
? `Meets all ${deck.format} deck-building rules.`
: deckLegality.status === "illegal"
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
: deck.format
? `Legality isn't checked for "${deck.format}".`
: "Set a format to check legality."}
</span>
</div>
</div>
{deckLegality.status === "illegal" && (
<ul className="legality-tile-reasons">
{deckLegality.reasons.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
<hr className="deck-section-divider" />
<div className="row" style={{ gap: 16 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
<input
ref={selectAllRef}
type="checkbox"
checked={contents.length > 0 && selectedCards.size === contents.length}
onChange={toggleSelectAllCards}
disabled={contents.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Combine owned/unowned into one section">
<input
type="checkbox"
checked={combineSections}
onChange={(e) => setCombineSections(e.target.checked)}
/>
<span className="muted" style={{ fontSize: 12 }}>
Combine sections
</span>
</label>
<input
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="Filter cards in deck…"
style={{ width: 200 }}
/>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
{combineSections ? (
<section style={{ marginTop: 10 }}>
<h3>
All cards <span className="badge">{owned.length + unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add to your deck…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup([...owned, ...unowned])}
</section>
) : (
<>
<section style={{ marginTop: 10 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
</h3>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
onChange={setLibQuery}
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add ones you don't own…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup(unowned)}
</section>
</>
)}
<section style={{ marginTop: 26 }}>
<h3>
Maybeboard <span className="badge">{maybeboard.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Cards under consideration — not counted in deck analysis.
</p>
<CardSearchBox
placeholder="Search all cards to add one to the maybeboard…"
value={maybeQuery}
onChange={setMaybeQuery}
results={maybeResults}
onPick={addToMaybeboard}
/>
{cardsGroup(maybeboard)}
</section>
</>
)}
</div>
{dialog?.kind === "delete" && (
<ConfirmDialog
title="Delete deck"
message={`Delete "${dialog.deck.name}"? This can't be undone.`}
confirmLabel="Delete"
danger
onConfirm={confirmDelete}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "rename" && (
<PromptDialog
title="Rename deck"
label="Deck name"
defaultValue={dialog.deck.name}
confirmLabel="Rename"
error={dialogError}
onConfirm={confirmRename}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "duplicate" && (
<PromptDialog
title="Duplicate deck"
label="Name for the copy"
defaultValue={`${dialog.deck.name} (copy)`}
confirmLabel="Duplicate"
error={dialogError}
onConfirm={confirmDuplicate}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-name" && (
<PromptDialog
title="Import deck"
label="Deck name"
defaultValue={dialog.suggestedName}
confirmLabel="Import"
error={dialogError}
onConfirm={confirmImportName}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-result" && (
<InfoDialog
title="Deck imported"
message={
dialog.result.unresolved.length > 0
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Imported "${dialog.result.deck.name}" — every card matched.`
}
items={dialog.result.unresolved}
onClose={() => setDialog(null)}
/>
)}
{handOpen && (
<SampleHandModal
hand={hand}
libraryCount={libraryStack.length}
onDrawOne={drawOneMore}
onNewHand={drawNewHand}
onClose={() => setHandOpen(false)}
/>
)}
{suggestOpen && deck && (
<SuggestionsModal
deckId={deck.id}
onClose={() => setSuggestOpen(false)}
onCardAdded={loadContents}
/>
)}
</div>
);
}