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 (
{card.image_uri ? ( {card.name} open(card, preview)} {...handlers(card)} /> ) : (
{card.name}
)}
Commander {card.name} {card.type_line}
); } // 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([]); const [selected, setSelected] = usePersistedState(libraryKey("deck-selected"), null); const [newName, setNewName] = useState(""); const [contents, setContents] = useState([]); const [view, setView] = useViewMode("deck-view"); const tiles = useTileColumns(); const [groupBy, setGroupBy] = usePersistedState("deck-group", "none"); const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false); const [filterQuery, setFilterQuery] = useState(""); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(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([]); const [anyQuery, setAnyQuery] = useState(""); const [anyResults, setAnyResults] = useState([]); const [cmdQuery, setCmdQuery] = useState(""); const [cmdResults, setCmdResults] = useState([]); const [maybeQuery, setMaybeQuery] = useState(""); const [maybeResults, setMaybeResults] = useState([]); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); const [selectedCards, setSelectedCards] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const selectAllRef = useRef(null); const [handOpen, setHandOpen] = useState(false); const [hand, setHand] = useState([]); const [libraryStack, setLibraryStack] = useState([]); 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 (
{entries.map((e) => ( toggleCard(e.oracle_id)} /> } corner={ 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={ setMaybeboard(e, !e.is_maybeboard)} /> } footer={e.is_commander ? ★ commander : undefined} /> ))}
); } function deckTable(entries: DeckEntry[]) { return ( {entries.map((e) => ( ))}
Colors Name Type In deck Owned
toggleCard(e.oracle_id)} /> {e.is_commander && ★ } {e.name} {e.type_line} 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)} /> {e.quantity_owned} setMaybeboard(e, !e.is_maybeboard)} />
); } function cardsGroup(entries: DeckEntry[]) { if (entries.length === 0) return

None yet.

; return groupCards(entries, groupBy).map((g) => (
{g.label && (

{g.label} {g.cards.length}

)} {view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
)); } return (

Decks

setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && createDeck()} />
    {decks.map((d) => (
  • { setSelected(d.id); setMobileShowList(false); }} >
    {d.name}
    {d.format ? {d.format} : } { e.stopPropagation(); setDialogError(null); setDialog({ kind: "rename", deck: d }); }} /> { e.stopPropagation(); setDialogError(null); setDialog({ kind: "duplicate", deck: d }); }} /> { e.stopPropagation(); setDialog({ kind: "delete", deck: d }); }} />
  • ))} {decks.length === 0 && (
  • No decks yet.
  • )}
{selected === null || !deck ? (

Select or create a deck.

) : ( <>
setMobileShowList(true)} />

{deck.name}

{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
)} {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
{COMMANDER_FORMATS.includes(deck.format) && (
{!commanderEntry && No commander set yet.}
)}
{contents.length > 0 && (
Deck analysis
Color identity
Mana production
Mana consumption
Mana curve (nonland)
)} {deck.format !== "Other" && (
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
{deckLegality.status === "legal" ? "Legal" : deckLegality.status === "illegal" ? "Illegal" : "Not checked"} {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."}
{deckLegality.status === "illegal" && (
    {deckLegality.reasons.map((r, i) => (
  • {r}
  • ))}
)}
)}

setFilterQuery(e.target.value)} placeholder="Filter cards in deck…" style={{ width: 200 }} /> {view === "tiles" && }
{selectedCards.size > 0 && (
{selectedCards.size} selected
)} {combineSections ? (

All cards {owned.length + unowned.length}

{cardsGroup([...owned, ...unowned])}
) : ( <>

In your library {owned.length}

{cardsGroup(owned)}

Not owned {unowned.length}

{cardsGroup(unowned)}
)}

Maybeboard {maybeboard.length}

Cards under consideration — not counted in deck analysis.

{cardsGroup(maybeboard)}
)}
{dialog?.kind === "delete" && ( setDialog(null)} /> )} {dialog?.kind === "rename" && ( setDialog(null)} /> )} {dialog?.kind === "duplicate" && ( setDialog(null)} /> )} {dialog?.kind === "import-name" && ( setDialog(null)} /> )} {dialog?.kind === "import-result" && ( 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 && ( setHandOpen(false)} /> )} {suggestOpen && deck && ( setSuggestOpen(false)} onCardAdded={loadContents} /> )}
); }