import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { api, CardRef, Deck, DeckEntry, DeckImportResult, 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, 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 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()); 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); // 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; }); }, [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) { 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)), ); } /** 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() { const ids = new Set(selectedCards); setBulkBusy(true); try { await Promise.all( [...ids].map((id) => { const e = contents.find((c) => c.oracle_id === id); return api.setDeckCard(deckId, id, 0, e?.is_commander ?? false, e?.is_maybeboard ?? false); }), ); for (const id of ids) { const timer = removalTimers.current.get(id); if (timer) clearTimeout(timer); removalTimers.current.delete(id); } setPendingRemoval((p) => new Set([...p].filter((id) => !ids.has(id)))); setContents((cs) => cs.filter((e) => !ids.has(e.oracle_id))); } 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))); setSelectedCards(new Set()); } finally { setBulkBusy(false); } } async function changeFormat(format: string) { await api.setDeckFormat(deckId, format); await onDecksChanged(); } 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); } 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)); // 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(); }, }; 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 ? ★ 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)} /> {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. */}
{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.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)}
{handOpen && ( setHandOpen(false)} /> )} {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)} /> )} ); }