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