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:
@@ -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)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user