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

@@ -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)}
/>
)
) : (