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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user