Add deck legality checking, deck/library import-export, and a command palette

Threads Scryfall's per-format legalities through the reference db and adds a
deck-page indicator (structural rules + banned/restricted lists) that lists
violation reasons inline. Decks and selected library cards can now be
exported to/imported from plain-text decklists, with best-effort name
resolution and a report of anything that didn't match. Also adds a shuffle-
and-draw sample hand modal, and an Alt+/ command palette for jumping to a
tab, deck, or card without leaving the keyboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:21:22 -05:00
parent cf9c930b02
commit 008ab33723
18 changed files with 1696 additions and 138 deletions

View File

@@ -10,6 +10,7 @@ import {
StatTile,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { DeckView } from "./views/DeckView";
@@ -399,6 +400,12 @@ export default function App() {
</div>
</div>
</div>
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onNewLibrary={newLibrary}
/>
</HoverPreviewProvider>
);
}

170
src/CommandPalette.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from "react";
import { api, CardRef, Deck } from "./api";
import { DialogFrame, useClickPreview } from "./components";
type Tab = "add" | "library" | "decks";
interface PaletteItem {
key: string;
label: string;
hint?: string;
run: () => void;
}
/** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd"
* matches "Go to Decks". Good enough for the small static command list and
* the deck list; card search still defers to the server-side fuzzy matcher
* via `api.searchCards`. */
function subsequenceMatch(query: string, text: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++;
}
return qi === q.length;
}
/**
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
* selecting a card opens the existing click-to-zoom preview modal without
* changing tabs.
*/
export function CommandPalette({
onNavigate,
onUpdateCardData,
onOpenLibrary,
onNewLibrary,
}: {
onNavigate: (tab: Tab) => void;
onUpdateCardData: () => void;
onOpenLibrary: () => void;
onNewLibrary: () => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [decks, setDecks] = useState<Deck[]>([]);
const [cardResults, setCardResults] = useState<CardRef[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const { open: openCardPreview } = useClickPreview();
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.altKey && e.key === "/") {
e.preventDefault();
setOpen((o) => !o);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (!open) return;
setQuery("");
setCardResults([]);
api.listDecks().then(setDecks);
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const q = query.trim();
if (!q) return setCardResults([]);
const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80);
return () => clearTimeout(t);
}, [query, open]);
function close() {
setOpen(false);
}
const staticCommands: PaletteItem[] = [
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
];
const items: PaletteItem[] = [
...staticCommands.filter((c) => subsequenceMatch(query, c.label)),
...decks
.filter((d) => subsequenceMatch(query, d.name))
.map((d) => ({
key: `deck-${d.id}`,
label: d.name,
hint: "Deck",
run: () => {
// DeckView reads its initial selection from this same
// localStorage key on mount (usePersistedState("deck-selected"),
// src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on
// tab switch, so writing here before navigating is enough for it
// to pick up the selection on its next mount.
localStorage.setItem("deck-selected", JSON.stringify(d.id));
onNavigate("decks");
},
})),
...cardResults.map((c) => ({
key: `card-${c.oracle_id}`,
label: c.name,
hint: c.type_line,
run: () => openCardPreview(c),
})),
].slice(0, 20);
useEffect(() => setSel(0), [items.length, query]);
function pick(item: PaletteItem) {
item.run();
close();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && items[sel]) {
e.preventDefault();
pick(items[sel]);
}
}
if (!open) return null;
return (
<DialogFrame title="Command palette" onCancel={close} wide>
<input
ref={inputRef}
autoFocus
style={{ width: "100%", marginBottom: 8 }}
placeholder="Type a command, deck, or card name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{items.length > 0 && (
<ul className="result-list" style={{ marginBottom: 0 }}>
{items.map((item, i) => (
<li
key={item.key}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(item)}
>
<span className="card-name">{item.label}</span>
{item.hint && <span className="card-type">{item.hint}</span>}
</li>
))}
</ul>
)}
</DialogFrame>
);
}

View File

@@ -15,6 +15,7 @@ export interface CardRef {
produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C}
price_usd: number; // USD market price, or 0 if unknown
legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS
}
export interface LibraryCard extends CardRef {
@@ -41,6 +42,16 @@ export interface Deck {
notes: string;
}
export interface DeckImportResult {
deck: Deck;
unresolved: string[];
}
export interface LibraryImportResult {
added: [string, number][];
unresolved: string[];
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
@@ -107,6 +118,32 @@ export function maskToLetters(mask: number): string {
.join("");
}
// --- Legality bitmask helpers ----------------------------------------------
// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per
// format, in this fixed order. Do not reorder.
export const LEGALITY_FORMATS: { key: string; label: string }[] = [
{ key: "standard", label: "Standard" },
{ key: "pioneer", label: "Pioneer" },
{ key: "modern", label: "Modern" },
{ key: "legacy", label: "Legacy" },
{ key: "vintage", label: "Vintage" },
{ key: "pauper", label: "Pauper" },
{ key: "commander", label: "Commander" },
{ key: "brawl", label: "Brawl" },
];
export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted";
const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"];
export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus {
const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel);
if (i < 0) return "not_legal";
const code = (legalities >> (i * 2)) & 0b11;
return LEGAL_STATUS_CODES[code];
}
// --- Commands -------------------------------------------------------------
export const api = {
@@ -150,4 +187,12 @@ export const api = {
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }),
exportLibraryCards: (oracleIds: string[], path: string) =>
invoke<void>("export_library_cards", { oracleIds, path }),
importLibraryCards: (path: string) =>
invoke<LibraryImportResult>("import_library_cards", { path }),
};

View File

@@ -63,6 +63,26 @@ const ICON_ARROW_LEFT_CIRCLE = icon(
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
const ICON_CARDS = icon(
<>
<rect x="4" y="2" width="12" height="17" rx="2" />
<rect x="8" y="5" width="12" height="17" rx="2" />
</>,
);
const ICON_DOWNLOAD = icon(
<>
<path d="M12 3v12" />
<polyline points="7 11 12 16 17 11" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_UPLOAD = icon(
<>
<path d="M12 21V9" />
<polyline points="7 13 12 8 17 13" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
@@ -108,22 +128,29 @@ export const Icons = {
close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
cards: ICON_CARDS,
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and
* [`PromptDialog`]. Dismisses on Escape or backdrop click.
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
* command palette). Dismisses on Escape or backdrop click.
*/
function DialogFrame({
export function DialogFrame({
title,
children,
onCancel,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
wide?: boolean;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
@@ -133,7 +160,7 @@ function DialogFrame({
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
<h3 className="dialog-title">{title}</h3>
{children}
</div>
@@ -220,6 +247,83 @@ export function PromptDialog({
);
}
/** Read-only informational dialog: a message plus an optional bulleted list
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
export function InfoDialog({
title,
message,
items,
onClose,
}: {
title: string;
message?: ReactNode;
items?: string[];
onClose: () => void;
}) {
return (
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
{message && <p className="dialog-message">{message}</p>}
{items && items.length > 0 && (
<ul className="dialog-list">
{items.map((it, i) => (
<li key={i}>{it}</li>
))}
</ul>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
OK
</button>
</div>
</DialogFrame>
);
}
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
export function SampleHandModal({
hand,
libraryCount,
onDrawOne,
onNewHand,
onClose,
}: {
hand: CardRef[];
libraryCount: number;
onDrawOne: () => void;
onNewHand: () => void;
onClose: () => void;
}) {
return (
<DialogFrame title="Sample hand" onCancel={onClose} wide>
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
<span className="muted" style={{ fontSize: 12 }}>
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
</span>
<span className="spacer" />
<button onClick={onDrawOne} disabled={libraryCount === 0}>
Draw a card
</button>
<button onClick={onNewHand}>New hand</button>
</div>
{hand.length > 0 ? (
<div className="tile-grid">
{hand.map((c, i) => (
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
))}
</div>
) : (
<p className="hint">Your deck has no cards to draw yet.</p>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
@@ -719,10 +823,12 @@ function primaryType(typeLine: string): string {
return words[words.length - 1] || "Other";
}
function firstSubtype(typeLine: string): string {
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
* by (type, subtype) rather than colliding on a subtype shared across types. */
function subtypeLabel(typeLine: string): string {
const parts = typeLine.split("—");
if (parts.length < 2) return "No subtype";
return parts[1].trim().split(/\s+/)[0] || "No subtype";
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
return `${primaryType(typeLine)} — ${subtype}`;
}
function colorLabel(mask: number): string {
@@ -741,7 +847,7 @@ export function groupCards<T extends CardRef>(
mode === "type"
? primaryType(c.type_line)
: mode === "subtype"
? firstSubtype(c.type_line)
? subtypeLabel(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
@@ -962,6 +1068,7 @@ export function CardTile({
card,
corner,
cornerLeft,
cornerBottomLeft,
footer,
selected,
}: {
@@ -969,6 +1076,8 @@ export function CardTile({
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
/** Rendered bottom-left, e.g. a secondary per-card action button. */
cornerBottomLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
@@ -990,6 +1099,7 @@ export function CardTile({
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>

View File

@@ -621,14 +621,14 @@ h2 {
letter-spacing: -0.2px;
}
h3 {
margin: 18px 0 10px;
font-size: 13px;
font-weight: 700;
margin: 20px 0 12px;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
letter-spacing: 0.05em;
color: var(--text-strong);
border-bottom: 2px solid var(--border-soft);
padding-bottom: 10px;
}
h4 {
margin: 14px 0 7px;
@@ -636,6 +636,26 @@ h4 {
font-weight: 600;
color: var(--accent-hover);
}
/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a
* section) — sticky within .content's scroll area so the current group
* stays visible while scrolling through a long list, instead of scrolling
* out of view along with its cards. Applied via class (not the bare h3/h4
* tag) since h3 is also used for .dialog-title, which must not be sticky. */
.group-header {
position: sticky;
top: 0;
z-index: 5;
margin: 14px 0 8px;
padding: 8px 0;
background: var(--bg-app);
font-size: 15px;
font-weight: 700;
text-transform: none;
letter-spacing: normal;
color: var(--text-strong);
border-bottom: 2px solid var(--accent);
}
.muted {
color: var(--muted);
}
@@ -660,6 +680,86 @@ h4 {
font-weight: 700;
}
/* Deck format-legality panel: a prominent, colorful tile, matching
.commander-hero / .deck-stats in weight, above the fold on every deck. */
.legality-tile {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.legality-tile.legal {
background: rgba(75, 176, 115, 0.16);
border-color: rgba(75, 176, 115, 0.4);
}
.legality-tile.illegal {
background: rgba(216, 80, 63, 0.16);
border-color: rgba(216, 80, 63, 0.4);
}
.legality-tile.unchecked {
background: var(--bg-panel);
border-color: var(--border-soft);
}
.legality-tile-head {
display: flex;
align-items: center;
gap: 14px;
}
.legality-tile-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 999px;
font-size: 20px;
font-weight: 700;
}
.legality-tile.legal .legality-tile-icon {
background: var(--success);
color: #fff;
}
.legality-tile.illegal .legality-tile-icon {
background: var(--danger);
color: #fff;
}
.legality-tile.unchecked .legality-tile-icon {
background: var(--muted);
color: var(--bg-panel);
}
.legality-tile-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.legality-tile-status {
font-weight: 700;
font-size: 14px;
}
.legality-tile.legal .legality-tile-status {
color: var(--success);
}
.legality-tile.illegal .legality-tile-status {
color: var(--danger);
}
.legality-tile-detail {
font-size: 12px;
color: var(--muted);
}
.legality-tile-reasons {
margin: 0 0 0 54px;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text);
}
/* ============================================================
Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards
and both Library views (list + tile) for a consistent look.
@@ -844,6 +944,11 @@ li:focus-within .row-actions {
padding: 20px;
box-shadow: var(--shadow-lg);
}
.dialog-wide {
width: min(900px, 92vw);
max-height: 86vh;
overflow-y: auto;
}
.dialog-title {
margin: 0 0 10px;
font-size: 16px;
@@ -860,6 +965,18 @@ li:focus-within .row-actions {
font-size: 14px;
line-height: 1.5;
}
.dialog-list {
margin: 0 0 18px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
overflow-y: auto;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.dialog-field {
display: flex;
flex-direction: column;
@@ -1210,7 +1327,10 @@ tbody tr:hover {
.preview-modal-backdrop {
position: fixed;
inset: 0;
z-index: 300;
/* Above .dialog-backdrop (400) — a card can now be clicked to zoom from
inside a dialog (e.g. the sample hand modal), and the zoomed preview
must stay on top of the dialog it was opened from. */
z-index: 450;
display: flex;
align-items: center;
justify-content: center;
@@ -1370,6 +1490,17 @@ tbody tr:hover {
.tile:focus-within .tile-corner-bottom-right {
opacity: 1;
}
.tile-corner-bottom-left {
position: absolute;
bottom: 8px;
left: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-left,
.tile:focus-within .tile-corner-bottom-left {
opacity: 1;
}
.tile-selected .tile-img {
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
}
@@ -1450,6 +1581,14 @@ tbody tr:hover {
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
/* Separates the deck-level info panels (commander, format, analysis,
legality) from the card-management controls + lists below. */
.deck-section-divider {
border: none;
border-top: 1px solid var(--border-soft);
margin: 4px 0 18px;
}
.commander-box {
display: flex;
@@ -1558,3 +1697,4 @@ tbody tr:hover {
color: var(--faint);
margin-bottom: 2px;
}

View File

@@ -1,5 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -11,9 +12,11 @@ import {
GroupMode,
IconButton,
Icons,
InfoDialog,
ManaCurve,
PromptDialog,
QtyStepper,
SampleHandModal,
StatTile,
ViewToggle,
groupCards,
@@ -34,6 +37,8 @@ type DeckDialog =
| { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck }
| { kind: "duplicate"; deck: Deck }
| { kind: "import-name"; suggestedName: string; path: string }
| { kind: "import-result"; result: DeckImportResult }
| null;
function friendlyDeckError(err: unknown): string {
@@ -181,6 +186,8 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
// the Library tab, so a mistaken removal can be undone with +.
const REMOVAL_GRACE_MS = 5000;
const STARTING_HAND_SIZE = 7;
const FORMATS = [
"",
"Commander",
@@ -195,10 +202,131 @@ const FORMATS = [
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
const COMMANDER_DECK_SIZE = 100;
const CONSTRUCTED_MIN_DECK_SIZE = 60;
const CONSTRUCTED_MAX_COPIES = 4;
/**
* Deck editor. A deck's 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. Deck-level format + commander metadata sit at the top.
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
}
const reasons: string[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
}
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 1) {
reasons.push(`More than one card is marked as commander.`);
}
const commander = commanders[0];
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
}
}
if (commander) {
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~commander.color_identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the commander's color identity.`);
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
} else {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
* groupable by card attribute; "Combine sections" merges these two into one
* list. The maybeboard is always shown as its own separate section. Deck-level
* format + commander metadata sit at the top.
*/
export function DeckView() {
const [decks, setDecks] = useState<Deck[]>([]);
@@ -207,6 +335,7 @@ export function DeckView() {
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
@@ -225,6 +354,10 @@ export function DeckView() {
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 deck = decks.find((d) => d.id === selected) ?? null;
const loadDecks = useCallback(async () => {
@@ -251,6 +384,9 @@ export function DeckView() {
useEffect(() => {
loadContents();
setSelectedCards(new Set());
setHandOpen(false);
setHand([]);
setLibraryStack([]);
}, [loadContents]);
// Cancel any in-flight removal timers on unmount so they don't fire against
@@ -310,6 +446,39 @@ export function DeckView() {
setSelected(d.id);
}
async function exportDeck(d: Deck) {
const path = await save({
title: "Export deck",
defaultPath: `${d.name}.txt`,
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportDeck(d.id, path);
}
async function pickImportFile() {
const path = await open({
title: "Import deck",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path !== "string") return;
const base = path.split(/[/\\]/).pop() ?? "Imported deck";
const suggestedName = base.replace(/\.txt$/i, "");
setDialog({ kind: "import-name", suggestedName, path });
}
async function confirmImportName(name: string) {
if (dialog?.kind !== "import-name") return;
try {
const result = await api.importDeck(dialog.path, name);
setDialog({ kind: "import-result", result });
setDialogError(null);
await loadDecks();
setSelected(result.deck.id);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function addToDeck(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
@@ -345,6 +514,35 @@ export function DeckView() {
await loadContents();
}
/** Every mainboard copy (commander/maybeboard excluded), one entry per
* physical copy so a card at quantity N is N times as likely to be drawn,
* shuffled in place (Fisher-Yates). */
function shuffledLibrary(): DeckEntry[] {
const pool: DeckEntry[] = [];
for (const e of contents) {
if (e.is_commander || e.is_maybeboard) continue;
for (let i = 0; i < e.quantity; i++) pool.push(e);
}
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool;
}
function drawNewHand() {
const shuffled = shuffledLibrary();
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
setHandOpen(true);
}
function drawOneMore() {
if (libraryStack.length === 0) return;
setHand((h) => [...h, libraryStack[0]]);
setLibraryStack((stack) => stack.slice(1));
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
const clamped = Math.max(0, qty);
@@ -478,6 +676,7 @@ export function DeckView() {
const maybeboard = contents.filter((e) => e.is_maybeboard);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
function deckTiles(entries: DeckEntry[]) {
return (
@@ -506,13 +705,10 @@ export function DeckView() {
pending={pendingRemoval.has(e.oracle_id)}
/>
}
footer={
<>
{e.is_commander && <span className="tile-badge">★ commander</span>}
<span className="spacer" />
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</>
cornerBottomLeft={
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
/>
))}
</div>
@@ -580,7 +776,7 @@ export function DeckView() {
return groupCards(entries, groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4>
<h4 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h4>
)}
@@ -592,7 +788,10 @@ export function DeckView() {
return (
<div style={{ display: "flex", gap: 20 }}>
<div style={{ width: 240, flexShrink: 0 }}>
<h2>Decks</h2>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>Decks</h2>
<IconButton icon={Icons.upload} label="Import deck…" onClick={pickImportFile} />
</div>
<div className="row" style={{ marginBottom: 10 }}>
<input
value={newName}
@@ -662,46 +861,57 @@ export function DeckView() {
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<div className="row" style={{ gap: 14 }}>
<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>
<h2>{deck.name}</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
<h2>{deck.name}</h2>
<div className="row" style={{ gap: 8 }}>
<button onClick={() => exportDeck(deck)}>
{Icons.download}
Export deck
</button>
<button
onClick={drawNewHand}
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
>
{Icons.cards}
Sample hand
</button>
</div>
)}
</div>
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<CommanderHero card={commanderEntry} onClear={clearCommander} />
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
</div>
)}
</div>
{contents.length > 0 && (
<div className="deck-stats">
<div className="deck-stats-head">Deck analysis</div>
@@ -737,64 +947,131 @@ export function DeckView() {
</div>
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="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}
/>
{deck.format !== "Other" && (
<div className={`legality-tile ${deckLegality.status}`}>
<div className="legality-tile-head">
<span className="legality-tile-icon">
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
</span>
<div className="legality-tile-info">
<span className="legality-tile-status">
{deckLegality.status === "legal"
? "Legal"
: deckLegality.status === "illegal"
? "Illegal"
: "Not checked"}
</span>
<span className="legality-tile-detail">
{deckLegality.status === "legal"
? `Meets all ${deck.format} deck-building rules.`
: deckLegality.status === "illegal"
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
: deck.format
? `Legality isn't checked for "${deck.format}".`
: "Set a format to check legality."}
</span>
</div>
</div>
)}
{deckLegality.status === "illegal" && (
<ul className="legality-tile-reasons">
{deckLegality.reasons.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
</div>
)}
<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>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
<section style={{ marginTop: 18 }}>
<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>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
<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>
{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>
@@ -848,6 +1125,38 @@ export function DeckView() {
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-name" && (
<PromptDialog
title="Import deck"
label="Deck name"
defaultValue={dialog.suggestedName}
confirmLabel="Import"
error={dialogError}
onConfirm={confirmImportName}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-result" && (
<InfoDialog
title="Deck imported"
message={
dialog.result.unresolved.length > 0
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Imported "${dialog.result.deck.name}" — every card matched.`
}
items={dialog.result.unresolved}
onClose={() => setDialog(null)}
/>
)}
{handOpen && (
<SampleHandModal
hand={hand}
libraryCount={libraryStack.length}
onDrawOne={drawOneMore}
onNewHand={drawNewHand}
onClose={() => setHandOpen(false)}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
import {
CardThumb,
CardTile,
@@ -7,6 +8,7 @@ import {
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
LocationField,
PromptDialog,
QtyStepper,
@@ -49,9 +51,11 @@ export function LibraryBrowser() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const [bulkDelta, setBulkDelta] = useState(1);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -172,7 +176,8 @@ export function LibraryBrowser() {
* owned count (e.g. select a handful of basic lands, "Add All" to put
* every copy you own into the deck at once) — overwrites the quantity for
* cards already in the deck rather than skipping them, so it doubles as a
* bulk "fix the land count" tool.
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
* deck's maybeboard instead of the mainboard.
*/
async function bulkAddToDeck(mode: "one" | "all") {
if (bulkDeckId === "") return;
@@ -183,7 +188,13 @@ export function LibraryBrowser() {
const targets = rows.filter((r) => selected.has(r.oracle_id));
await Promise.all(
targets.map((r) =>
api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false),
api.setDeckCard(
deck.id,
r.oracle_id,
mode === "all" ? r.quantity_owned : 1,
false,
bulkToMaybeboard,
),
),
);
setRefreshKey((k) => k + 1);
@@ -212,6 +223,28 @@ export function LibraryBrowser() {
}
}
async function exportSelected() {
const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id);
if (targets.length === 0) return;
const path = await save({
title: "Export library cards",
defaultPath: "library-export.txt",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportLibraryCards(targets, path);
}
async function pickImportFile() {
const path = await open({
title: "Import library cards",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path !== "string") return;
const result = await api.importLibraryCards(path);
setImportResult(result);
setRefreshKey((k) => k + 1);
}
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
@@ -342,6 +375,7 @@ export function LibraryBrowser() {
</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<button onClick={pickImportFile}>Import cards…</button>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
@@ -378,6 +412,17 @@ export function LibraryBrowser() {
>
Add All
</button>
<label className="row" style={{ gap: 4, cursor: "pointer" }} title="Send to the maybeboard instead of the mainboard">
<input
type="checkbox"
checked={bulkToMaybeboard}
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
disabled={bulkBusy}
/>
<span className="muted" style={{ fontSize: 12 }}>
Maybeboard
</span>
</label>
</span>
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
Set location…
@@ -405,6 +450,13 @@ export function LibraryBrowser() {
+
</button>
</span>
<button
onClick={exportSelected}
disabled={bulkBusy}
title="Export the selected cards to a decklist file"
>
Export selected
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
@@ -424,6 +476,19 @@ export function LibraryBrowser() {
/>
)}
{importResult && (
<InfoDialog
title="Cards imported"
message={
importResult.unresolved.length > 0
? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.`
}
items={importResult.unresolved}
onClose={() => setImportResult(null)}
/>
)}
<div className="filters">
<div className="field">
<label>Name contains</label>
@@ -516,7 +581,7 @@ export function LibraryBrowser() {
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3>
<h3 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}