Printing tracking for owned copies and deck art, and an outdated-card-data notice

Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.

A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.

UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.

Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.

Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.

reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
This commit is contained in:
2026-10-16 21:06:17 -05:00
parent 1c5e045591
commit 214a535588
23 changed files with 2092 additions and 138 deletions

View File

@@ -342,6 +342,14 @@ export default function App() {
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Card data built by an older version lacks columns this one reads, so
// some features quietly stay off until it's updated. "Not now" is
// remembered against exactly what's missing: the notice comes back once a
// later version adds something more.
const missingColumns = status?.reference_missing_columns ?? [];
const outdatedKey = missingColumns.join(",");
const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", "");
const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey;
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
// across renders (re-creating them would re-arm the listeners below and fire
// a fresh sync every time card data finished loading).
@@ -632,6 +640,30 @@ export default function App() {
</div>
)}
{showOutdated && (
<div className="outdated-bar" role="status">
<span className="outdated-bar-message">
Your card data was built by an older version of mtgc, so some newer features stay off until you
update it.
</span>
<span className="outdated-bar-actions">
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
Update card data
</button>
{/* A full download is heavy on a phone; copying a fresh
reference.db over from a desktop is the cheaper route. */}
{status?.is_mobile && (
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
)}
<button onClick={() => setDismissedOutdated(outdatedKey)}>Not now</button>
</span>
</div>
)}
<div className="content">
{!statusLoaded ? (
<div className="empty-state">

View File

@@ -36,6 +36,32 @@ export interface DeckEntry extends CardRef {
* analysis stats and library assignment totals. */
is_maybeboard: boolean;
quantity_owned: number;
/** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null;
}
/** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef {
scryfall_id: string;
set_code: string; // lowercase, e.g. "2x2"
collector_number: string;
lang: string; // "" on an older card database
finishes: string[]; // "nonfoil" | "foil" | "etched"
rarity: string;
image_uri: string;
back_image_uri: string;
price_usd: number;
released_at: string; // YYYY-MM-DD, "" on an older card database
}
/** Owned copies pinned to one printing + finish. A subset of the card's
* `quantity_owned`; copies in no group are unspecified. */
export interface LibraryCopy {
scryfall_id: string;
finish: string;
quantity: number;
/** Null when the printing has left the card database. */
printing: PrintingRef | null;
}
/**
@@ -48,8 +74,21 @@ export interface DeckEntry extends CardRef {
export type PreviewCard = CardRef & {
quantity_owned?: number;
decks?: string[];
preferred_scryfall_id?: string | null;
};
/**
* Where a click preview was opened from, when the caller lets it edit
* printings: the info tile then tracks owned copies by printing, and — for a
* deck slot — chooses which printing's art the slot shows.
*/
export interface PreviewContext {
/** Called after any printing change, so the caller can refetch. */
onChanged: () => void;
/** The deck the previewed card is a slot of. */
deckId?: number;
}
export interface Deck {
id: number;
name: string;
@@ -149,6 +188,10 @@ export interface Status {
reference_ready: boolean;
card_count: number;
reference_updated_at: number | null; // unix seconds
/** Columns the installed card data lacks ("table.column") because an older
* version built it. Non-empty means some features are degraded until the
* user updates card data. */
reference_missing_columns: string[];
/** True on mobile builds. Used to hide affordances that depend on choosing an
* arbitrary filesystem path for library.db — Android scoped storage has no
* equivalent, and SQLite can't be backed by a content:// URI. */
@@ -407,6 +450,19 @@ export const api = {
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
/** Every paper printing of a card, newest first. */
listPrintings: (oracleId: string) => invoke<PrintingRef[]>("list_printings", { oracleId }),
listLibraryCopies: (oracleId: string) =>
invoke<LibraryCopy[]>("list_library_copies", { oracleId }),
/** Set how many owned copies are this printing + finish (0 removes the
* group). Resolves with the card's owned quantity, which grows to fit. */
setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) =>
invoke<number>("set_library_copy", { oracleId, scryfallId, finish, quantity }),
/** Choose which printing a deck slot shows; null restores the default.
* Resolves with the slot afterwards (its art reflecting the choice). */
setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) =>
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }),

View File

@@ -9,15 +9,19 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
api,
CardRef,
COLORS,
LEGALITY_FORMATS,
LibraryCopy,
LibraryFilter,
legalStatusFor,
maskToLetters,
PreviewCard,
PreviewContext,
PrintingRef,
} from "./api";
import { useBackButton } from "./backButton";
@@ -476,7 +480,7 @@ export function ColorPips({ mask }: { mask: number }) {
* clicking it opens a larger, centered preview (with a flip button for
* double-faced cards).
*/
export function CardThumb({ card }: { card: CardRef }) {
export function CardThumb({ card, preview }: { card: PreviewCard; preview?: PreviewContext }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
if (!card.image_uri) {
@@ -494,7 +498,7 @@ export function CardThumb({ card }: { card: CardRef }) {
loading="lazy"
onClick={(e) => {
e.stopPropagation();
open(card);
open(card, preview);
}}
{...handlers(card)}
/>
@@ -698,8 +702,9 @@ interface HoverApi {
leave: () => void;
}
interface ClickApi {
open: (card: PreviewCard) => void;
open: (card: PreviewCard, ctx?: PreviewContext) => void;
}
type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean };
const HoverCtx = createContext<HoverApi | null>(null);
const ClickCtx = createContext<ClickApi | null>(null);
@@ -711,7 +716,7 @@ const ClickCtx = createContext<ClickApi | null>(null);
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null);
const [clicked, setClicked] = useState<ClickedState | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
@@ -721,10 +726,10 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) {
}, []);
const leave = useCallback(() => setHover(null), []);
const open = useCallback((card: PreviewCard) => {
const open = useCallback((card: PreviewCard, ctx?: PreviewContext) => {
if (!card.image_uri) return;
setHover(null);
setClicked({ card, showBack: false });
setClicked({ card, ctx, showBack: false });
}, []);
const close = useCallback(() => setClicked(null), []);
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
@@ -763,10 +768,11 @@ export function useHoverPreview() {
};
}
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
/** Returns `open(card, ctx?)` to trigger the larger click-to-preview modal.
* Passing a `ctx` adds the Printings section to its info tile. */
export function useClickPreview() {
const api = useContext(ClickCtx);
return { open: (card: PreviewCard) => api?.open(card) };
return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) };
}
const PREVIEW_W = 320;
@@ -867,7 +873,11 @@ function LegalityPills({ legalities }: { legalities: number }) {
* type/mana/color, price, ownership context (when the caller had a
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
* legality. */
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
const CardInfoTile = forwardRef<
HTMLDivElement,
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void }
>(function CardInfoTile({ card, ctx, onArtChange }, ref) {
const printings = !!ctx && card.quantity_owned !== undefined;
return (
<div className="preview-info" ref={ref}>
<div className="preview-info-name">{card.name}</div>
@@ -879,13 +889,17 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
{card.price_usd > 0 && (
<div className="preview-info-price">${card.price_usd.toFixed(2)}</div>
)}
{card.quantity_owned !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
</span>
</div>
{printings ? (
<PrintingsSection card={card} ctx={ctx} onArtChange={onArtChange} />
) : (
card.quantity_owned !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
</span>
</div>
)
)}
{card.decks && card.decks.length > 0 && (
<div className="preview-info-section">
@@ -907,6 +921,254 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
);
});
type CardArt = Pick<CardRef, "image_uri" | "back_image_uri">;
const FINISH_LABELS: Record<string, string> = { nonfoil: "Nonfoil", foil: "Foil", etched: "Etched" };
/** "2X2 #315", the way decklists and binders name a printing. */
function printingLabel(p: PrintingRef): string {
return `${p.set_code.toUpperCase()} #${p.collector_number}`;
}
/**
* The click preview's printing controls (shown when it was opened with a
* `PreviewContext`): owned copies broken down by printing + finish, and — for
* a deck slot — which printing's art the slot shows. Takes the place of the
* plain "Owned" row, since growing a copy group can change that total.
*/
function PrintingsSection({
card,
ctx,
onArtChange,
}: {
card: PreviewCard;
ctx: PreviewContext;
onArtChange: (art: CardArt) => void;
}) {
const [owned, setOwned] = useState(card.quantity_owned ?? 0);
const [copies, setCopies] = useState<LibraryCopy[]>([]);
const [printings, setPrintings] = useState<PrintingRef[] | null>(null);
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
api
.listLibraryCopies(card.oracle_id)
.then((c) => live && setCopies(c))
.catch(() => {});
api
.listPrintings(card.oracle_id)
.then((p) => live && setPrintings(p))
.catch(() => live && setPrintings([]));
return () => {
live = false;
};
}, [card.oracle_id]);
const tracked = copies.reduce((n, c) => n + c.quantity, 0);
const shown = preferred ? printings?.find((p) => p.scryfall_id === preferred) : undefined;
async function run(action: () => Promise<void>) {
setError(null);
try {
await action();
ctx.onChanged();
} catch (e) {
setError(String(e));
}
}
const setCopy = (scryfallId: string, finish: string, quantity: number) =>
run(async () => {
setOwned(await api.setLibraryCopy(card.oracle_id, scryfallId, finish, quantity));
setCopies(await api.listLibraryCopies(card.oracle_id));
});
const showPrinting = (scryfallId: string | null) =>
run(async () => {
const entry = await api.setDeckCardPrinting(ctx.deckId!, card.oracle_id, scryfallId);
setPreferred(scryfallId);
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
});
function onPick(p: PrintingRef, finish: string) {
const mode = picking;
setPicking(null);
if (mode === "deck") {
showPrinting(p.scryfall_id);
} else {
const existing = copies.find((c) => c.scryfall_id === p.scryfall_id && c.finish === finish);
setCopy(p.scryfall_id, finish, (existing?.quantity ?? 0) + 1);
}
}
return (
<>
{ctx.deckId !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Shown in this deck</span>
<div className="printing-row">
<span className="printing-row-label">
{!preferred ? "Default printing" : shown ? printingLabel(shown) : "Unavailable printing"}
</span>
<span className="spacer" />
<span className="printing-actions">
<button onClick={() => setPicking("deck")} disabled={!printings?.length}>
Change…
</button>
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
</span>
</div>
</div>
)}
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{owned} cop{owned === 1 ? "y" : "ies"}
{tracked > 0 && (
<span className="muted">
{" "}
· {tracked} by printing · {Math.max(0, owned - tracked)} unspecified
</span>
)}
</span>
{copies.map((c) => (
<div key={`${c.scryfall_id}/${c.finish}`} className="printing-row">
{c.printing?.image_uri ? (
<img className="printing-row-thumb" src={c.printing.image_uri} alt="" loading="lazy" />
) : (
<span className="printing-row-thumb" />
)}
<span className="printing-row-label">
{c.printing ? printingLabel(c.printing) : "Unavailable printing"}
<span className="muted"> · {FINISH_LABELS[c.finish] ?? c.finish}</span>
</span>
<span className="spacer" />
<QtyStepper
value={c.quantity}
onDecrement={() => setCopy(c.scryfall_id, c.finish, c.quantity - 1)}
onIncrement={() => setCopy(c.scryfall_id, c.finish, c.quantity + 1)}
/>
</div>
))}
<span className="printing-actions">
<button onClick={() => setPicking("copy")} disabled={!printings?.length}>
Add a printing…
</button>
</span>
{error && <div className="dialog-error">{error}</div>}
</div>
{picking && printings && (
<PrintingPicker
name={card.name}
printings={printings}
chooseFinish={picking === "copy"}
current={picking === "deck" ? preferred : null}
onPick={onPick}
onCancel={() => setPicking(null)}
/>
)}
</>
);
}
/**
* A grid of a card's printings to pick from. With `chooseFinish`, each
* printing also offers its finishes (clicking the art takes the first).
*
* Opens above the click preview, which closes on any Escape it hears on
* `window` — so this listens in the capture phase and stops the event there,
* and only the picker closes.
*/
function PrintingPicker({
name,
printings,
chooseFinish,
current,
onPick,
onCancel,
}: {
name: string;
printings: PrintingRef[];
chooseFinish: boolean;
current: string | null;
onPick: (p: PrintingRef, finish: string) => void;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
onCancel();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [onCancel]);
useBackButton(onCancel);
return createPortal(
<div
className="dialog-backdrop printing-picker-backdrop"
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
>
<div className="dialog dialog-wide" onClick={(e) => e.stopPropagation()}>
<div className="dialog-title-row">
<h3 className="dialog-title">{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}</h3>
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
</div>
<div className="printing-grid">
{printings.map((p) => {
const finishes = p.finishes.length > 0 ? p.finishes : ["nonfoil"];
const meta = [
p.released_at.slice(0, 4),
p.rarity && p.rarity[0].toUpperCase() + p.rarity.slice(1),
p.lang && p.lang !== "en" ? p.lang.toUpperCase() : "",
p.price_usd > 0 ? `$${p.price_usd.toFixed(2)}` : "",
]
.filter(Boolean)
.join(" · ");
return (
<div
key={p.scryfall_id}
className={`printing-option ${p.scryfall_id === current ? "selected" : ""}`}
>
<button
className="printing-option-art"
onClick={() => onPick(p, finishes[0])}
title={chooseFinish ? `Add a ${FINISH_LABELS[finishes[0]] ?? finishes[0]} copy` : "Show this printing"}
>
{p.image_uri ? (
<img src={p.image_uri} alt={printingLabel(p)} loading="lazy" />
) : (
<div className="tile-noimg">{printingLabel(p)}</div>
)}
</button>
<div className="printing-option-label">{printingLabel(p)}</div>
{meta && <div className="printing-option-meta">{meta}</div>}
{chooseFinish && finishes.length > 1 && (
<div className="printing-actions">
{finishes.map((f) => (
<button key={f} onClick={() => onPick(p, f)}>
{FINISH_LABELS[f] ?? f}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>,
document.body,
);
}
type PreviewPane = "card" | "info";
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
@@ -946,11 +1208,15 @@ function ClickPreviewModal({
onClose,
onFlip,
}: {
state: { card: PreviewCard; showBack: boolean };
state: ClickedState;
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const { ctx, showBack } = state;
// A printing just chosen for this deck slot: show its art right away
// rather than the art the preview was opened with.
const [art, setArt] = useState<CardArt | null>(null);
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
const hasBack = !!card.back_image_uri;
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -1072,7 +1338,7 @@ function ClickPreviewModal({
</button>
)}
</div>
<CardInfoTile ref={infoRef} card={card} />
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
@@ -1455,8 +1721,9 @@ export function CardTile({
footer,
selected,
rotated,
preview,
}: {
card: CardRef;
card: PreviewCard;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
@@ -1469,6 +1736,8 @@ export function CardTile({
* is also true, so mixed results (e.g. a set with both Sieges and normal
* cards) don't spin the ones that are already right-side up. */
rotated?: boolean;
/** Lets the click preview edit this card's printings. */
preview?: PreviewContext;
}) {
const { open } = useClickPreview();
const showRotated = !!rotated && card.horizontal;
@@ -1482,7 +1751,7 @@ export function CardTile({
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
onClick={() => open(card, preview)}
/>
) : (
<img
@@ -1490,7 +1759,7 @@ export function CardTile({
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
onClick={() => open(card, preview)}
/>
)
) : (

View File

@@ -672,6 +672,27 @@ input[type="checkbox"] {
border-bottom: 1px solid var(--border-soft);
font-size: 13px;
}
/* Card data from an older version: same strip as the ingest bar, tinted as
a warning. Wraps its buttons under the message on narrow screens. */
.outdated-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding: 8px 18px;
background: rgba(224, 163, 74, 0.12);
border-bottom: 1px solid rgba(224, 163, 74, 0.35);
font-size: 13px;
}
.outdated-bar-message {
flex: 1 1 260px;
color: var(--text);
}
.outdated-bar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ingest-bar-head {
display: flex;
align-items: center;
@@ -3299,3 +3320,82 @@ tbody tr:hover {
font-size: 22px;
}
}
/* ============================================================
Printings: the click preview's per-printing controls, and the picker
============================================================ */
.printing-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.printing-row-thumb {
width: 28px;
height: 39px;
flex-shrink: 0;
object-fit: cover;
border-radius: 3px;
background: var(--bg-elev);
}
.printing-row-label {
min-width: 0;
font-size: 13px;
overflow-wrap: anywhere;
}
.printing-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.printing-actions button {
padding: 2px 10px;
font-size: 12px;
}
/* Above the click preview (450) it opens from. */
.printing-picker-backdrop {
z-index: 500;
}
.printing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(130px, 40vw), 1fr));
gap: var(--space-3);
}
.printing-option {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.printing-option-art {
display: block;
width: 100%;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-card);
background: none;
cursor: pointer;
}
.printing-option-art:hover {
border-color: var(--accent-hover);
background: none;
}
.printing-option.selected .printing-option-art {
border-color: var(--accent);
}
.printing-option-art img {
display: block;
width: 100%;
aspect-ratio: 488 / 680;
object-fit: cover;
border-radius: calc(var(--radius-card) - 2px);
}
.printing-option-label {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
.printing-option-meta {
font-size: 12px;
color: var(--muted);
}

View File

@@ -1,6 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -169,7 +169,15 @@ function computeDeckStats(contents: DeckEntry[]): DeckStats {
}
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
function CommanderHero({
card,
onClear,
preview,
}: {
card: DeckEntry;
onClear: () => void;
preview?: PreviewContext;
}) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
@@ -179,7 +187,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card)}
onClick={() => open(card, preview)}
{...handlers(card)}
/>
) : (
@@ -740,6 +748,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
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 className="tile-grid">
@@ -747,6 +759,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<CardTile
key={e.oracle_id}
card={e}
preview={preview}
selected={selectedCards.has(e.oracle_id)}
cornerLeft={
<span className="tile-select">
@@ -803,7 +816,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
/>
</td>
<td className="cell-thumb">
<CardThumb card={e} />
<CardThumb card={e} preview={preview} />
</td>
<td>
<ColorPips mask={e.color_identity} />
@@ -961,7 +974,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<div className="deck-top-grid">
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<div className="deck-top-commander">
<CommanderHero card={commanderEntry} onClear={clearCommander} />
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
</div>
)}

View File

@@ -1,6 +1,14 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
import {
api,
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
import {
CardThumb,
CardTile,
@@ -330,6 +338,9 @@ export function LibraryBrowser({
</span>
);
// Lets the click preview track which printings the copies are.
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
function libTable(cards: LibraryCard[]) {
return (
<table>
@@ -356,7 +367,7 @@ export function LibraryBrowser({
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} />
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
@@ -399,6 +410,7 @@ export function LibraryBrowser({
<CardTile
key={c.oracle_id}
card={c}
preview={preview}
selected={selected.has(c.oracle_id)}
cornerLeft={
<span className="tile-select">