Deck builder: select illegal cards, with legality issues grouped by category
Legality issues now carry a category (deck size, commander, color identity, too many copies, banned/restricted) and the offending card. The legality tile groups them and offers per-category and all-issue selection; over-limit copies select only the extras, so bulk remove trims to the legal count. Closes #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -188,9 +188,49 @@ export const FORMATS = [
|
|||||||
];
|
];
|
||||||
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
||||||
|
|
||||||
|
export type LegalityCategory = "size" | "commander" | "color" | "copies" | "banned";
|
||||||
|
|
||||||
|
/** Issue groups in display order. Selectable ones are tied to specific cards,
|
||||||
|
* so the deck view can select those cards for removal. */
|
||||||
|
export const LEGALITY_CATEGORIES: { key: LegalityCategory; label: string; selectable: boolean }[] = [
|
||||||
|
{ key: "size", label: "Deck size", selectable: false },
|
||||||
|
{ key: "commander", label: "Commander", selectable: false },
|
||||||
|
{ key: "color", label: "Color identity", selectable: true },
|
||||||
|
{ key: "copies", label: "Too many copies", selectable: true },
|
||||||
|
{ key: "banned", label: "Banned / restricted", selectable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface LegalityIssue {
|
||||||
|
category: LegalityCategory;
|
||||||
|
message: string;
|
||||||
|
/** The offending card, for card-level issues. */
|
||||||
|
oracleId?: string;
|
||||||
|
/** Copies over the limit; absent means the whole entry is illegal. */
|
||||||
|
excess?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeckLegality {
|
export interface DeckLegality {
|
||||||
status: "legal" | "illegal" | "unchecked";
|
status: "legal" | "illegal" | "unchecked";
|
||||||
reasons: string[];
|
issues: LegalityIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards to select to resolve card-level issues (optionally only those in
|
||||||
|
* `categories`): oracle id → copies to remove, or null for the whole entry.
|
||||||
|
* A card that is illegal outright wins over one that merely has extra copies.
|
||||||
|
*/
|
||||||
|
export function illegalSelection(
|
||||||
|
issues: LegalityIssue[],
|
||||||
|
categories?: LegalityCategory[],
|
||||||
|
): Map<string, number | null> {
|
||||||
|
const out = new Map<string, number | null>();
|
||||||
|
for (const i of issues) {
|
||||||
|
if (!i.oracleId || (categories && !categories.includes(i.category))) continue;
|
||||||
|
const prev = out.get(i.oracleId);
|
||||||
|
if (i.excess === undefined || prev === null) out.set(i.oracleId, null);
|
||||||
|
else out.set(i.oracleId, Math.max(prev ?? 0, i.excess));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COMMANDER_DECK_SIZE = 100;
|
export const COMMANDER_DECK_SIZE = 100;
|
||||||
@@ -206,10 +246,10 @@ export const CONSTRUCTED_MAX_COPIES = 4;
|
|||||||
*/
|
*/
|
||||||
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
|
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
|
||||||
if (!deck || deck.format === "" || deck.format === "Other") {
|
if (!deck || deck.format === "" || deck.format === "Other") {
|
||||||
return { status: "unchecked", reasons: [] };
|
return { status: "unchecked", issues: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasons: string[] = [];
|
const issues: LegalityIssue[] = [];
|
||||||
const cards = contents.filter((e) => !e.is_maybeboard);
|
const cards = contents.filter((e) => !e.is_maybeboard);
|
||||||
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
|
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
|
||||||
|
|
||||||
@@ -229,27 +269,34 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
|
|||||||
// below.
|
// below.
|
||||||
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||||
if (totalCards !== COMMANDER_DECK_SIZE) {
|
if (totalCards !== COMMANDER_DECK_SIZE) {
|
||||||
reasons.push(
|
issues.push({
|
||||||
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
|
category: "size",
|
||||||
);
|
message: `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.
|
// Two commanders are allowed when their partner abilities pair them up.
|
||||||
const commanders = cards.filter((e) => e.is_commander);
|
const commanders = cards.filter((e) => e.is_commander);
|
||||||
if (commanders.length === 0) {
|
if (commanders.length === 0) {
|
||||||
reasons.push(`No commander is set.`);
|
issues.push({ category: "commander", message: `No commander is set.` });
|
||||||
} else if (commanders.length > 2) {
|
} else if (commanders.length > 2) {
|
||||||
reasons.push(`More than two cards are marked as commanders.`);
|
issues.push({ category: "commander", message: `More than two cards are marked as commanders.` });
|
||||||
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
|
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
|
||||||
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
|
issues.push({
|
||||||
|
category: "commander",
|
||||||
|
message: `${commanders[0].name} and ${commanders[1].name} can't be commanders together.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of cards) {
|
for (const e of cards) {
|
||||||
if (e.is_commander) continue;
|
if (e.is_commander) continue;
|
||||||
if (!isBasicLand(e) && e.quantity > 1) {
|
if (!isBasicLand(e) && e.quantity > 1) {
|
||||||
reasons.push(
|
issues.push({
|
||||||
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
|
category: "copies",
|
||||||
);
|
message: `${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
|
||||||
|
oracleId: e.oracle_id,
|
||||||
|
excess: e.quantity - 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +307,11 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
|
|||||||
if (e.is_commander) continue;
|
if (e.is_commander) continue;
|
||||||
const outsideIdentity = e.color_identity & ~identity;
|
const outsideIdentity = e.color_identity & ~identity;
|
||||||
if (outsideIdentity !== 0) {
|
if (outsideIdentity !== 0) {
|
||||||
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
|
issues.push({
|
||||||
|
category: "color",
|
||||||
|
message: `${e.name}'s color identity is outside the ${whose} color identity.`,
|
||||||
|
oracleId: e.oracle_id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,11 +319,14 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
|
|||||||
for (const e of cards) {
|
for (const e of cards) {
|
||||||
const status = legalStatusFor(e.legalities, deck.format);
|
const status = legalStatusFor(e.legalities, deck.format);
|
||||||
if (status === "banned") {
|
if (status === "banned") {
|
||||||
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
issues.push({ category: "banned", message: `${e.name} is banned in ${deck.format}.`, oracleId: e.oracle_id });
|
||||||
} else if (status === "restricted" && e.quantity > 1) {
|
} else if (status === "restricted" && e.quantity > 1) {
|
||||||
reasons.push(
|
issues.push({
|
||||||
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
category: "banned",
|
||||||
);
|
message: `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
||||||
|
oracleId: e.oracle_id,
|
||||||
|
excess: e.quantity - 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -280,35 +334,42 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
|
|||||||
// Vintage, Pauper. No commander concept — is_commander is ignored here.
|
// Vintage, Pauper. No commander concept — is_commander is ignored here.
|
||||||
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||||
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
|
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
|
||||||
reasons.push(
|
issues.push({
|
||||||
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
|
category: "size",
|
||||||
);
|
message: `Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of cards) {
|
for (const e of cards) {
|
||||||
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
|
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
|
||||||
reasons.push(
|
issues.push({
|
||||||
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
|
category: "copies",
|
||||||
);
|
message: `${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
|
||||||
|
oracleId: e.oracle_id,
|
||||||
|
excess: e.quantity - CONSTRUCTED_MAX_COPIES,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of cards) {
|
for (const e of cards) {
|
||||||
const status = legalStatusFor(e.legalities, deck.format);
|
const status = legalStatusFor(e.legalities, deck.format);
|
||||||
if (status === "banned") {
|
if (status === "banned") {
|
||||||
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
issues.push({ category: "banned", message: `${e.name} is banned in ${deck.format}.`, oracleId: e.oracle_id });
|
||||||
} else if (status === "restricted" && e.quantity > 1) {
|
} else if (status === "restricted" && e.quantity > 1) {
|
||||||
// In practice this only ever fires for Vintage among the 8 tracked
|
// In practice this only ever fires for Vintage among the 8 tracked
|
||||||
// formats, since Vintage is the only format with a restricted list —
|
// formats, since Vintage is the only format with a restricted list —
|
||||||
// but the check is generic/future-proof rather than Vintage-special-cased.
|
// but the check is generic/future-proof rather than Vintage-special-cased.
|
||||||
reasons.push(
|
issues.push({
|
||||||
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
category: "banned",
|
||||||
);
|
message: `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
||||||
|
oracleId: e.oracle_id,
|
||||||
|
excess: e.quantity - 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: reasons.length ? "illegal" : "legal", reasons };
|
return { status: issues.length ? "illegal" : "legal", issues };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1066,6 +1066,29 @@ h4 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.legality-tile-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.legality-tile-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 54px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
button.legality-tile-select {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
.legality-tile-reasons {
|
.legality-tile-reasons {
|
||||||
margin: 0 0 0 54px;
|
margin: 0 0 0 54px;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?
|
|||||||
// An empty deck is "illegal" only in the sense that it isn't built yet.
|
// An empty deck is "illegal" only in the sense that it isn't built yet.
|
||||||
if (s.legality.status === "unchecked" || s.size === 0) return null;
|
if (s.legality.status === "unchecked" || s.size === 0) return null;
|
||||||
const legal = s.legality.status === "legal";
|
const legal = s.legality.status === "legal";
|
||||||
const n = s.legality.reasons.length;
|
const n = s.legality.issues.length;
|
||||||
const title = legal ? "Meets the format's deck-building rules" : s.legality.reasons.join("\n");
|
const title = legal ? "Meets the format's deck-building rules" : s.legality.issues.map((i) => i.message).join("\n");
|
||||||
return (
|
return (
|
||||||
<span className={`deck-legality ${legal ? "legal" : "illegal"}`} title={title}>
|
<span className={`deck-legality ${legal ? "legal" : "illegal"}`} title={title}>
|
||||||
{legal ? "✓" : "⚠"}
|
{legal ? "✓" : "⚠"}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import {
|
|||||||
computeDeckStats,
|
computeDeckStats,
|
||||||
formatCost,
|
formatCost,
|
||||||
FORMATS,
|
FORMATS,
|
||||||
|
illegalSelection,
|
||||||
|
LEGALITY_CATEGORIES,
|
||||||
|
LegalityCategory,
|
||||||
pairsWith,
|
pairsWith,
|
||||||
partnerPlaceholder,
|
partnerPlaceholder,
|
||||||
shortBy,
|
shortBy,
|
||||||
@@ -159,6 +162,8 @@ function CommanderHero({
|
|||||||
// the Library tab, so a mistaken removal can be undone with +.
|
// the Library tab, so a mistaken removal can be undone with +.
|
||||||
const REMOVAL_GRACE_MS = 5000;
|
const REMOVAL_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
|
||||||
|
|
||||||
const STARTING_HAND_SIZE = 7;
|
const STARTING_HAND_SIZE = 7;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,6 +221,9 @@ function DeckBuilder({
|
|||||||
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
||||||
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
|
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
|
||||||
|
// Selected cards where only some copies are marked for removal (the extras
|
||||||
|
// over a format's copy limit), keyed by oracle id → copies to remove.
|
||||||
|
const [partialRemoval, setPartialRemoval] = useState<Map<string, number>>(new Map());
|
||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -260,6 +268,11 @@ function DeckBuilder({
|
|||||||
const next = new Set([...s].filter((id) => visible.has(id)));
|
const next = new Set([...s].filter((id) => visible.has(id)));
|
||||||
return next.size === s.size ? s : next;
|
return next.size === s.size ? s : next;
|
||||||
});
|
});
|
||||||
|
setPartialRemoval((m) => {
|
||||||
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
||||||
|
const next = new Map([...m].filter(([id]) => visible.has(id)));
|
||||||
|
return next.size === m.size ? m : next;
|
||||||
|
});
|
||||||
}, [contents]);
|
}, [contents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -415,6 +428,8 @@ function DeckBuilder({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleCard(oracleId: string) {
|
function toggleCard(oracleId: string) {
|
||||||
|
// A manual (de)select always means the whole entry.
|
||||||
|
dropPartial(oracleId);
|
||||||
setSelectedCards((s) => {
|
setSelectedCards((s) => {
|
||||||
const next = new Set(s);
|
const next = new Set(s);
|
||||||
if (next.has(oracleId)) next.delete(oracleId);
|
if (next.has(oracleId)) next.delete(oracleId);
|
||||||
@@ -423,7 +438,33 @@ function DeckBuilder({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropPartial(oracleId: string) {
|
||||||
|
setPartialRemoval((m) => {
|
||||||
|
if (!m.has(oracleId)) return m;
|
||||||
|
const next = new Map(m);
|
||||||
|
next.delete(oracleId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
setSelectedCards(new Set());
|
||||||
|
setPartialRemoval(new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the selection with the cards behind the deck's card-level
|
||||||
|
* legality issues (optionally only some categories). Over-limit copies
|
||||||
|
* select just the extras, so removing them leaves the legal count. */
|
||||||
|
function selectIllegal(categories?: LegalityCategory[]) {
|
||||||
|
const sel = illegalSelection(deckLegality.issues, categories);
|
||||||
|
setSelectedCards(new Set(sel.keys()));
|
||||||
|
setPartialRemoval(
|
||||||
|
new Map([...sel].filter((kv): kv is [string, number] => kv[1] !== null)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAllCards() {
|
function toggleSelectAllCards() {
|
||||||
|
setPartialRemoval(new Map());
|
||||||
setSelectedCards((s) =>
|
setSelectedCards((s) =>
|
||||||
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
||||||
);
|
);
|
||||||
@@ -432,22 +473,34 @@ function DeckBuilder({
|
|||||||
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
||||||
* this is a deliberate action, so there's no grace period to linger in. */
|
* this is a deliberate action, so there's no grace period to linger in. */
|
||||||
async function bulkRemoveFromDeck() {
|
async function bulkRemoveFromDeck() {
|
||||||
const ids = new Set(selectedCards);
|
// Entries with only some copies selected are trimmed rather than removed.
|
||||||
|
const remaining = new Map<string, number>();
|
||||||
|
for (const id of selectedCards) {
|
||||||
|
const e = contents.find((c) => c.oracle_id === id);
|
||||||
|
const n = partialRemoval.get(id);
|
||||||
|
remaining.set(id, e && n !== undefined ? Math.max(e.quantity - n, 0) : 0);
|
||||||
|
}
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...ids].map((id) => {
|
[...remaining].map(([id, qty]) => {
|
||||||
const e = contents.find((c) => c.oracle_id === id);
|
const e = contents.find((c) => c.oracle_id === id);
|
||||||
return api.setDeckCard(deckId, id, 0, e?.is_commander ?? false, e?.is_maybeboard ?? false);
|
return api.setDeckCard(deckId, id, qty, e?.is_commander ?? false, e?.is_maybeboard ?? false);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const id of ids) {
|
const removed = new Set([...remaining].filter(([, qty]) => qty === 0).map(([id]) => id));
|
||||||
|
for (const id of removed) {
|
||||||
const timer = removalTimers.current.get(id);
|
const timer = removalTimers.current.get(id);
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
removalTimers.current.delete(id);
|
removalTimers.current.delete(id);
|
||||||
}
|
}
|
||||||
setPendingRemoval((p) => new Set([...p].filter((id) => !ids.has(id))));
|
setPendingRemoval((p) => new Set([...p].filter((id) => !removed.has(id))));
|
||||||
setContents((cs) => cs.filter((e) => !ids.has(e.oracle_id)));
|
setContents((cs) =>
|
||||||
|
cs
|
||||||
|
.filter((e) => !removed.has(e.oracle_id))
|
||||||
|
.map((e) => (remaining.has(e.oracle_id) ? { ...e, quantity: remaining.get(e.oracle_id)! } : e)),
|
||||||
|
);
|
||||||
|
clearSelection();
|
||||||
} finally {
|
} finally {
|
||||||
setBulkBusy(false);
|
setBulkBusy(false);
|
||||||
}
|
}
|
||||||
@@ -464,7 +517,7 @@ function DeckBuilder({
|
|||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
try {
|
try {
|
||||||
await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id)));
|
await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id)));
|
||||||
setSelectedCards(new Set());
|
clearSelection();
|
||||||
} finally {
|
} finally {
|
||||||
setBulkBusy(false);
|
setBulkBusy(false);
|
||||||
}
|
}
|
||||||
@@ -590,7 +643,16 @@ function DeckBuilder({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
|
footer={
|
||||||
|
e.is_commander || partialRemoval.has(e.oracle_id) ? (
|
||||||
|
<span className="row" style={{ gap: 4 }}>
|
||||||
|
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
||||||
|
{partialRemoval.has(e.oracle_id) && (
|
||||||
|
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -631,6 +693,11 @@ function DeckBuilder({
|
|||||||
<td className="card-name">
|
<td className="card-name">
|
||||||
{e.is_commander && <span title="Commander">★ </span>}
|
{e.is_commander && <span title="Commander">★ </span>}
|
||||||
{e.name}
|
{e.name}
|
||||||
|
{partialRemoval.has(e.oracle_id) && (
|
||||||
|
<span className="badge" style={{ marginLeft: 6 }}>
|
||||||
|
{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="card-type">{e.type_line}</td>
|
<td className="card-type">{e.type_line}</td>
|
||||||
<td data-label="In deck">
|
<td data-label="In deck">
|
||||||
@@ -815,20 +882,44 @@ function DeckBuilder({
|
|||||||
{deckLegality.status === "legal"
|
{deckLegality.status === "legal"
|
||||||
? `Meets all ${deck.format} deck-building rules.`
|
? `Meets all ${deck.format} deck-building rules.`
|
||||||
: deckLegality.status === "illegal"
|
: deckLegality.status === "illegal"
|
||||||
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
? `${deckLegality.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:`
|
||||||
: deck.format
|
: deck.format
|
||||||
? `Legality isn't checked for "${deck.format}".`
|
? `Legality isn't checked for "${deck.format}".`
|
||||||
: "Set a format to check legality."}
|
: "Set a format to check legality."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{illegalSelection(deckLegality.issues).size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="legality-tile-select" onClick={() => selectIllegal()}>
|
||||||
|
Select illegal cards
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{deckLegality.status === "illegal" && (
|
{deckLegality.status === "illegal" &&
|
||||||
<ul className="legality-tile-reasons">
|
LEGALITY_CATEGORIES.map((cat) => {
|
||||||
{deckLegality.reasons.map((r, i) => (
|
const issues = deckLegality.issues.filter((i) => i.category === cat.key);
|
||||||
<li key={i}>{r}</li>
|
if (issues.length === 0) return null;
|
||||||
))}
|
const cardCount = illegalSelection(issues).size;
|
||||||
</ul>
|
return (
|
||||||
)}
|
<div key={cat.key} className="legality-tile-group">
|
||||||
|
<div className="legality-tile-group-head">
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
{cat.selectable && cardCount > 0 && (
|
||||||
|
<button className="legality-tile-select" onClick={() => selectIllegal([cat.key])}>
|
||||||
|
Select ({cardCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="legality-tile-reasons">
|
||||||
|
{issues.map((i, n) => (
|
||||||
|
<li key={n}>{i.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -873,7 +964,10 @@ function DeckBuilder({
|
|||||||
|
|
||||||
{selectedCards.size > 0 && (
|
{selectedCards.size > 0 && (
|
||||||
<div className="bulk-bar">
|
<div className="bulk-bar">
|
||||||
<span className="bulk-bar-count">{selectedCards.size} selected</span>
|
<span className="bulk-bar-count">
|
||||||
|
{selectedCards.size} selected
|
||||||
|
{partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`}
|
||||||
|
</span>
|
||||||
<div className="bulk-bar-actions">
|
<div className="bulk-bar-actions">
|
||||||
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
||||||
Remove from deck
|
Remove from deck
|
||||||
@@ -887,7 +981,7 @@ function DeckBuilder({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
|
<button onClick={clearSelection} disabled={bulkBusy}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user