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:
32
src/App.tsx
32
src/App.tsx
@@ -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">
|
||||
|
||||
56
src/api.ts
56
src/api.ts
@@ -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 }),
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
100
src/styles.css
100
src/styles.css
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user