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:
@@ -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
170
src/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/api.ts
45
src/api.ts
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
156
src/styles.css
156
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user