Files
mtgc/src/views/DeckView.tsx

1343 lines
47 KiB
TypeScript
Raw Normal View History

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,
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 (
<DialogFrame title="New deck" onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">Deck name</span>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirm()}
/>
</label>
<label className="dialog-field">
<span className="muted">Format</span>
<select value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={confirm} disabled={!name.trim()}>
Create
</button>
</div>
</DialogFrame>
);
}
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). */
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
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}
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
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 ${card.name} as 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 extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
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<void>;
onRename: () => void;
onDuplicate: () => void;
onDelete: () => void;
onExport: () => void;
}) {
const deckId = deck.id;
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 [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 [partnerQuery, setPartnerQuery] = useState("");
const [partnerResults, setPartnerResults] = 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());
// 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<Map<string, number>>(new Map());
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);
// 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<string, number>();
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 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]);
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
// Lets the click preview track copies and choose each slot's printing.
const preview: PreviewContext = {
deckId,
coverOracleId: deck.cover_oracle_id,
onChanged: () => {
loadContents();
onDecksChanged();
},
};
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
function deckTiles(entries: DeckEntry[]) {
return (
<div {...tiles.gridProps}>
{entries.map((e) => (
<CardTile
key={e.oracle_id}
card={e}
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
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={
<span className="row" style={{ gap: 4 }}>
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
{e.quantity_owned === 0 && (
<IconButton
overlay
icon={Icons.libraryAdd}
label={libraryAddLabel(e)}
onClick={() => addToLibrary([e])}
/>
)}
</span>
}
footer={
e.is_commander || partialRemoval.has(e.oracle_id) ? (
<span className="row" style={{ gap: 4 }}>
{e.is_commander && <span className="tile-badge">★ commander</span>}
{partialRemoval.has(e.oracle_id) && (
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
)}
</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">
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
<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}
{partialRemoval.has(e.oracle_id) && (
<span className="badge" style={{ marginLeft: 6 }}>
{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}
</span>
)}
</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>
<span className="row" style={{ gap: 4 }}>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
{e.quantity_owned === 0 && (
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
)}
</span>
</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-builder">
<button className="deck-builder-back" onClick={onBack}>
{Icons.arrowLeft}
All decks
</button>
<div className="row" style={{ justifyContent: "space-between" }}>
<div className="row deck-builder-title">
<h2>{deck.name}</h2>
<IconButton icon={Icons.pencil} label="Rename deck" onClick={onRename} />
<IconButton icon={Icons.copy} label="Duplicate deck" onClick={onDuplicate} />
<IconButton icon={Icons.trash} label="Delete deck" danger onClick={onDelete} />
</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={onExport}>
{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">
{commanders.map((c) => (
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} 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}
/>
{commanderEntry?.partner && !partnerEntry && (
<CardSearchBox
placeholder={partnerPlaceholder(commanderEntry.partner)}
value={partnerQuery}
onChange={setPartnerQuery}
results={partnerResults}
onPick={addPartner}
/>
)}
</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="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" />
<StatTile value={formatCost(deckStats.totalCost)} label="cost" />
</div>
<div className="deck-stats-body">
<div className="deck-stats-numbers">
<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.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:`
: deck.format
? `Legality isn't checked for "${deck.format}".`
: "Set a format to check legality."}
</span>
</div>
{illegalSelection(deckLegality.issues).size > 0 && (
<>
<span className="spacer" />
<button className="legality-tile-select" onClick={() => selectIllegal()}>
Select illegal cards
</button>
</>
)}
</div>
{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 (
<div key={cat.key} className="legality-tile-group">
<div className="legality-tile-group-head">
<span>{cat.label}</span>
{cat.selectable && cardCount > 0 && (
<button className="legality-tile-select" onClick={() => selectIllegal([cat.key])}>
Select ({cardCount})
</button>
)}
</div>
<ul className="legality-tile-reasons">
{issues.map((i, n) => (
<li key={n}>{i.message}</li>
))}
</ul>
</div>
);
})}
</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
{partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`}
</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
<button
onClick={bulkAddToLibrary}
disabled={bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && shortBy(e) > 0)}
title="Add the copies these cards are missing to your library"
>
Add to library
</button>
</div>
<span className="spacer" />
<button onClick={clearSelection} 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>
{handOpen && (
<SampleHandModal
hand={hand}
libraryCount={libraryStack.length}
onDrawOne={drawOneMore}
onNewHand={drawNewHand}
onClose={() => setHandOpen(false)}
/>
)}
{suggestOpen && (
<SuggestionsModal
deckId={deck.id}
onClose={() => setSuggestOpen(false)}
onCardAdded={loadContents}
/>
)}
</div>
);
}
// 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<HTMLElement>(".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<Deck[] | null>(null);
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
// When each deck was last opened, for the list's "Recently opened" sort.
const [openedAt, setOpenedAt] = usePersistedState<Record<number, number>>(libraryKey("deck-opened"), {});
// The format the last new deck was created with; null until one has been.
const [lastNewFormat, setLastNewFormat] = usePersistedState<string | null>("new-deck-format", null);
const [screen, setScreenState] = useState(lastScreen);
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(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<Deck | null>(
(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 ? (
<DeckBuilder
key={deck.id}
deck={deck}
refreshSignal={refreshSignal}
onBack={() => setScreen("list")}
onDecksChanged={loadDecks}
onRename={() => showDialog({ kind: "rename", deck })}
onDuplicate={() => showDialog({ kind: "duplicate", deck })}
onDelete={() => showDialog({ kind: "delete", deck })}
onExport={() => exportDeck(deck)}
/>
) : (
<DeckList
decks={decks}
refreshSignal={refreshSignal}
openedAt={openedAt}
onOpen={openDeck}
onNew={() => 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" && (
<NewDeckDialog
defaultFormat={newDeckFormat}
error={dialogError}
onConfirm={confirmCreate}
onCancel={() => setDialog(null)}
/>
)}
{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)}
/>
)}
</>
);
}