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

@@ -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>