import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { api, CardRef, Deck, DECK_STATUSES, DeckEntry, DeckHolding, DeckImportResult, DeckStatus, isPhysical, libraryKey, PreviewContext, } from "../api"; import { CardSearchBox, CardThumb, CardTile, ColorDistributionBar, ColorPips, ConfirmDialog, DialogFrame, GroupBySelect, GroupMode, IconButton, Icons, InfoDialog, ManaCurve, PromptDialog, QtyStepper, SampleHandModal, StatTile, TileColumnsControl, ViewToggle, groupCards, useClickPreview, useHoverPreview, usePersistedState, useTileColumns, useViewMode, fold, } from "../components"; import { COMMANDER_FORMATS, computeDeckLegality, computeDeckStats, formatCost, FORMATS, illegalSelection, LEGALITY_CATEGORIES, LegalityCategory, pairsWith, partnerPlaceholder, shortBy, } from "../deckAnalysis"; import { SuggestionsModal } from "../SuggestionsModal"; import { useBackButton } from "../backButton"; import { DeckList } from "./DeckList"; function libraryAddLabel(e: DeckEntry): string { const n = shortBy(e); return n === 1 ? "Add to library" : `Add ${n} copies to library`; } /** 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: "create" } | { 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; /** Name a new deck and, optionally, pick its format up front. */ function NewDeckDialog({ defaultFormat, error, onConfirm, onCancel, }: { defaultFormat: string; error: string | null; onConfirm: (name: string, format: string) => void; onCancel: () => void; }) { const [name, setName] = useState(""); const [format, setFormat] = useState(FORMATS.includes(defaultFormat) ? defaultFormat : ""); const confirm = () => { if (name.trim()) onConfirm(name.trim(), format); }; return ( {error &&

{error}

}
); } function friendlyDeckError(err: unknown): string { const msg = String(err); return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg; } /** 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 extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`; const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`; const statusLabel = (s: DeckStatus) => DECK_STATUSES.find((d) => d.value === s)?.label ?? s; /** One line under the status select saying what the status means. */ const STATUS_HINTS: Record = { idea: "Just a list — it claims none of your cards.", building: "Being assembled: pull its cards as you go.", built: "Assembled: its cards are in the deck box.", retired: "Taken apart, kept for reference.", }; const STARTING_HAND_SIZE = 7; /** * 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. */ function DeckBuilder({ deck, refreshSignal, onBack, onDecksChanged, onRename, onDuplicate, onDelete, onExport, }: { deck: Deck; refreshSignal?: number; onBack: () => void; /** Refetch the deck list after a change to the deck's own fields. */ onDecksChanged: () => Promise; onRename: () => void; onDuplicate: () => void; onDelete: () => void; onExport: () => void; }) { const deckId = deck.id; 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 [libQuery, setLibQuery] = useState(""); const [libResults, setLibResults] = useState([]); const [anyQuery, setAnyQuery] = useState(""); const [anyResults, setAnyResults] = useState([]); const [cmdQuery, setCmdQuery] = useState(""); const [cmdResults, setCmdResults] = useState([]); const [partnerQuery, setPartnerQuery] = useState(""); const [partnerResults, setPartnerResults] = 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()); // Selected cards where only some copies are marked for removal (the extras // over a format's copy limit), keyed by oracle id → copies to remove. const [partialRemoval, setPartialRemoval] = useState>(new Map()); 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 [statusDialog, setStatusDialog] = useState<{ to: DeckStatus; kind: "assemble" | "release" } | null>( null, ); // Keyed by deck id in `DeckView`, so switching decks remounts with fresh // state rather than resetting it here. const loadContents = useCallback(async () => { setContents(await api.deckContents(deckId)); }, [deckId]); useEffect(() => { loadContents(); }, [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; }); setPartialRemoval((m) => { const visible = new Set(contents.map((e) => e.oracle_id)); const next = new Map([...m].filter(([id]) => visible.has(id))); return next.size === m.size ? m : 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]); // The primary commander, whose partner ability the second search box serves. const partnerOf = deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null; useEffect(() => { const q = partnerQuery.trim(); if (!q || !partnerOf) return setPartnerResults([]); const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80); return () => clearTimeout(t); }, [partnerQuery, partnerOf]); async function addToDeck(card: CardRef) { const existing = contents.find((e) => e.oracle_id === card.oracle_id); const qty = existing ? existing.quantity + 1 : 1; await api.setDeckCard(deckId, card.oracle_id, qty, false, false); setLibQuery(""); setLibResults([]); setAnyQuery(""); setAnyResults([]); await loadContents(); } async function addToMaybeboard(card: CardRef) { const existing = contents.find((e) => e.oracle_id === card.oracle_id); const qty = existing ? existing.quantity + 1 : 1; await api.setDeckCard(deckId, card.oracle_id, qty, false, true); setMaybeQuery(""); setMaybeResults([]); await loadContents(); } /** Move a card between the mainboard and the maybeboard. Clears commander * status (dropping it from the deck's commanders) when maybeboarding. */ async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) { const isCommander = isMaybeboard ? false : entry.is_commander; await api.setDeckCard(deckId, entry.oracle_id, entry.quantity, isCommander, isMaybeboard); if (isMaybeboard && entry.is_commander) { await api.setDeckCommanders( deckId, commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id), ); await onDecksChanged(); } 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) { const clamped = Math.max(0, qty); const existing = contents.find((e) => e.oracle_id === oracleId); await api.setDeckCard( deckId, 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) { // A manual (de)select always means the whole entry. dropPartial(oracleId); setSelectedCards((s) => { const next = new Set(s); if (next.has(oracleId)) next.delete(oracleId); else next.add(oracleId); return next; }); } function dropPartial(oracleId: string) { setPartialRemoval((m) => { if (!m.has(oracleId)) return m; const next = new Map(m); next.delete(oracleId); return next; }); } function clearSelection() { setSelectedCards(new Set()); setPartialRemoval(new Map()); } /** Replace the selection with the cards behind the deck's card-level * legality issues (optionally only some categories). Over-limit copies * select just the extras, so removing them leaves the legal count. */ function selectIllegal(categories?: LegalityCategory[]) { const sel = illegalSelection(deckLegality.issues, categories); setSelectedCards(new Set(sel.keys())); setPartialRemoval( new Map([...sel].filter((kv): kv is [string, number] => kv[1] !== null)), ); } function toggleSelectAllCards() { setPartialRemoval(new Map()); setSelectedCards((s) => s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)), ); } /** Remove every selected card outright. Unlike stepping one card down to 0, * this is a deliberate action, so there's no grace period to linger in. */ async function bulkRemoveFromDeck() { // Entries with only some copies selected are trimmed rather than removed. const remaining = new Map(); for (const id of selectedCards) { const e = contents.find((c) => c.oracle_id === id); const n = partialRemoval.get(id); remaining.set(id, e && n !== undefined ? Math.max(e.quantity - n, 0) : 0); } setBulkBusy(true); try { await Promise.all( [...remaining].map(([id, qty]) => { const e = contents.find((c) => c.oracle_id === id); return api.setDeckCard(deckId, id, qty, e?.is_commander ?? false, e?.is_maybeboard ?? false); }), ); const removed = new Set([...remaining].filter(([, qty]) => qty === 0).map(([id]) => id)); for (const id of removed) { const timer = removalTimers.current.get(id); if (timer) clearTimeout(timer); removalTimers.current.delete(id); } setPendingRemoval((p) => new Set([...p].filter((id) => !removed.has(id)))); setContents((cs) => cs .filter((e) => !removed.has(e.oracle_id)) .map((e) => (remaining.has(e.oracle_id) ? { ...e, quantity: remaining.get(e.oracle_id)! } : e)), ); clearSelection(); } finally { setBulkBusy(false); } } /** Record cards as owned: as many copies as the deck uses beyond those * already owned, so an acquired card moves to "In your library". */ async function addToLibrary(entries: DeckEntry[]) { await Promise.all(entries.filter((e) => shortBy(e) > 0).map((e) => api.addOwned(e.oracle_id, shortBy(e)))); await loadContents(); } async function bulkAddToLibrary() { setBulkBusy(true); try { await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id))); clearSelection(); } finally { setBulkBusy(false); } } async function changeFormat(format: string) { await api.setDeckFormat(deckId, format); await onDecksChanged(); } async function applyStatus(status: DeckStatus, assembled = false) { setStatusDialog(null); await api.setDeckStatus(deckId, status, assembled); await onDecksChanged(); await loadContents(); } /** Change status, first asking how to treat the cards when the deck starts * or stops physically holding them. */ function changeStatus(status: DeckStatus) { if (status === deck.status) return; if (isPhysical(status) && !isPhysical(deck.status) && contents.some((e) => !e.is_maybeboard)) { setStatusDialog({ to: status, kind: "assemble" }); } else if (!isPhysical(status) && pulledCopies > 0) { setStatusDialog({ to: status, kind: "release" }); } else { applyStatus(status); } } /** Mark as many copies pulled as the deck needs (the backend clamps to * what's actually in the box). */ async function pullCard(e: DeckEntry) { await api.setDeckCardPulled(deckId, e.oracle_id, e.quantity); await loadContents(); } async function takeCard(e: DeckEntry, from: DeckHolding) { await api.takeDeckCardCopy(from.deck_id, deckId, e.oracle_id); await loadContents(); } async function bulkSetPulled(pulled: boolean) { setBulkBusy(true); try { await Promise.all( contents .filter((e) => selectedCards.has(e.oracle_id) && !e.is_maybeboard) .map((e) => api.setDeckCardPulled(deckId, e.oracle_id, pulled ? e.quantity : 0)), ); clearSelection(); await loadContents(); } finally { setBulkBusy(false); } } async function setCommander(card: CardRef) { // 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(deckId, card.oracle_id, existing?.quantity ?? 1, true, false); // Keep the partner only if it can still pair with the new commander; if // not, it stays in the deck as an ordinary card. const picked = (await api.deckContents(deckId)).find((e) => e.oracle_id === card.oracle_id); const keepPartner = partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry); await api.setDeckCommanders( deckId, keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id], ); setCmdQuery(""); setCmdResults([]); await loadContents(); await onDecksChanged(); } async function addPartner(card: CardRef) { if (!commanderEntry) return; const existing = contents.find((e) => e.oracle_id === card.oracle_id); await api.setDeckCard(deckId, card.oracle_id, existing?.quantity ?? 1, true, false); await api.setDeckCommanders(deckId, [commanderEntry.oracle_id, card.oracle_id]); setPartnerQuery(""); setPartnerResults([]); await loadContents(); await onDecksChanged(); } /** Stop treating a card as a commander; it stays in the deck. Clearing the * primary commander promotes its partner. */ async function clearCommander(entry: DeckEntry) { await api.setDeckCommanders( deckId, commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id), ); await loadContents(); await onDecksChanged(); } // 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 = fold(filterQuery.trim()); if (!q) return true; return fold(e.name).includes(q) || fold(e.type_line).includes(q); } // Building/built decks split by what's physically in them; ideas and // retired decks by whether the cards are free to use. const physical = isPhysical(deck.status); const mainboard = contents.filter((e) => !e.is_maybeboard && matchesFilter(e)); const toPull = mainboard.filter((e) => e.quantity_pulled < e.quantity); const inDeck = mainboard.filter((e) => e.quantity_pulled >= e.quantity); const available = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare >= e.quantity); const elsewhere = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare < e.quantity); const unowned = mainboard.filter((e) => e.quantity_owned === 0); const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0); const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length; const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e)); // The primary commander first, then its partner, if any. const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id); const commanders = contents .filter((e) => e.is_commander && !e.is_maybeboard) .sort((a, b) => isPrimary(b) - isPrimary(a)); const commanderEntry: DeckEntry | undefined = commanders[0]; const partnerEntry: DeckEntry | undefined = commanders[1]; 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 = { deckId, coverOracleId: deck.cover_oracle_id, onChanged: () => { loadContents(); onDecksChanged(); }, }; /** Where a card's copies are, when that's worth saying: for a deck being * built, where to pull what's missing from; for an idea, which real decks * already use the copies. Null when there's nothing to act on. */ function whereabouts(e: DeckEntry, compact = false) { if (e.is_maybeboard) return null; if (physical) { if (e.quantity_pulled >= e.quantity) return null; const progress = e.quantity_pulled > 0 && ( {e.quantity_pulled}/{e.quantity} pulled ); const holders = e.claimed_by.filter((h) => h.pulled > 0); let source; if (e.quantity_in_box > 0) { source = ( <> In {e.location || "your library"} ); } else if (holders.length > 0) { source = holders.map((h) => ( In {h.name} )); } else if (e.quantity_owned === 0) { source = Not owned; } else { source = ( Need {copies(e.quantity - e.quantity_pulled)} more ); } return ( {progress} {source} ); } if (e.quantity_owned === 0 || e.quantity_spare >= e.quantity) return null; const names = e.claimed_by.map((h) => h.name).join(", "); return ( In {names} {e.quantity_spare > 0 && ( {e.quantity_spare} of {e.quantity} free )} ); } 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)} /> {e.quantity_owned === 0 && ( addToLibrary([e])} /> )} } footer={ e.is_commander || partialRemoval.has(e.oracle_id) || whereabouts(e) ? ( {e.is_commander && ★ commander} {partialRemoval.has(e.oracle_id) && ( {extraCopiesLabel(partialRemoval.get(e.oracle_id)!)} )} {whereabouts(e, true)} ) : undefined } /> ))}
); } function deckTable(entries: DeckEntry[]) { return ( {entries.map((e) => ( ))}
Colors Name Type In deck Owned Where
toggleCard(e.oracle_id)} /> {e.is_commander && ★ } {e.name} {partialRemoval.has(e.oracle_id) && ( {extraCopiesLabel(partialRemoval.get(e.oracle_id)!)} )} {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} {whereabouts(e)} setMaybeboard(e, !e.is_maybeboard)} /> {e.quantity_owned === 0 && ( addToLibrary([e])} /> )}
); } 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 (

{deck.name}

{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
{commanders.map((c) => ( clearCommander(c)} preview={preview} /> ))}
)} {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
{deck.status === "building" && leftToPull > 0 ? `Being assembled: ${leftToPull} card${leftToPull === 1 ? "" : "s"} left to pull.` : deck.status === "built" && leftToPull > 0 ? `Assembled, with ${leftToPull} card${leftToPull === 1 ? "" : "s"} still to pull.` : STATUS_HINTS[deck.status]} {COMMANDER_FORMATS.includes(deck.format) && (
{!commanderEntry && No commander set yet.} {commanderEntry?.partner && !partnerEntry && ( )}
)}
{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.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:` : deck.format ? `Legality isn't checked for "${deck.format}".` : "Set a format to check legality."}
{illegalSelection(deckLegality.issues).size > 0 && ( <> )}
{deckLegality.status === "illegal" && LEGALITY_CATEGORIES.map((cat) => { const issues = deckLegality.issues.filter((i) => i.category === cat.key); if (issues.length === 0) return null; const cardCount = illegalSelection(issues).size; return (
{cat.label} {cat.selectable && cardCount > 0 && ( )}
    {issues.map((i, n) => (
  • {i.message}
  • ))}
); })}
)}

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

All cards {mainboard.length}

{cardsGroup(physical ? [...toPull, ...inDeck] : [...available, ...elsewhere, ...unowned])}
) : physical ? ( <>

To pull {toPull.length}

In the deck's list, but not physically in it yet. New cards land here.

{toPull.length === 0 && deck.status === "building" && mainboard.length > 0 ? (

Everything's pulled.

) : ( cardsGroup(toPull) )}

In deck {inDeck.length}

{cardsGroup(inDeck)}
) : ( <>

Available {available.length}

Owned, with copies no built deck is using.

{cardsGroup(available)}
{elsewhere.length > 0 && (

In other decks {elsewhere.length}

Owned, but your copies are used by decks you're building or have built.

{cardsGroup(elsewhere)}
)}

Not owned {unowned.length}

{cardsGroup(unowned)}
)}

Maybeboard {maybeboard.length}

Cards under consideration — not counted in deck analysis.

{cardsGroup(maybeboard)}
{handOpen && ( setHandOpen(false)} /> )} {statusDialog?.kind === "assemble" && ( setStatusDialog(null)}>

Are this deck's cards already together? If so, the copies in your library are recorded as in the deck. Otherwise, they're listed under "To pull" until you take them out of their boxes.

)} {statusDialog?.kind === "release" && ( applyStatus(statusDialog.to)} onCancel={() => setStatusDialog(null)} /> )} {suggestOpen && ( setSuggestOpen(false)} onCardAdded={loadContents} /> )}
); } // Which screen the Decks tab was last on. Module-level rather than component // state so hopping to another tab and back returns to the deck being built, // while a fresh launch starts on the deck list. let lastScreen: "list" | "builder" = "list"; // Where the deck list was scrolled to when a deck was opened, so coming back // lands where you left off rather than at the top. let listScrollTop = 0; /** The app's scrolling page area (the window itself doesn't scroll). */ const scroller = () => document.querySelector(".content"); /** * The Decks tab: the deck list, or one deck's builder, as separate screens. * Owns the deck list itself and every whole-deck action (create, import, * rename, duplicate, export, delete), which both screens offer. */ export function DeckView({ refreshSignal, openDeckId, onOpenedDeck, }: { refreshSignal?: number; /** A deck to open, e.g. from a deck chip elsewhere; `onOpenedDeck` clears it. */ openDeckId?: number | null; onOpenedDeck?: () => void; }) { const [decks, setDecks] = useState(null); const [selected, setSelected] = usePersistedState(libraryKey("deck-selected"), null); // When each deck was last opened, for the list's "Recently opened" sort. const [openedAt, setOpenedAt] = usePersistedState>(libraryKey("deck-opened"), {}); // The format the last new deck was created with; null until one has been. const [lastNewFormat, setLastNewFormat] = usePersistedState("new-deck-format", null); const [screen, setScreenState] = useState(lastScreen); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(null); const setScreen = useCallback((next: "list" | "builder") => { if (lastScreen === "list" && next === "builder") listScrollTop = scroller()?.scrollTop ?? 0; lastScreen = next; setScreenState(next); }, []); // A fresh screen starts at the top; the list at wherever it was left. useLayoutEffect(() => { scroller()?.scrollTo(0, screen === "list" ? listScrollTop : 0); }, [screen, selected]); const loadDecks = useCallback(async () => { setDecks(await api.listDecks()); }, []); useEffect(() => { loadDecks(); }, [loadDecks]); function openDeck(id: number) { setSelected(id); setOpenedAt({ ...openedAt, [id]: Date.now() }); setScreen("builder"); } // Open a deck asked for from outside — straight to its building screen. useEffect(() => { if (openDeckId == null) return; openDeck(openDeckId); onOpenedDeck?.(); }, [openDeckId]); // Android back from the builder returns to the deck list, like its ← button. useBackButton(() => setScreen("list"), screen === "builder"); const deck = decks?.find((d) => d.id === selected) ?? null; // A new deck starts in the format the last one was created with — or, // before any has been, the format of the deck opened most recently. const newDeckFormat = lastNewFormat ?? (decks ?? []).reduce( (latest, d) => ((openedAt[d.id] ?? 0) > (latest ? openedAt[latest.id] ?? 0 : 0) ? d : latest), null, )?.format ?? ""; function showDialog(next: DeckDialog) { setDialogError(null); setDialog(next); } async function confirmCreate(name: string, format: string) { try { const d = await api.createDeck(name, null); if (format) await api.setDeckFormat(d.id, format); setLastNewFormat(format); setDialog(null); await loadDecks(); openDeck(d.id); } catch (err) { setDialogError(friendlyDeckError(err)); } } 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, ""); showDialog({ 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(); openDeck(result.deck.id); } catch (err) { setDialogError(friendlyDeckError(err)); } } async function confirmDelete() { if (dialog?.kind !== "delete") return; await api.deleteDeck(dialog.deck.id); if (selected === dialog.deck.id) { setSelected(null); setScreen("list"); } 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(); // From the builder, carry on in the copy; from the list, it just appears. if (screen === "builder") openDeck(d.id); } catch (err) { setDialogError(friendlyDeckError(err)); } } // Nothing until the first fetch, so a builder left open doesn't flash the // list on the way back in. if (decks === null) return null; return ( <> {screen === "builder" && deck ? ( setScreen("list")} onDecksChanged={loadDecks} onRename={() => showDialog({ kind: "rename", deck })} onDuplicate={() => showDialog({ kind: "duplicate", deck })} onDelete={() => showDialog({ kind: "delete", deck })} onExport={() => exportDeck(deck)} /> ) : ( showDialog({ kind: "create" })} onImport={pickImportFile} onRename={(d) => showDialog({ kind: "rename", deck: d })} onDuplicate={(d) => showDialog({ kind: "duplicate", deck: d })} onDelete={(d) => showDialog({ kind: "delete", deck: d })} onExport={exportDeck} /> )} {dialog?.kind === "create" && ( setDialog(null)} /> )} {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)} /> )} ); }