A Commander/Brawl deck can now have two commanders when their abilities allow it. Partner abilities are parsed from oracle text (Scryfall's keyword list files every "Partner—Survivors"-style variant under plain "Partner"), and a second search box offers only cards that can pair with the commander. The legality check accepts a valid pair and checks color identity against both; suggestions and decklist import/export handle both commanders. Closes #4
1399 lines
51 KiB
TypeScript
1399 lines
51 KiB
TypeScript
import { open, save } from "@tauri-apps/plugin-dialog";
|
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
import {
|
|
api,
|
|
CardRef,
|
|
COLORS,
|
|
Deck,
|
|
DeckEntry,
|
|
DeckImportResult,
|
|
legalStatusFor,
|
|
libraryKey,
|
|
Partner,
|
|
PreviewContext,
|
|
} from "../api";
|
|
import {
|
|
CardSearchBox,
|
|
CardThumb,
|
|
CardTile,
|
|
ColorDistributionBar,
|
|
ColorPips,
|
|
ConfirmDialog,
|
|
GroupBySelect,
|
|
GroupMode,
|
|
IconButton,
|
|
Icons,
|
|
InfoDialog,
|
|
ManaCurve,
|
|
PromptDialog,
|
|
QtyStepper,
|
|
SampleHandModal,
|
|
StatTile,
|
|
TileColumnsControl,
|
|
ViewToggle,
|
|
groupCards,
|
|
useClickPreview,
|
|
useHoverPreview,
|
|
usePersistedState,
|
|
useTileColumns,
|
|
useViewMode,
|
|
} from "../components";
|
|
import { SuggestionsModal } from "../SuggestionsModal";
|
|
import { useBackButton } from "../backButton";
|
|
|
|
/** Whether a CSS media query currently matches, kept live across resizes. */
|
|
function useMediaQuery(query: string) {
|
|
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
|
useEffect(() => {
|
|
const mql = window.matchMedia(query);
|
|
const onChange = () => setMatches(mql.matches);
|
|
onChange();
|
|
mql.addEventListener("change", onChange);
|
|
return () => mql.removeEventListener("change", onChange);
|
|
}, [query]);
|
|
return matches;
|
|
}
|
|
|
|
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
|
|
function boardToggleProps(e: DeckEntry) {
|
|
return e.is_maybeboard
|
|
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
|
|
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
|
}
|
|
|
|
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 {
|
|
const msg = String(err);
|
|
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
|
}
|
|
|
|
interface DeckStats {
|
|
totalCards: number;
|
|
uniqueCards: number;
|
|
landCount: number;
|
|
avgManaValue: number;
|
|
curve: {
|
|
label: string;
|
|
count: number;
|
|
colorCounts: [number, number, number, number, number];
|
|
colorlessCount: number;
|
|
}[];
|
|
colorCounts: [number, number, number, number, number];
|
|
colorlessCount: number;
|
|
manaProduction: [number, number, number, number, number];
|
|
colorlessProduction: number;
|
|
manaConsumption: [number, number, number, number, number];
|
|
colorlessConsumption: number;
|
|
}
|
|
|
|
/**
|
|
* Deck composition stats, excluding the commander (shown separately in its
|
|
* own hero display) and the maybeboard (cards under consideration, not
|
|
* actually part of the deck) — matches the usual "99 + commander" framing
|
|
* even for non-Commander decks, where it's just "everything but those".
|
|
*/
|
|
function computeDeckStats(contents: DeckEntry[]): DeckStats {
|
|
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
|
|
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
|
const lands = cards.filter((e) => e.type_line.includes("Land"));
|
|
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
|
|
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
|
|
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
|
|
const avgManaValue = nonlandCount
|
|
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
|
|
: 0;
|
|
|
|
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
|
|
const curveBuckets = new Map(
|
|
bucketLabels.map((l) => [
|
|
l,
|
|
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
|
|
]),
|
|
);
|
|
for (const e of nonlands) {
|
|
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
|
|
const bucket = curveBuckets.get(label)!;
|
|
bucket.count += e.quantity;
|
|
if (e.color_identity === 0) {
|
|
bucket.colorlessCount += e.quantity;
|
|
} else {
|
|
COLORS.forEach((c, i) => {
|
|
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
|
|
});
|
|
}
|
|
}
|
|
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
|
|
|
|
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
|
let colorlessCount = 0;
|
|
for (const e of cards) {
|
|
if (e.color_identity === 0) {
|
|
colorlessCount += e.quantity;
|
|
} else {
|
|
COLORS.forEach((c, i) => {
|
|
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
|
|
});
|
|
}
|
|
}
|
|
|
|
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
|
let colorlessProduction = 0;
|
|
for (const e of cards) {
|
|
if (e.produces_colorless) colorlessProduction += e.quantity;
|
|
COLORS.forEach((c, i) => {
|
|
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
|
|
});
|
|
}
|
|
|
|
// Consumption: colored cost requirements of the spells actually cast —
|
|
// lands are excluded since they have no colored casting cost.
|
|
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
|
let colorlessConsumption = 0;
|
|
for (const e of nonlands) {
|
|
if (e.colors === 0) {
|
|
colorlessConsumption += e.quantity;
|
|
} else {
|
|
COLORS.forEach((c, i) => {
|
|
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalCards,
|
|
uniqueCards: cards.length,
|
|
landCount,
|
|
avgManaValue,
|
|
curve,
|
|
colorCounts,
|
|
colorlessCount,
|
|
manaProduction,
|
|
colorlessProduction,
|
|
manaConsumption,
|
|
colorlessConsumption,
|
|
};
|
|
}
|
|
|
|
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
|
|
function CommanderHero({
|
|
card,
|
|
onClear,
|
|
preview,
|
|
}: {
|
|
card: DeckEntry;
|
|
onClear: () => void;
|
|
preview?: PreviewContext;
|
|
}) {
|
|
const { handlers } = useHoverPreview();
|
|
const { open } = useClickPreview();
|
|
return (
|
|
<div className="panel commander-hero">
|
|
{card.image_uri ? (
|
|
<img
|
|
className="commander-hero-img"
|
|
src={card.image_uri}
|
|
alt={card.name}
|
|
onClick={() => open(card, preview)}
|
|
{...handlers(card)}
|
|
/>
|
|
) : (
|
|
<div className="commander-hero-noimg">{card.name}</div>
|
|
)}
|
|
<div className="commander-hero-info">
|
|
<span className="commander-hero-label">Commander</span>
|
|
<span className="commander-hero-name">{card.name}</span>
|
|
<span className="commander-hero-type">{card.type_line}</span>
|
|
<ColorPips mask={card.color_identity} />
|
|
</div>
|
|
<IconButton icon={Icons.close} label={`Clear ${card.name} as commander`} onClick={onClear} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
|
|
* crates/mtgc-core/src/partner.rs — keep the two in step. */
|
|
function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
|
|
const p = a.partner;
|
|
const q = b.partner;
|
|
if (!p || !q) return false;
|
|
switch (p.kind) {
|
|
case "partner":
|
|
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
|
|
case "variant":
|
|
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
|
|
case "with":
|
|
return q.kind === "with" && p.name === b.name && q.name === a.name;
|
|
case "chooses_background":
|
|
return q.kind === "background";
|
|
case "background":
|
|
return q.kind === "chooses_background";
|
|
case "doctors_companion":
|
|
return q.kind === "doctor";
|
|
case "doctor":
|
|
return q.kind === "doctors_companion";
|
|
}
|
|
}
|
|
|
|
/** What the second-commander search box asks for, given the first's ability. */
|
|
function partnerPlaceholder(partner: Partner): string {
|
|
switch (partner.kind) {
|
|
case "chooses_background":
|
|
return "Search to add a Background…";
|
|
case "background":
|
|
return "Search to add a commander that chooses a Background…";
|
|
case "doctors_companion":
|
|
return "Search to add a Doctor…";
|
|
case "doctor":
|
|
return "Search to add a Doctor's companion…";
|
|
case "with":
|
|
return `Search to add ${partner.name}…`;
|
|
case "variant":
|
|
return `Search to add a partner (${partner.name})…`;
|
|
case "partner":
|
|
return "Search to add a partner…";
|
|
}
|
|
}
|
|
|
|
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
|
// before actually dropping out of the deck — same grace-period pattern as
|
|
// the Library tab, so a mistaken removal can be undone with +.
|
|
const REMOVAL_GRACE_MS = 5000;
|
|
|
|
const STARTING_HAND_SIZE = 7;
|
|
|
|
const FORMATS = [
|
|
"",
|
|
"Commander",
|
|
"Brawl",
|
|
"Standard",
|
|
"Pioneer",
|
|
"Modern",
|
|
"Legacy",
|
|
"Vintage",
|
|
"Pauper",
|
|
"Other",
|
|
];
|
|
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;
|
|
|
|
/**
|
|
* 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).`,
|
|
);
|
|
}
|
|
|
|
// Two commanders are allowed when their partner abilities pair them up.
|
|
const commanders = cards.filter((e) => e.is_commander);
|
|
if (commanders.length === 0) {
|
|
reasons.push(`No commander is set.`);
|
|
} else if (commanders.length > 2) {
|
|
reasons.push(`More than two cards are marked as commanders.`);
|
|
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
|
|
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
|
|
}
|
|
|
|
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 (commanders.length > 0) {
|
|
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
|
|
const whose = commanders.length > 1 ? "commanders'" : "commander's";
|
|
for (const e of cards) {
|
|
if (e.is_commander) continue;
|
|
const outsideIdentity = e.color_identity & ~identity;
|
|
if (outsideIdentity !== 0) {
|
|
reasons.push(`${e.name}'s color identity is outside the ${whose} 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.
|
|
*
|
|
* `refreshSignal` is bumped by `App` when something outside this view's own
|
|
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
|
|
* from the command palette while this view is already mounted — so the
|
|
* card list, mana curve, and legality tile recompute immediately instead of
|
|
* only on the next mount.
|
|
*/
|
|
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|
const [decks, setDecks] = useState<Deck[]>([]);
|
|
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
|
|
const [newName, setNewName] = useState("");
|
|
const [contents, setContents] = useState<DeckEntry[]>([]);
|
|
const [view, setView] = useViewMode("deck-view");
|
|
const tiles = useTileColumns();
|
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
|
const [filterQuery, setFilterQuery] = useState("");
|
|
const [dialog, setDialog] = useState<DeckDialog>(null);
|
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
|
// On narrow screens the deck list and the deck-building interface are
|
|
// separate "screens" (only one visible at a time) rather than stacked —
|
|
// this only matters below the .deck-layout stacking breakpoint; CSS keeps
|
|
// both panes visible side by side above it regardless of this flag.
|
|
const [mobileShowList, setMobileShowList] = useState(true);
|
|
// Android back from the deck-building screen returns to the deck list, like
|
|
// its ← button. Only while they're separate screens (the ≤720px
|
|
// `.deck-layout` breakpoint): side by side, flipping the flag changes
|
|
// nothing visible, so back would seem to do nothing.
|
|
const listIsSeparateScreen = useMediaQuery("(max-width: 720px)");
|
|
useBackButton(() => setMobileShowList(true), listIsSeparateScreen && !mobileShowList);
|
|
|
|
const [libQuery, setLibQuery] = useState("");
|
|
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
|
const [anyQuery, setAnyQuery] = useState("");
|
|
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
|
|
const [cmdQuery, setCmdQuery] = useState("");
|
|
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
|
const [partnerQuery, setPartnerQuery] = useState("");
|
|
const [partnerResults, setPartnerResults] = useState<CardRef[]>([]);
|
|
const [maybeQuery, setMaybeQuery] = useState("");
|
|
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
|
|
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
|
|
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 [suggestOpen, setSuggestOpen] = useState(false);
|
|
|
|
const deck = decks.find((d) => d.id === selected) ?? null;
|
|
|
|
const loadDecks = useCallback(async () => {
|
|
const d = await api.listDecks();
|
|
setDecks(d);
|
|
// Keep the persisted selection if it still exists, else fall back to the first.
|
|
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
|
|
setSelected(d[0].id);
|
|
}
|
|
}, [selected, setSelected]);
|
|
|
|
useEffect(() => {
|
|
loadDecks();
|
|
}, [loadDecks]);
|
|
|
|
const loadContents = useCallback(async () => {
|
|
if (selected === null) {
|
|
setContents([]);
|
|
return;
|
|
}
|
|
setContents(await api.deckContents(selected));
|
|
}, [selected]);
|
|
|
|
useEffect(() => {
|
|
loadContents();
|
|
setSelectedCards(new Set());
|
|
setHandOpen(false);
|
|
setHand([]);
|
|
setLibraryStack([]);
|
|
setFilterQuery("");
|
|
}, [loadContents]);
|
|
|
|
// Refetch on an external refresh signal without resetting the UI state
|
|
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
|
// selection, or open sample hand). Skips the first render so it doesn't
|
|
// duplicate the mount-time fetch above.
|
|
const skipFirstRefresh = useRef(true);
|
|
useEffect(() => {
|
|
if (skipFirstRefresh.current) {
|
|
skipFirstRefresh.current = false;
|
|
return;
|
|
}
|
|
loadContents();
|
|
}, [refreshSignal]);
|
|
|
|
// Cancel any in-flight removal timers on unmount so they don't fire against
|
|
// an unmounted component.
|
|
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
|
|
|
// Keep selection in sync with what's actually visible — drop ids that fall
|
|
// out of the deck (deck switched, or a card's qty hit 0 and its grace
|
|
// period expired).
|
|
useEffect(() => {
|
|
setSelectedCards((s) => {
|
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
|
const next = new Set([...s].filter((id) => visible.has(id)));
|
|
return next.size === s.size ? s : next;
|
|
});
|
|
}, [contents]);
|
|
|
|
useEffect(() => {
|
|
if (selectAllRef.current) {
|
|
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
|
|
}
|
|
}, [selectedCards, contents]);
|
|
|
|
useEffect(() => {
|
|
const q = libQuery.trim();
|
|
if (!q) return setLibResults([]);
|
|
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
|
|
return () => clearTimeout(t);
|
|
}, [libQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = anyQuery.trim();
|
|
if (!q) return setAnyResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [anyQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = maybeQuery.trim();
|
|
if (!q) return setMaybeResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [maybeQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = cmdQuery.trim();
|
|
if (!q) return setCmdResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [cmdQuery]);
|
|
|
|
// The primary commander, whose partner ability the second search box serves.
|
|
const partnerOf =
|
|
deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null;
|
|
useEffect(() => {
|
|
const q = partnerQuery.trim();
|
|
if (!q || !partnerOf) return setPartnerResults([]);
|
|
const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [partnerQuery, partnerOf]);
|
|
|
|
async function createDeck() {
|
|
if (!newName.trim()) return;
|
|
const d = await api.createDeck(newName.trim(), null);
|
|
setNewName("");
|
|
await loadDecks();
|
|
setSelected(d.id);
|
|
setMobileShowList(false);
|
|
}
|
|
|
|
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);
|
|
setMobileShowList(false);
|
|
} 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);
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
|
await api.setDeckCard(selected, card.oracle_id, qty, false, false);
|
|
setLibQuery("");
|
|
setLibResults([]);
|
|
setAnyQuery("");
|
|
setAnyResults([]);
|
|
await loadContents();
|
|
}
|
|
|
|
async function addToMaybeboard(card: CardRef) {
|
|
if (selected === null) return;
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
|
await api.setDeckCard(selected, card.oracle_id, qty, false, true);
|
|
setMaybeQuery("");
|
|
setMaybeResults([]);
|
|
await loadContents();
|
|
}
|
|
|
|
/** Move a card between the mainboard and the maybeboard. Clears commander
|
|
* status (dropping it from the deck's commanders) when maybeboarding. */
|
|
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
|
|
if (selected === null) return;
|
|
const isCommander = isMaybeboard ? false : entry.is_commander;
|
|
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
|
|
if (isMaybeboard && entry.is_commander) {
|
|
await api.setDeckCommanders(
|
|
selected,
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
);
|
|
await loadDecks();
|
|
}
|
|
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);
|
|
const existing = contents.find((e) => e.oracle_id === oracleId);
|
|
await api.setDeckCard(
|
|
selected,
|
|
oracleId,
|
|
clamped,
|
|
existing?.is_commander ?? false,
|
|
existing?.is_maybeboard ?? false,
|
|
);
|
|
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
|
|
|
|
// Re-adding during the grace period below cancels the pending removal.
|
|
const timer = removalTimers.current.get(oracleId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
removalTimers.current.delete(oracleId);
|
|
setPendingRemoval((p) => {
|
|
const next = new Set(p);
|
|
next.delete(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
if (clamped <= 0) {
|
|
// deck_contents wouldn't include this card on a fresh fetch anymore —
|
|
// rather than dropping the row immediately, dim it and give the user a
|
|
// window to undo a mistaken decrement (via +) before it disappears.
|
|
setPendingRemoval((p) => new Set(p).add(oracleId));
|
|
removalTimers.current.set(
|
|
oracleId,
|
|
setTimeout(() => {
|
|
setContents((cs) => cs.filter((e) => e.oracle_id !== oracleId));
|
|
setPendingRemoval((p) => {
|
|
const next = new Set(p);
|
|
next.delete(oracleId);
|
|
return next;
|
|
});
|
|
removalTimers.current.delete(oracleId);
|
|
}, REMOVAL_GRACE_MS),
|
|
);
|
|
}
|
|
}
|
|
|
|
function toggleCard(oracleId: string) {
|
|
setSelectedCards((s) => {
|
|
const next = new Set(s);
|
|
if (next.has(oracleId)) next.delete(oracleId);
|
|
else next.add(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleSelectAllCards() {
|
|
setSelectedCards((s) =>
|
|
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
|
);
|
|
}
|
|
|
|
async function bulkRemoveFromDeck() {
|
|
setBulkBusy(true);
|
|
try {
|
|
await Promise.all([...selectedCards].map((id) => setQty(id, 0)));
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (dialog?.kind !== "delete") return;
|
|
await api.deleteDeck(dialog.deck.id);
|
|
if (selected === dialog.deck.id) {
|
|
setSelected(null);
|
|
setMobileShowList(true);
|
|
}
|
|
setDialog(null);
|
|
await loadDecks();
|
|
}
|
|
|
|
async function confirmRename(name: string) {
|
|
if (dialog?.kind !== "rename") return;
|
|
try {
|
|
await api.renameDeck(dialog.deck.id, name);
|
|
setDialog(null);
|
|
setDialogError(null);
|
|
await loadDecks();
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function confirmDuplicate(newName: string) {
|
|
if (dialog?.kind !== "duplicate") return;
|
|
try {
|
|
const d = await api.duplicateDeck(dialog.deck.id, newName);
|
|
setDialog(null);
|
|
setDialogError(null);
|
|
await loadDecks();
|
|
setSelected(d.id);
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function changeFormat(format: string) {
|
|
if (selected === null) return;
|
|
await api.setDeckFormat(selected, format);
|
|
await loadDecks();
|
|
}
|
|
|
|
async function setCommander(card: CardRef) {
|
|
if (selected === null) return;
|
|
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
|
|
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
|
|
// Keep the partner only if it can still pair with the new commander; if
|
|
// not, it stays in the deck as an ordinary card.
|
|
const picked = (await api.deckContents(selected)).find((e) => e.oracle_id === card.oracle_id);
|
|
const keepPartner =
|
|
partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry);
|
|
await api.setDeckCommanders(
|
|
selected,
|
|
keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id],
|
|
);
|
|
setCmdQuery("");
|
|
setCmdResults([]);
|
|
await loadContents();
|
|
await loadDecks();
|
|
}
|
|
|
|
async function addPartner(card: CardRef) {
|
|
if (selected === null || !commanderEntry) return;
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
|
|
await api.setDeckCommanders(selected, [commanderEntry.oracle_id, card.oracle_id]);
|
|
setPartnerQuery("");
|
|
setPartnerResults([]);
|
|
await loadContents();
|
|
await loadDecks();
|
|
}
|
|
|
|
/** Stop treating a card as a commander; it stays in the deck. Clearing the
|
|
* primary commander promotes its partner. */
|
|
async function clearCommander(entry: DeckEntry) {
|
|
if (selected === null) return;
|
|
await api.setDeckCommanders(
|
|
selected,
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
);
|
|
await loadContents();
|
|
await loadDecks();
|
|
}
|
|
|
|
// Quick-search over cards already in the deck/maybeboard — a display
|
|
// filter only, distinct from the CardSearchBox boxes below (which search
|
|
// the library/reference db to find NEW cards to add).
|
|
function matchesFilter(e: DeckEntry): boolean {
|
|
const q = filterQuery.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return e.name.toLowerCase().includes(q) || e.type_line.toLowerCase().includes(q);
|
|
}
|
|
|
|
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
|
|
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
|
|
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
|
// The primary commander first, then its partner, if any.
|
|
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
|
const commanders = contents
|
|
.filter((e) => e.is_commander && !e.is_maybeboard)
|
|
.sort((a, b) => isPrimary(b) - isPrimary(a));
|
|
const commanderEntry: DeckEntry | undefined = commanders[0];
|
|
const partnerEntry: DeckEntry | undefined = commanders[1];
|
|
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
|
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
|
|
|
// Lets the click preview track copies and choose each slot's printing.
|
|
const preview: PreviewContext | undefined =
|
|
selected === null ? undefined : { deckId: selected, onChanged: loadContents };
|
|
|
|
function deckTiles(entries: DeckEntry[]) {
|
|
return (
|
|
<div {...tiles.gridProps}>
|
|
{entries.map((e) => (
|
|
<CardTile
|
|
key={e.oracle_id}
|
|
card={e}
|
|
preview={preview}
|
|
selected={selectedCards.has(e.oracle_id)}
|
|
cornerLeft={
|
|
<span className="tile-select">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
/>
|
|
</span>
|
|
}
|
|
corner={
|
|
<QtyStepper
|
|
overlay
|
|
value={e.quantity}
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
|
/>
|
|
}
|
|
cornerBottomLeft={
|
|
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
}
|
|
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function deckTable(entries: DeckEntry[]) {
|
|
return (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th></th>
|
|
<th>Colors</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>In deck</th>
|
|
<th>Owned</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e) => (
|
|
<tr key={e.oracle_id}>
|
|
<td className="cell-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
/>
|
|
</td>
|
|
<td className="cell-thumb">
|
|
<CardThumb card={e} preview={preview} />
|
|
</td>
|
|
<td>
|
|
<ColorPips mask={e.color_identity} />
|
|
</td>
|
|
<td className="card-name">
|
|
{e.is_commander && <span title="Commander">★ </span>}
|
|
{e.name}
|
|
</td>
|
|
<td className="card-type">{e.type_line}</td>
|
|
<td data-label="In deck">
|
|
<QtyStepper
|
|
value={e.quantity}
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
|
/>
|
|
</td>
|
|
<td data-label="Owned">{e.quantity_owned}</td>
|
|
<td>
|
|
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function cardsGroup(entries: DeckEntry[]) {
|
|
if (entries.length === 0) return <p className="hint">None yet.</p>;
|
|
return groupCards(entries, groupBy).map((g) => (
|
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
|
{g.label && (
|
|
<h4 className="group-header">
|
|
{g.label} <span className="badge">{g.cards.length}</span>
|
|
</h4>
|
|
)}
|
|
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
return (
|
|
<div className={`deck-layout ${mobileShowList ? "show-list" : "show-detail"}`}>
|
|
<div className="deck-sidebar">
|
|
<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}
|
|
placeholder="New deck name"
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && createDeck()}
|
|
/>
|
|
<button className="primary" onClick={createDeck}>
|
|
+
|
|
</button>
|
|
</div>
|
|
<ul className="result-list deck-list">
|
|
{decks.map((d) => (
|
|
<li
|
|
key={d.id}
|
|
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
|
onClick={() => {
|
|
setSelected(d.id);
|
|
setMobileShowList(false);
|
|
}}
|
|
>
|
|
<div className="deck-list-row">
|
|
<span className="card-name">{d.name}</span>
|
|
</div>
|
|
<div className="deck-list-row deck-list-actions-row">
|
|
{d.format ? <span className="deck-chip">{d.format}</span> : <span />}
|
|
<span className="row-actions">
|
|
<IconButton
|
|
icon={Icons.pencil}
|
|
label="Rename deck"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialogError(null);
|
|
setDialog({ kind: "rename", deck: d });
|
|
}}
|
|
/>
|
|
<IconButton
|
|
icon={Icons.copy}
|
|
label="Duplicate deck"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialogError(null);
|
|
setDialog({ kind: "duplicate", deck: d });
|
|
}}
|
|
/>
|
|
<IconButton
|
|
icon={Icons.trash}
|
|
label="Delete deck"
|
|
danger
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialog({ kind: "delete", deck: d });
|
|
}}
|
|
/>
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
{decks.length === 0 && (
|
|
<li style={{ cursor: "default" }} className="hint">
|
|
No decks yet.
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="deck-detail-pane">
|
|
{selected === null || !deck ? (
|
|
<p className="hint">Select or create a deck.</p>
|
|
) : (
|
|
<>
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<span className="deck-back-btn">
|
|
<IconButton
|
|
icon={Icons.arrowLeftCircle}
|
|
label="Back to decks"
|
|
onClick={() => setMobileShowList(true)}
|
|
/>
|
|
</span>
|
|
<h2>{deck.name}</h2>
|
|
</div>
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<button
|
|
onClick={() => setSuggestOpen(true)}
|
|
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
|
|
>
|
|
{Icons.cards}
|
|
Suggest cards
|
|
</button>
|
|
<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>
|
|
|
|
<div className="deck-top-grid">
|
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
|
<div className="deck-top-commander">
|
|
{commanders.map((c) => (
|
|
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} preview={preview} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
|
<div className="deck-top-meta">
|
|
<div className="panel 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}
|
|
/>
|
|
{commanderEntry?.partner && !partnerEntry && (
|
|
<CardSearchBox
|
|
placeholder={partnerPlaceholder(commanderEntry.partner)}
|
|
value={partnerQuery}
|
|
onChange={setPartnerQuery}
|
|
results={partnerResults}
|
|
onPick={addPartner}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{contents.length > 0 && (
|
|
<div className="deck-top-stats">
|
|
<div className="panel deck-stats">
|
|
<div className="deck-stats-head section-label">Deck analysis</div>
|
|
<div className="deck-stats-body">
|
|
<div className="deck-stats-numbers">
|
|
<div className="stat-tiles-row">
|
|
<StatTile value={deckStats.totalCards} label="cards" />
|
|
<StatTile value={deckStats.uniqueCards} label="unique" />
|
|
<StatTile value={deckStats.landCount} label="lands" />
|
|
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
|
</div>
|
|
<div className="deck-stats-curve-label">Color identity</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.colorCounts}
|
|
colorlessCount={deckStats.colorlessCount}
|
|
/>
|
|
<div className="deck-stats-curve-label">Mana production</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.manaProduction}
|
|
colorlessCount={deckStats.colorlessProduction}
|
|
/>
|
|
<div className="deck-stats-curve-label">Mana consumption</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.manaConsumption}
|
|
colorlessCount={deckStats.colorlessConsumption}
|
|
/>
|
|
</div>
|
|
<div className="deck-stats-curve">
|
|
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
|
<ManaCurve buckets={deckStats.curve} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{deck.format !== "Other" && (
|
|
<div className="deck-top-legality">
|
|
<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>
|
|
</div>
|
|
)}
|
|
</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>
|
|
<input
|
|
value={filterQuery}
|
|
onChange={(e) => setFilterQuery(e.target.value)}
|
|
placeholder="Filter cards in deck…"
|
|
style={{ width: 200 }}
|
|
/>
|
|
<span className="spacer" />
|
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
|
<ViewToggle mode={view} onChange={setView} />
|
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
|
</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
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
Maybeboard <span className="badge">{maybeboard.length}</span>
|
|
</h3>
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
Cards under consideration — not counted in deck analysis.
|
|
</p>
|
|
<CardSearchBox
|
|
placeholder="Search all cards to add one to the maybeboard…"
|
|
value={maybeQuery}
|
|
onChange={setMaybeQuery}
|
|
results={maybeResults}
|
|
onPick={addToMaybeboard}
|
|
/>
|
|
{cardsGroup(maybeboard)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{dialog?.kind === "delete" && (
|
|
<ConfirmDialog
|
|
title="Delete deck"
|
|
message={`Delete "${dialog.deck.name}"? This can't be undone.`}
|
|
confirmLabel="Delete"
|
|
danger
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "rename" && (
|
|
<PromptDialog
|
|
title="Rename deck"
|
|
label="Deck name"
|
|
defaultValue={dialog.deck.name}
|
|
confirmLabel="Rename"
|
|
error={dialogError}
|
|
onConfirm={confirmRename}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "duplicate" && (
|
|
<PromptDialog
|
|
title="Duplicate deck"
|
|
label="Name for the copy"
|
|
defaultValue={`${dialog.deck.name} (copy)`}
|
|
confirmLabel="Duplicate"
|
|
error={dialogError}
|
|
onConfirm={confirmDuplicate}
|
|
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)}
|
|
/>
|
|
)}
|
|
{suggestOpen && deck && (
|
|
<SuggestionsModal
|
|
deckId={deck.id}
|
|
onClose={() => setSuggestOpen(false)}
|
|
onCardAdded={loadContents}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|