Add card USD pricing and a deck maybeboard
Prices come from Scryfall's prices.usd on printings, shown as a hover badge on card tiles. The price is picked independently of whichever printing supplies the display image (the cheapest known price across all printings), since the display printing is often a promo/variant with no listed price. The maybeboard lets a deck track cards under consideration without counting them in deck analysis stats or library assignment totals. Cards move between mainboard and maybeboard via a per-card toggle; maybeboarding the commander clears commander status. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ export interface CardRef {
|
||||
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
||||
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
||||
produces_colorless: boolean; // can produce {C}
|
||||
price_usd: number; // USD market price, or 0 if unknown
|
||||
}
|
||||
|
||||
export interface LibraryCard extends CardRef {
|
||||
@@ -26,6 +27,9 @@ export interface LibraryCard extends CardRef {
|
||||
export interface DeckEntry extends CardRef {
|
||||
quantity: number;
|
||||
is_commander: boolean;
|
||||
/** On the maybeboard: tracked against the deck but excluded from deck
|
||||
* analysis stats and library assignment totals. */
|
||||
is_maybeboard: boolean;
|
||||
quantity_owned: number;
|
||||
}
|
||||
|
||||
@@ -142,7 +146,8 @@ export const api = {
|
||||
oracleId: string,
|
||||
quantity: number,
|
||||
isCommander = false,
|
||||
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }),
|
||||
isMaybeboard = false,
|
||||
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
|
||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,20 @@ const ICON_CLOSE = icon(
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>,
|
||||
);
|
||||
const ICON_HELP_CIRCLE = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</>,
|
||||
);
|
||||
const ICON_ARROW_LEFT_CIRCLE = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 8 8 12 12 16" />
|
||||
<line x1="16" y1="12" x2="8" y2="12" />
|
||||
</>,
|
||||
);
|
||||
|
||||
/**
|
||||
* A small round icon-only button, used consistently for lightweight actions
|
||||
@@ -92,6 +106,8 @@ export const Icons = {
|
||||
copy: ICON_COPY,
|
||||
pencil: ICON_PENCIL,
|
||||
close: ICON_CLOSE,
|
||||
helpCircle: ICON_HELP_CIRCLE,
|
||||
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
|
||||
};
|
||||
|
||||
// --- Dialogs ------------------------------------------------------------
|
||||
@@ -974,6 +990,11 @@ export function CardTile({
|
||||
)}
|
||||
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
|
||||
{corner && <div className="tile-corner">{corner}</div>}
|
||||
{card.price_usd > 0 && (
|
||||
<div className="tile-corner-bottom-right">
|
||||
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{footer && <div className="tile-footer">{footer}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1359,6 +1359,17 @@ tbody tr:hover {
|
||||
.tile-selected .tile-corner-left {
|
||||
opacity: 1;
|
||||
}
|
||||
.tile-corner-bottom-right {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tile:hover .tile-corner-bottom-right,
|
||||
.tile:focus-within .tile-corner-bottom-right {
|
||||
opacity: 1;
|
||||
}
|
||||
.tile-selected .tile-img {
|
||||
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
|
||||
/** 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 }
|
||||
@@ -55,11 +62,12 @@ interface DeckStats {
|
||||
|
||||
/**
|
||||
* Deck composition stats, excluding the commander (shown separately in its
|
||||
* own hero display) — matches the usual "99 + commander" framing even for
|
||||
* non-Commander decks, where it's just "everything but the flagged card".
|
||||
* 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);
|
||||
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"));
|
||||
@@ -208,6 +216,8 @@ export function DeckView() {
|
||||
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
|
||||
const [cmdQuery, setCmdQuery] = useState("");
|
||||
const [cmdResults, setCmdResults] = 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());
|
||||
@@ -278,6 +288,13 @@ export function DeckView() {
|
||||
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([]);
|
||||
@@ -297,7 +314,7 @@ export function DeckView() {
|
||||
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);
|
||||
await api.setDeckCard(selected, card.oracle_id, qty, false, false);
|
||||
setLibQuery("");
|
||||
setLibResults([]);
|
||||
setAnyQuery("");
|
||||
@@ -305,10 +322,40 @@ export function DeckView() {
|
||||
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 (and the deck's commander, if this was it) 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 && deck?.commander_oracle_id === entry.oracle_id) {
|
||||
await api.setDeckCommander(selected, null);
|
||||
await loadDecks();
|
||||
}
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function setQty(oracleId: string, qty: number) {
|
||||
if (selected === null) return;
|
||||
const clamped = Math.max(0, qty);
|
||||
await api.setDeckCard(selected, oracleId, clamped, false);
|
||||
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.
|
||||
@@ -408,10 +455,10 @@ export function DeckView() {
|
||||
|
||||
async function setCommander(card: CardRef) {
|
||||
if (selected === null) return;
|
||||
// Ensure the commander is in the deck, then flag it.
|
||||
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
|
||||
await api.setDeckCard(selected, card.oracle_id, 1, true);
|
||||
}
|
||||
// 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);
|
||||
await api.setDeckCommander(selected, card.oracle_id);
|
||||
setCmdQuery("");
|
||||
setCmdResults([]);
|
||||
@@ -426,8 +473,9 @@ export function DeckView() {
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
||||
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0);
|
||||
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]);
|
||||
|
||||
@@ -458,7 +506,13 @@ export function DeckView() {
|
||||
pending={pendingRemoval.has(e.oracle_id)}
|
||||
/>
|
||||
}
|
||||
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
|
||||
footer={
|
||||
<>
|
||||
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
||||
<span className="spacer" />
|
||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -477,6 +531,7 @@ export function DeckView() {
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -510,6 +565,9 @@ export function DeckView() {
|
||||
/>
|
||||
</td>
|
||||
<td>{e.quantity_owned}</td>
|
||||
<td>
|
||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -737,6 +795,23 @@ export function DeckView() {
|
||||
/>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user