);
}
/** Read-only informational dialog: a message plus an optional bulleted list
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
export function InfoDialog({
title,
message,
items,
onClose,
}: {
title: string;
message?: ReactNode;
items?: string[];
onClose: () => void;
}) {
return (
{message &&
{message}
}
{items && items.length > 0 && (
{items.map((it, i) => (
{it}
))}
)}
);
}
/** Floating snackbar offering to reverse the caller's last action. Auto-
* dismisses on a timer managed by the caller (this component is purely
* presentational) — pass `null`/omit rendering it once the window lapses. */
export function UndoBar({
message,
onUndo,
onDismiss,
}: {
message: string;
onUndo: () => void;
onDismiss: () => void;
}) {
return (
{message}
);
}
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
export function SampleHandModal({
hand,
libraryCount,
onDrawOne,
onNewHand,
onClose,
}: {
hand: CardRef[];
libraryCount: number;
onDrawOne: () => void;
onNewHand: () => void;
onClose: () => void;
}) {
return (
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
{hand.length > 0 ? (
{hand.map((c, i) => (
))}
) : (
Your deck has no cards to draw yet.
)}
);
}
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
*/
export function LocationField({
oracleId,
location,
onSaved,
}: {
oracleId: string;
location: string;
onSaved?: () => void;
}) {
const [val, setVal] = useState(location);
useEffect(() => setVal(location), [location]);
const save = () => {
if (val !== location) api.setLocation(oracleId, val).then(() => onSaved?.());
};
return (
setVal(e.target.value)}
onBlur={save}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
);
}
/**
* A small pill-shaped [− N +] quantity stepper, used consistently for owned-copy
* counts on the Add Cards page and both Library views.
*
* - `overlay`: dark translucent variant for use atop card art (tile corners),
* instead of the default panel-toned variant used in rows/lists.
* - `warn`: tints the pill amber (e.g. "assigned to more decks than owned").
* - `pending`: dims the pill (e.g. mid grace-period before a 0-qty row is removed).
*/
export function QtyStepper({
value,
onDecrement,
onIncrement,
overlay,
warn,
pending,
}: {
value: number;
onDecrement: () => void;
onIncrement: () => void;
overlay?: boolean;
warn?: boolean;
pending?: boolean;
}) {
return (
{value}
);
}
/** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */
export function ColorPips({ mask }: { mask: number }) {
const letters = maskToLetters(mask);
if (letters.length === 0) {
return (
C
);
}
return (
{letters.split("").map((c) => (
{c}
))}
);
}
/**
* Small card-image thumbnail. Hovering it shows a cursor-tracking preview;
* clicking it opens a larger, centered preview (with a flip button for
* double-faced cards).
*/
export function CardThumb({ card }: { card: CardRef }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
if (!card.image_uri) {
return (
🂠
);
}
return (
{
e.stopPropagation();
open(card);
}}
{...handlers(card)}
/>
);
}
/**
* A search input plus a results dropdown with keyboard navigation:
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
* `value`/`onChange` are controlled by the caller (which runs the search).
* Forwards a ref to the underlying `` so callers can focus it
* imperatively (e.g. redirecting a stray keystroke into the search box).
*/
export const CardSearchBox = forwardRef<
HTMLInputElement,
{
placeholder: string;
value: string;
onChange: (v: string) => void;
results: CardRef[];
onPick: (card: CardRef) => void;
autoFocus?: boolean;
/** Larger, more prominent input (used for the primary Add Cards field). */
big?: boolean;
}
>(function CardSearchBox(
{ placeholder, value, onChange, results, onPick, autoFocus, big },
forwardedRef,
) {
const [sel, setSel] = useState(0);
const inputRef = useRef(null);
useEffect(() => setSel(0), [results]);
// Keep focus in the box after a pick so cards can be added in a rapid streak.
function pick(card: CardRef) {
onPick(card);
inputRef.current?.focus();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && results[sel]) {
e.preventDefault();
pick(results[sel]);
}
}
return (
<>
{
inputRef.current = el;
if (typeof forwardedRef === "function") forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject).current = el;
}}
autoFocus={autoFocus}
style={{
width: "100%",
marginBottom: 8,
...(big ? { fontSize: 16, padding: "10px 12px" } : {}),
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{results.length > 0 && (
);
}
// --- Hover + click preview -------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
interface ClickApi {
open: (card: CardRef) => void;
}
const HoverCtx = createContext(null);
const ClickCtx = createContext(null);
/**
* Provides card-image previews app-wide: a small floating preview that
* tracks the cursor while hovering (see [`useHoverPreview`]), and a larger
* centered modal on click (see [`useClickPreview`]) — with a flip button for
* double-faced cards. Wrap the app once.
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const [clicked, setClicked] = useState<{ card: CardRef; showBack: boolean } | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
}, []);
const move = useCallback((e: React.MouseEvent) => {
setHover((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
}, []);
const leave = useCallback(() => setHover(null), []);
const open = useCallback((card: CardRef) => {
if (!card.image_uri) return;
setHover(null);
setClicked({ card, showBack: false });
}, []);
const close = useCallback(() => setClicked(null), []);
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
useEffect(() => {
if (!clicked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [clicked, close]);
return (
{children}
{!clicked && hover && }
{clicked && }
);
}
/** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */
export function useHoverPreview() {
const api = useContext(HoverCtx);
return {
handlers: (card: CardRef) =>
api
? {
onMouseEnter: (e: React.MouseEvent) => api.enter(card, e),
onMouseMove: (e: React.MouseEvent) => api.move(e),
onMouseLeave: () => api.leave(),
}
: {},
};
}
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
export function useClickPreview() {
const api = useContext(ClickCtx);
return { open: (card: CardRef) => api?.open(card) };
}
const PREVIEW_W = 320;
const PREVIEW_H = 446; // ~ card aspect for the normal image
const GAP = 24;
const PAD = 12;
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
// Battles/Planes/Phenomena store their image portrait-shaped with the
// content itself rotated, so the box footprint swaps (landscape) and the
// image inside gets rotated 90° back into place — see `CardRef.horizontal`.
const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W;
const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H;
// Prefer the right of the cursor; flip left if it would overflow.
let left = x + GAP;
if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP;
const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD));
return (
);
}
// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image —
// the raw file is portrait (488x680); rotated 90° it reads as 680x488.
const ROTATED_ASPECT = 680 / 488;
/** The largest landscape box (matching `.preview-modal-img`'s own
* `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated
* horizontal-card preview. Recomputed on resize so it stays live like the
* CSS-only sizing used for normal (portrait) cards. */
function useRotatedPreviewBox() {
const compute = () => {
const boundW = Math.min(720, window.innerWidth * 0.9);
const boundH = window.innerHeight * 0.9;
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
return { w, h: w / ROTATED_ASPECT };
};
const [box, setBox] = useState(compute);
useEffect(() => {
const onResize = () => setBox(compute());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return box;
}
function ClickPreviewModal({
state,
onClose,
onFlip,
}: {
state: { card: CardRef; showBack: boolean };
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const rotatedBox = useRotatedPreviewBox();
const hasBack = !!card.back_image_uri;
// Both faces are always in the DOM when the card has one; a wide-viewport
// media query shows them side by side (no flip needed), while a narrow one
// shows only the "active" face and the flip button toggles between them.
return (
e.stopPropagation()}
>
{/* Only the front face can be horizontal (Battles) — a transformed
Battle's back face is always a normal, correctly-oriented
creature card, per `CardRef.horizontal`'s doc comment. */}
{card.horizontal ? (
) : (
)}
{hasBack && (
)}
{hasBack && (
)}
);
}
// --- Grouping -------------------------------------------------------------
export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors";
export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [
{ value: "none", label: "No grouping" },
{ value: "type", label: "Card type" },
{ value: "subtype", label: "Subtype" },
{ value: "cmc", label: "Mana value" },
{ value: "colors", label: "Color identity" },
];
// Priority for choosing a card's primary type from its (multi-type) type line.
const TYPE_PRIORITY = [
"Creature",
"Planeswalker",
"Battle",
"Land",
"Artifact",
"Enchantment",
"Instant",
"Sorcery",
];
function primaryType(typeLine: string): string {
const left = typeLine.split("—")[0];
for (const t of TYPE_PRIORITY) if (left.includes(t)) return t;
const words = left.trim().split(/\s+/);
return words[words.length - 1] || "Other";
}
/** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order
* type groups and to sort cards by type. */
function typeRank(primary: string): number {
const i = TYPE_PRIORITY.indexOf(primary);
return i === -1 ? TYPE_PRIORITY.length : i;
}
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
* by (type, subtype) rather than colliding on a subtype shared across types. */
function subtypeLabel(typeLine: string): string {
const parts = typeLine.split("—");
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
return `${primaryType(typeLine)} — ${subtype}`;
}
function colorLabel(mask: number): string {
const letters = maskToLetters(mask);
return letters === "" ? "Colorless" : letters;
}
/** Group cards by the chosen attribute, returning ordered groups. */
export function groupCards(
cards: T[],
mode: GroupMode,
): { label: string; cards: T[] }[] {
if (mode === "none") return [{ label: "", cards }];
const keyOf = (c: T) =>
mode === "type"
? primaryType(c.type_line)
: mode === "subtype"
? subtypeLabel(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
const map = new Map();
for (const c of cards) {
const k = keyOf(c);
const arr = map.get(k);
if (arr) arr.push(c);
else map.set(k, [c]);
}
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
if (mode === "type") {
groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label));
} else if (mode === "cmc") {
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
} else {
groups.sort((a, b) => a.label.localeCompare(b.label));
}
return groups;
}
/** Dropdown for choosing a [`GroupMode`]. */
export function GroupBySelect({
value,
onChange,
}: {
value: GroupMode;
onChange: (m: GroupMode) => void;
}) {
return (
);
}
// --- Sorting ----------------------------------------------------------
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "mv", label: "Mana value" },
{ value: "color", label: "Color identity" },
{ value: "type", label: "Card type" },
{ value: "qty", label: "Quantity owned" },
{ value: "price", label: "Price" },
];
export type SortDir = "asc" | "desc";
/** Sort cards by the chosen attribute; ties always break by name so the
* order stays stable and predictable regardless of `mode`. Grouping (see
* [`groupCards`]) determines section order independently — sort this array
* first, then group it, and each group keeps this order internally. */
export function sortCards(
cards: T[],
mode: SortMode,
dir: SortDir,
): T[] {
const sorted = [...cards].sort((a, b) => {
const cmp =
mode === "name"
? a.name.localeCompare(b.name)
: mode === "mv"
? a.mana_value - b.mana_value || a.name.localeCompare(b.name)
: mode === "color"
? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) ||
a.name.localeCompare(b.name)
: mode === "type"
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
a.name.localeCompare(b.name)
: mode === "qty"
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
return dir === "asc" ? cmp : -cmp;
});
return sorted;
}
/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */
export function SortBySelect({
value,
dir,
onChangeValue,
onToggleDir,
}: {
value: SortMode;
dir: SortDir;
onChangeValue: (m: SortMode) => void;
onToggleDir: () => void;
}) {
return (
);
}
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
export function StatTile({ value, label }: { value: string | number; label: string }) {
return (
{value}{label}
);
}
/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */
export function ColorDistributionBar({
colorCounts,
colorlessCount,
}: {
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}) {
const segs: [string, number][] = [
["W", colorCounts[0]],
["U", colorCounts[1]],
["B", colorCounts[2]],
["R", colorCounts[3]],
["G", colorCounts[4]],
["C", colorlessCount],
];
if (segs.every(([, count]) => count === 0)) return null;
return (
);
}
/** A mana-value histogram (bar height proportional to the largest bucket),
* each bar segmented by color like {@link ColorDistributionBar}. */
export function ManaCurve({
buckets,
}: {
buckets: {
label: string;
count: number;
colorCounts: [number, number, number, number, number]; // W, U, B, R, G
colorlessCount: number;
}[];
}) {
const max = Math.max(1, ...buckets.map((b) => b.count));
return (