);
}
/** 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: PreviewCard) => 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: PreviewCard; 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: PreviewCard) => {
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]);
useBackButton(close, !!clicked);
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: PreviewCard) => 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.
*
* `dfc` (when wide enough for side-by-side faces) shrinks the budget to
* roughly match a DFC back face's own `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img`
* rule) and leaves headroom below for the info tile — pass it whenever the
* horizontal front face has a sibling back face, so a transformed Battle
* doesn't render at full single-card size next to a much smaller back face
* and push the info tile off-screen.
*
* `fitH` (compact mode, see `ClickPreviewModal`) overrides both with the
* exact art height budget left over after the pane toolbar. */
function useRotatedPreviewBox(dfc: boolean, fitH: number | null) {
const compute = () => {
// Faces only sit side by side at the `.preview-modal-dfc` ≥860px
// breakpoint; below it they flip one at a time, so use the full budget.
const sideBySide = dfc && fitH === null && window.innerWidth >= 860;
const boundW = sideBySide ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9);
const boundH = fitH ?? (sideBySide ? window.innerHeight * 0.55 : window.innerHeight * 0.9);
const w = Math.min(boundW, boundH * ROTATED_ASPECT);
return { w, h: w / ROTATED_ASPECT };
};
const [box, setBox] = useState(compute);
useEffect(() => {
setBox(compute());
const onResize = () => setBox(compute());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [dfc, fitH]);
return box;
}
/** Legality status pills for every tracked format, e.g. inside the
* click-preview modal's info tile. Not-legal formats are shown too (dimmed)
* so the list reads as a complete checklist rather than a partial one. */
function LegalityPills({ legalities }: { legalities: number }) {
return (
);
}
/** Details panel shown alongside the card art in the click-to-preview modal:
* type/mana/color, price, ownership context (when the caller had a
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
* legality. */
const CardInfoTile = forwardRef(function CardInfoTile({ card }, ref) {
return (
);
});
type PreviewPane = "card" | "info";
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
// `.preview-modal-bar` / `.preview-modal` in styles.css.
const PREVIEW_BAR_H = 36;
const PREVIEW_GAP = 16;
/** Segmented control for the compact (one-pane-at-a-time) click preview:
* front face / back face (double-faced cards only) / info tile. */
function PreviewPaneToggle({
hasBack,
showBack,
pane,
onSelect,
}: {
hasBack: boolean;
showBack: boolean;
pane: PreviewPane;
onSelect: (pane: PreviewPane, back: boolean) => void;
}) {
const btn = (label: string, active: boolean, onClick: () => void) => (
);
return (
{btn(hasBack ? "Front" : "Card", pane === "card" && !showBack, () => onSelect("card", false))}
{hasBack && btn("Back", pane === "card" && showBack, () => onSelect("card", true))}
{btn("Info", pane === "info", () => onSelect("info", showBack))}
);
}
function ClickPreviewModal({
state,
onClose,
onFlip,
}: {
state: { card: PreviewCard; showBack: boolean };
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const hasBack = !!card.back_image_uri;
const backdropRef = useRef(null);
const modalRef = useRef(null);
const infoRef = useRef(null);
// Compact mode: when the full layout (art + info tile) would be taller than
// the viewport — i.e. on phones, where the tile wraps below the art — show
// one pane at a time above a Front/Back/Info toggle instead of making the
// user scroll. Decided by measuring the full layout itself: every resize
// drops back to it, and the layout effect below re-measures and switches
// before paint, so there's no flash and no feedback loop (compact mode is
// never what gets measured). The art's size doesn't depend on the image
// having loaded (see `.preview-modal-img`), so measuring on mount is exact.
// `fit` holds the measurements compact mode sizes itself from; null = full.
const [fit, setFit] = useState<{ avail: number; infoH: number } | null>(null);
const [pane, setPane] = useState("card");
const [measureKey, setMeasureKey] = useState(0);
const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP;
const rotatedBox = useRotatedPreviewBox(hasBack, artBudget);
useEffect(() => {
const onResize = () => {
setFit(null);
setMeasureKey((k) => k + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
useLayoutEffect(() => {
const backdrop = backdropRef.current;
const modal = modalRef.current;
if (fit || !backdrop || !modal) return;
const cs = getComputedStyle(backdrop);
const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (modal.getBoundingClientRect().height > avail) {
setFit({ avail, infoH: infoRef.current?.getBoundingClientRect().height ?? 0 });
}
}, [fit, measureKey, card, showBack]);
// In compact mode the modal gets a fixed box — as wide and tall as the
// largest pane (capped to the screen), toolbar pinned to its bottom — so
// switching panes never moves the toolbar. Mirrors the CSS sizing of the
// art (`.preview-modal-img`) and info tile (`.preview-info`).
let compactStyle: React.CSSProperties | undefined;
if (fit && artBudget !== null) {
const vw = window.innerWidth;
const portraitW = Math.min(488, vw * 0.9, (artBudget * 488) / 680);
const faces = [
card.horizontal ? rotatedBox : { w: portraitW, h: (portraitW * 680) / 488 },
...(hasBack ? [{ w: portraitW, h: (portraitW * 680) / 488 }] : []),
];
const paneW = Math.max(Math.min(320, vw * 0.9), ...faces.map((f) => f.w));
const paneH = Math.min(artBudget, Math.max(fit.infoH, ...faces.map((f) => f.h)));
compactStyle = {
width: paneW,
height: paneH + PREVIEW_GAP + PREVIEW_BAR_H,
["--preview-img-h" as string]: `${artBudget}px`,
};
}
const compact = !!fit;
const selectPane = (next: PreviewPane, back: boolean) => {
setPane(next);
if (back !== showBack) onFlip();
};
// 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 && (
)}
{compact && (
)}
);
}
// --- 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 (
);
}
/** Clickable WUBRG toggles that edit a bitmask value. */
export function ColorToggles({
value,
onChange,
colorless,
onColorlessChange,
colorlessTitle,
}: {
value: number;
onChange: (mask: number) => void;
/** Renders a "C" pip alongside the colours when provided.
*
* Colourlessness can't live in the bitmask: 0 there already means "don't
* filter by colour", so there'd be no way to say "no colours at all" — which
* is exactly what you need to find a colourless commander's card pool. */
colorless?: boolean;
onColorlessChange?: (v: boolean) => void;
colorlessTitle?: string;
}) {
return (
{COLORS.map((c) => (
onChange(value ^ c.bit)}
>
{c.code}
))}
{onColorlessChange && (
onColorlessChange(!colorless)}
>
C
)}
);
}
// --- Shared card criteria -------------------------------------------------
/** Ringed planet — a card from another world/property (Universes Beyond). */
const ICON_CROSSOVER = icon(
<>
>,
);
/** Monitor — exists only on Arena/MTGO. */
const ICON_SCREEN = icon(
<>
>,
);
/** Smiley — the joke sets. */
const ICON_SMILEY = icon(
<>
>,
);
/** Crown — can head a Commander deck. */
const ICON_CROWN = icon(
<>
>,
);
/** An empty rules box — a vanilla creature, all body and no text. */
const ICON_BLANK_BOX = icon(
<>
>,
);
/** An open package — cards you don't have a copy of yet. */
const ICON_PACKAGE = icon(
<>
>,
);
/**
* Card-origin and numeric criteria shared by the Library and Card Search
* filter panels. Kept here rather than duplicated in both views, since they
* must stay in step: a filter that behaves differently between browsing what
* you own and searching everything would be a bug, not a feature.
*
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
* rather than coercing a blank to 0.
*/
export type CardCriteria = {
excludeUb: boolean;
excludeDigital: boolean;
excludeFunny: boolean;
priceMin: string;
priceMax: string;
legalIn: string;
canBeCommander: boolean;
vanillaOnly: boolean;
notOwned: boolean;
powerMin: string;
powerMax: string;
toughMin: string;
toughMax: string;
};
export const EMPTY_CRITERIA: CardCriteria = {
excludeUb: false,
excludeDigital: false,
excludeFunny: false,
priceMin: "",
priceMax: "",
legalIn: "",
canBeCommander: false,
vanillaOnly: false,
notOwned: false,
powerMin: "",
powerMax: "",
toughMin: "",
toughMax: "",
};
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial) => void] {
const [value, setValue] = usePersistedState(key, EMPTY_CRITERIA);
// Merged over the defaults so a stored object written before a field existed
// doesn't leave it undefined.
const merged = { ...EMPTY_CRITERIA, ...value };
const patch = useCallback(
(p: Partial) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
[setValue, value],
);
return [merged, patch];
}
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
/** Fold criteria into the filter sent to the backend. */
export function applyCardCriteria(c: CardCriteria): Partial {
return {
exclude_universes_beyond: c.excludeUb,
exclude_digital_only: c.excludeDigital,
exclude_funny: c.excludeFunny,
price_min: num(c.priceMin),
price_max: num(c.priceMax),
legal_in: c.legalIn || null,
can_be_commander: c.canBeCommander,
vanilla_only: c.vanillaOnly,
not_owned: c.notOwned,
power_min: num(c.powerMin),
power_max: num(c.powerMax),
toughness_min: num(c.toughMin),
toughness_max: num(c.toughMax),
};
}
/**
* Whether these criteria amount to a search in their own right.
*
* The three "exclude" toggles deliberately don't count: they refine a result
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
* make Card Search pull every card in the database. Choosing a format, a
* power range, or "not owned" is a real query, and does.
*/
export function criteriaActive(c: CardCriteria): boolean {
return (
c.canBeCommander ||
c.vanillaOnly ||
c.notOwned ||
c.legalIn !== "" ||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
(v) => v.trim() !== "",
)
);
}
function RangePair({
label,
min,
max,
onMin,
onMax,
step,
prefix,
}: {
label: string;
min: string;
max: string;
onMin: (v: string) => void;
onMax: (v: string) => void;
step?: string;
prefix?: string;
}) {
return (
);
}
export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
}: {
value: CardCriteria;
onChange: (patch: Partial) => void;
/** Omit the "Not owned" toggle — for the Library tab, which already shows
* nothing but owned cards, so the filter would just be a no-op there. */
hideNotOwned?: boolean;
}) {
return (
<>
onChange({ priceMin: v })}
onMax={(v) => onChange({ priceMax: v })}
/>
onChange({ powerMin: v })}
onMax={(v) => onChange({ powerMax: v })}
/>
onChange({ toughMin: v })}
onMax={(v) => onChange({ toughMax: v })}
/>
{/* Exclusions read as "no X" — tinted with the danger colour when
active, so removing things looks different from selecting them. */}
onChange({ excludeUb: !c.excludeUb })}
/>
onChange({ excludeDigital: !c.excludeDigital })}
/>
onChange({ excludeFunny: !c.excludeFunny })}
/>
onChange({ canBeCommander: !c.canBeCommander })}
/>
onChange({ vanillaOnly: !c.vanillaOnly })}
/>
{!hideNotOwned && (
onChange({ notOwned: !c.notOwned })}
/>
)}
>
);
}
/**
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
* rather than selecting them, which is worth distinguishing visually since
* both live in the same row.
*/
function IconToggle({
icon,
label,
hint,
on,
exclude,
onToggle,
}: {
icon: ReactNode;
label: string;
hint: string;
on: boolean;
exclude?: boolean;
onToggle: () => void;
}) {
return (
);
}
/** Compact relative time, e.g. "2h ago" — for "updated"/"synced" stamps. */
export function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
if (diffSec < 30) return "just now";
const mins = Math.round(diffSec / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.round(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.round(months / 12)}y ago`;
}