Files
mtgc/src/components.tsx
Garrett Mills b39b89be20 Android back button closes modals and leaves the News set browser
Adds useBackButton, a stack of back-button claimants (last claimed wins)
backed by Tauri's app-plugin back-button event. The listener is only
registered while something has claimed the button, so at the root back
keeps its native leave-the-app behavior.

Claimed by: the click-to-preview modal, DialogFrame (so Suggest cards,
Sample hand and every other dialog), and NewsView while a set's card
list is open. A preview opened from inside a dialog closes first.
2026-10-14 21:12:30 -05:00

1907 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
api,
CardRef,
COLORS,
LEGALITY_FORMATS,
LibraryFilter,
legalStatusFor,
maskToLetters,
PreviewCard,
} from "./api";
import { useBackButton } from "./backButton";
// --- Icons ------------------------------------------------------------
const icon = (children: ReactNode) => (
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const ICON_TRASH = icon(
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</>,
);
const ICON_COPY = icon(
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>,
);
const ICON_PENCIL = icon(<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />);
const ICON_CLOSE = icon(
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>,
);
const ICON_HELP_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>,
);
const ICON_ARROW_LEFT_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 8 8 12 12 16" />
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
const ICON_CARDS = icon(
<>
<rect x="4" y="2" width="12" height="17" rx="2" />
<rect x="8" y="5" width="12" height="17" rx="2" />
</>,
);
const ICON_DOWNLOAD = icon(
<>
<path d="M12 3v12" />
<polyline points="7 11 12 16 17 11" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_UPLOAD = icon(
<>
<path d="M12 21V9" />
<polyline points="7 13 12 8 17 13" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
* (rename, duplicate, delete, dismiss, close) throughout the app.
* - `overlay`: dark translucent variant for sitting on top of card art.
* - `danger`: tints red on hover, for destructive actions.
*/
export function IconButton({
icon,
label,
onClick,
overlay,
danger,
disabled,
}: {
icon: ReactNode;
label: string;
onClick: (e: React.MouseEvent) => void;
overlay?: boolean;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
type="button"
className={["icon-btn", overlay ? "icon-btn-overlay" : "", danger ? "icon-btn-danger" : ""]
.filter(Boolean)
.join(" ")}
onClick={onClick}
title={label}
aria-label={label}
disabled={disabled}
>
{icon}
</button>
);
}
export const Icons = {
trash: ICON_TRASH,
copy: ICON_COPY,
pencil: ICON_PENCIL,
close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
cards: ICON_CARDS,
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
* command palette). Dismisses on Escape, Android back, backdrop click, or the
* title-bar close button — the explicit button matters most on mobile, where
* there's no Escape key and a `wide` dialog can leave little to no backdrop
* to tap.
*/
export function DialogFrame({
title,
children,
onCancel,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
wide?: boolean;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
useBackButton(onCancel);
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
<div className="dialog-title-row">
<h3 className="dialog-title">{title}</h3>
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
</div>
{children}
</div>
</div>
);
}
/** A confirm/cancel dialog for destructive or otherwise consequential actions. */
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
danger,
onConfirm,
onCancel,
}: {
title: string;
message: ReactNode;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<DialogFrame title={title} onCancel={onCancel}>
<p className="dialog-message">{message}</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className={danger ? "danger" : "primary"} onClick={onConfirm} autoFocus>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/** A single-text-field dialog (rename, duplicate-as, etc.), Enter to confirm. */
export function PromptDialog({
title,
label,
defaultValue,
confirmLabel = "Save",
error,
onConfirm,
onCancel,
}: {
title: string;
label: string;
defaultValue: string;
confirmLabel?: string;
error?: string | null;
onConfirm: (value: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const confirm = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
<DialogFrame title={title} onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">{label}</span>
<input
ref={inputRef}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirm()}
/>
</label>
{error && <p className="dialog-error">{error}</p>}
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={confirm} disabled={!value.trim()}>
{confirmLabel}
</button>
</div>
</DialogFrame>
);
}
/** 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 (
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
{message && <p className="dialog-message">{message}</p>}
{items && items.length > 0 && (
<ul className="dialog-list">
{items.map((it, i) => (
<li key={i}>{it}</li>
))}
</ul>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
OK
</button>
</div>
</DialogFrame>
);
}
/** 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 (
<div className="undo-bar">
<span>{message}</span>
<button className="primary" onClick={onUndo}>
Undo
</button>
<button className="undo-bar-close" onClick={onDismiss} title="Dismiss">
×
</button>
</div>
);
}
/** 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 (
<DialogFrame title="Sample hand" onCancel={onClose} wide>
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
<span className="muted" style={{ fontSize: 12 }}>
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
</span>
<span className="spacer" />
<button onClick={onDrawOne} disabled={libraryCount === 0}>
Draw a card
</button>
<button onClick={onNewHand}>New hand</button>
</div>
{hand.length > 0 ? (
<div className="tile-grid">
{hand.map((c, i) => (
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
))}
</div>
) : (
<p className="hint">Your deck has no cards to draw yet.</p>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}
/**
* 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 (
<input
className="location-input"
placeholder="Location…"
value={val}
onChange={(e) => 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 (
<span
className={[
"qty-stepper",
overlay ? "qty-stepper-overlay" : "",
warn ? "warn" : "",
pending ? "pending" : "",
]
.filter(Boolean)
.join(" ")}
>
<button
type="button"
className="qty-stepper-btn"
onClick={onDecrement}
disabled={value <= 0}
title="Remove a copy"
>
−
</button>
<span className="qty-stepper-value">{value}</span>
<button type="button" className="qty-stepper-btn" onClick={onIncrement} title="Add a copy">
+
</button>
</span>
);
}
/** 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 (
<span className="pips">
<span className="pip C">C</span>
</span>
);
}
return (
<span className="pips">
{letters.split("").map((c) => (
<span key={c} className={`pip ${c}`}>
{c}
</span>
))}
</span>
);
}
/**
* 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 (
<span className="thumb-empty" title="No image">
🂠
</span>
);
}
return (
<img
className="card-thumb"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={(e) => {
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 `<input>` 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<HTMLInputElement | null>(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 (
<>
<input
ref={(el) => {
inputRef.current = el;
if (typeof forwardedRef === "function") forwardedRef(el);
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).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 && (
<ul className="result-list" style={{ marginBottom: 14 }}>
{results.map((c, i) => (
<li
key={c.oracle_id}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(c)}
>
<ColorPips mask={c.color_identity} />
<span className="card-name">{c.name}</span>
<span className="card-type">{c.type_line}</span>
</li>
))}
</ul>
)}
</>
);
});
/**
* A chip-based multi-value text filter with autocomplete: selected values
* render as removable chips, and typing filters `suggestions` into a
* dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the
* typed text verbatim, so it's not limited to only-suggested values).
* Backspace on an empty input quick-removes the last chip.
*/
export function TagInput({
values,
onChange,
suggestions,
placeholder,
}: {
values: string[];
onChange: (values: string[]) => void;
suggestions: string[];
placeholder?: string;
}) {
const [text, setText] = useState("");
const [sel, setSel] = useState(0);
const query = text.trim().toLowerCase();
const filtered = query
? suggestions
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
.filter((s) => s.toLowerCase().includes(query))
.slice(0, 8)
: [];
useEffect(() => setSel(0), [text]);
function addValue(v: string) {
const trimmed = v.trim();
if (!trimmed) return;
if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) {
onChange([...values, trimmed]);
}
setText("");
}
function removeValue(v: string) {
onChange(values.filter((x) => x !== v));
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.min(s + 1, filtered.length - 1));
} else if (e.key === "ArrowUp" && filtered.length > 0) {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[sel]) addValue(filtered[sel]);
else if (text.trim()) addValue(text);
} else if (e.key === "Backspace" && text === "" && values.length > 0) {
onChange(values.slice(0, -1));
} else if (e.key === "Escape") {
setText("");
}
}
return (
<div className="tag-input">
<div className="tag-chips">
{values.map((v) => (
<span key={v} className="tag-chip">
{v}
<button
type="button"
className="tag-chip-remove"
onClick={() => removeValue(v)}
title={`Remove ${v}`}
>
×
</button>
</span>
))}
<input
className="tag-chip-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder={values.length === 0 ? placeholder : ""}
/>
</div>
{filtered.length > 0 && (
<ul className="result-list tag-suggestions">
{filtered.map((s, i) => (
<li
key={s}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => addValue(s)}
>
{s}
</li>
))}
</ul>
)}
</div>
);
}
// --- 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<HoverApi | null>(null);
const ClickCtx = createContext<ClickApi | null>(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 (
<HoverCtx.Provider value={{ enter, move, leave }}>
<ClickCtx.Provider value={{ open }}>
{children}
{!clicked && hover && <FloatingCard card={hover.card} x={hover.x} y={hover.y} />}
{clicked && <ClickPreviewModal state={clicked} onClose={close} onFlip={flip} />}
</ClickCtx.Provider>
</HoverCtx.Provider>
);
}
/** 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 (
<div className="hover-card" style={{ left, top, width: boxW, height: boxH }}>
<img
className="hover-card-img"
src={card.image_uri}
alt={card.name}
style={
card.horizontal
? {
position: "absolute",
top: "50%",
left: "50%",
width: PREVIEW_W,
height: PREVIEW_H,
transform: "translate(-50%, -50%) rotate(90deg)",
}
: undefined
}
/>
</div>
);
}
// 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 (
<div className="preview-info-legal-grid">
{LEGALITY_FORMATS.map((f) => {
const status = legalStatusFor(legalities, f.label);
return (
<span key={f.key} className={`legal-pill legal-${status}`}>
{f.label}
</span>
);
})}
</div>
);
}
/** 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<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
return (
<div className="preview-info" ref={ref}>
<div className="preview-info-name">{card.name}</div>
<div className="preview-info-type">{card.type_line}</div>
<div className="preview-info-row">
<span className="preview-info-mv">Mana value {card.mana_value}</span>
<ColorPips mask={card.colors} />
</div>
{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>
)}
{card.decks && card.decks.length > 0 && (
<div className="preview-info-section">
<span className="preview-info-label">In decks</span>
<div className="preview-info-deck-chips">
{card.decks.map((d) => (
<span key={d} className="badge">
{d}
</span>
))}
</div>
</div>
)}
<div className="preview-info-section">
<span className="preview-info-label">Legalities</span>
<LegalityPills legalities={card.legalities} />
</div>
</div>
);
});
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) => (
<button className={active ? "active" : ""} onClick={onClick}>
{label}
</button>
);
return (
<span className="view-toggle">
{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))}
</span>
);
}
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<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const infoRef = useRef<HTMLDivElement>(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<PreviewPane>("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 (
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
<div
ref={modalRef}
style={compactStyle}
className={[
"preview-modal",
hasBack ? "preview-modal-dfc" : "",
compact ? "preview-modal-compact" : "",
compact && pane === "info" ? "preview-modal-show-info" : "",
]
.filter(Boolean)
.join(" ")}
onClick={(e) => e.stopPropagation()}
>
<div className="preview-modal-faces">
{/* 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 ? (
<div
className={`preview-modal-img preview-modal-img-rotated ${showBack ? "" : "active"}`}
style={{ width: rotatedBox.w, height: rotatedBox.h }}
>
<img
className="preview-modal-rotate-img"
src={card.image_uri}
alt={card.name}
style={{ width: rotatedBox.h, height: rotatedBox.w }}
/>
</div>
) : (
<img
className={`preview-modal-img ${showBack ? "" : "active"}`}
src={card.image_uri}
alt={card.name}
/>
)}
{hasBack && (
<img
className={`preview-modal-img ${showBack ? "active" : ""}`}
src={card.back_image_uri}
alt={`${card.name} (back)`}
/>
)}
<span className="preview-modal-close">
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
{hasBack && (
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
⟲ Flip
</button>
)}
</div>
<CardInfoTile ref={infoRef} card={card} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</div>
)}
</div>
</div>
);
}
// --- 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<T extends CardRef>(
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<string, T[]>();
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 (
<label className="group-by">
<span className="muted">Group by</span>
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
{GROUP_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
// --- 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<T extends CardRef & { quantity_owned: number }>(
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 (
<span className="row" style={{ gap: 4 }}>
<label className="group-by">
<span className="muted">Sort by</span>
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<button
type="button"
className="sort-dir-btn"
onClick={onToggleDir}
title={dir === "asc" ? "Ascending — click for descending" : "Descending — click for ascending"}
>
{dir === "asc" ? "↑" : "↓"}
</button>
</span>
);
}
// --- 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 (
<div className="stat-tile">
<span className="stat-tile-value">{value}</span>
<span className="stat-tile-label">{label}</span>
</div>
);
}
/** 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 (
<div className="color-bar">
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`color-bar-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
);
}
/** 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 (
<div className="mana-curve">
{buckets.map((b) => {
const segs: [string, number][] = [
["W", b.colorCounts[0]],
["U", b.colorCounts[1]],
["B", b.colorCounts[2]],
["R", b.colorCounts[3]],
["G", b.colorCounts[4]],
["C", b.colorlessCount],
];
return (
<div key={b.label} className="mana-curve-col">
<span className="mana-curve-count">{b.count || ""}</span>
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
{segs.map(([code, count]) =>
count > 0 ? (
<span
key={code}
className={`mana-curve-seg ${code}`}
style={{ flexGrow: count }}
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
/>
) : null,
)}
</div>
<span className="mana-curve-label">{b.label}</span>
</div>
);
})}
</div>
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives
* remounts and app restarts. */
export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState<T>(() => {
const raw = localStorage.getItem(key);
if (raw === null) return initial;
try {
return JSON.parse(raw) as T;
} catch {
return initial;
}
});
const set = useCallback(
(v: T) => {
localStorage.setItem(key, JSON.stringify(v));
setVal(v);
},
[key],
);
return [val, set];
}
// --- View mode (list vs. image tiles) ------------------------------------
export type ViewMode = "list" | "tiles";
/** View-mode state persisted to localStorage so it survives tab switches.
*
* With nothing persisted yet, the default itself is width-aware: List's wide
* multi-column table forces horizontal scrolling on a phone, so narrow
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
* This only picks the *default* — once a mode is explicitly chosen it's
* remembered and used everywhere, same as before. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(key) as ViewMode | null;
if (saved) return saved;
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list";
});
const set = useCallback(
(m: ViewMode) => {
localStorage.setItem(key, m);
setMode(m);
},
[key],
);
return [mode, set];
}
/** Segmented control switching between the list and image-tile layouts. */
export function ViewToggle({
mode,
onChange,
}: {
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) {
return (
<span className="view-toggle">
<button
className={mode === "list" ? "active" : ""}
onClick={() => onChange("list")}
title="List view"
>
☰ List
</button>
<button
className={mode === "tiles" ? "active" : ""}
onClick={() => onChange("tiles")}
title="Image tiles"
>
▦ Tiles
</button>
</span>
);
}
/**
* A single card rendered as a full image tile, with optional corner + footer.
* Clicking the image opens a larger click-to-preview modal.
*/
export function CardTile({
card,
corner,
cornerLeft,
cornerBottomLeft,
footer,
selected,
rotated,
}: {
card: CardRef;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
/** Rendered bottom-left, e.g. a secondary per-card action button. */
cornerBottomLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
/** Caller wants Horizontal view — only actually rotates when `card.horizontal`
* 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;
}) {
const { open } = useClickPreview();
const showRotated = !!rotated && card.horizontal;
return (
<div className={`tile ${selected ? "tile-selected" : ""} ${showRotated ? "tile-rotated" : ""}`}>
<div className={showRotated ? "tile-img-wrap-rotated" : "tile-img-wrap"}>
{card.image_uri ? (
showRotated ? (
<img
className="tile-img-rotated"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
/>
) : (
<img
className="tile-img"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
/>
)
) : (
<div className="tile-noimg">{card.name}</div>
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
</div>
)}
</div>
{footer && <div className="tile-footer">{footer}</div>}
</div>
);
}
/** 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 (
<span className="color-toggles">
{COLORS.map((c) => (
<span
key={c.code}
title={c.label}
className={`pip ${c.code} ${value & c.bit ? "on" : ""}`}
onClick={() => onChange(value ^ c.bit)}
>
{c.code}
</span>
))}
{onColorlessChange && (
<span
title={colorlessTitle ?? "Colorless"}
className={`pip C ${colorless ? "on" : ""}`}
onClick={() => onColorlessChange(!colorless)}
>
C
</span>
)}
</span>
);
}
// --- Shared card criteria -------------------------------------------------
/** Ringed planet — a card from another world/property (Universes Beyond). */
const ICON_CROSSOVER = icon(
<>
<circle cx="12" cy="11" r="6" />
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
</>,
);
/** Monitor — exists only on Arena/MTGO. */
const ICON_SCREEN = icon(
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6" />
<path d="M12 16v4" />
</>,
);
/** Smiley — the joke sets. */
const ICON_SMILEY = icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
</>,
);
/** Crown — can head a Commander deck. */
const ICON_CROWN = icon(
<>
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
);
/** An empty rules box — a vanilla creature, all body and no text. */
const ICON_BLANK_BOX = icon(
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
</>,
);
/** An open package — cards you don't have a copy of yet. */
const ICON_PACKAGE = icon(
<>
<path d="M21 8l-9-5-9 5 9 5 9-5z" />
<path d="M3 8v8l9 5 9-5V8" />
<path d="M12 13v8" />
</>,
);
/**
* 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<CardCriteria>) => void] {
const [value, setValue] = usePersistedState<CardCriteria>(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<CardCriteria>) => 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<LibraryFilter> {
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 (
<div className="field">
<label>{label}</label>
<div className="row">
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="min"
value={min}
onChange={(e) => onMin(e.target.value)}
/>
<span className="muted">–</span>
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="max"
value={max}
onChange={(e) => onMax(e.target.value)}
/>
</div>
</div>
);
}
export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => 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 (
<>
<div className="field">
<label>Legal in</label>
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
<option value="">Any format</option>
{LEGALITY_FORMATS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div>
<RangePair
label="Price (USD)"
prefix="$"
step="0.01"
min={c.priceMin}
max={c.priceMax}
onMin={(v) => onChange({ priceMin: v })}
onMax={(v) => onChange({ priceMax: v })}
/>
<RangePair
label="Power"
min={c.powerMin}
max={c.powerMax}
onMin={(v) => onChange({ powerMin: v })}
onMax={(v) => onChange({ powerMax: v })}
/>
<RangePair
label="Toughness"
min={c.toughMin}
max={c.toughMax}
onMin={(v) => onChange({ toughMin: v })}
onMax={(v) => onChange({ toughMax: v })}
/>
<div className="field">
<label>Quick filters</label>
<div className="icon-toggles">
{/* Exclusions read as "no X" — tinted with the danger colour when
active, so removing things looks different from selecting them. */}
<IconToggle
icon={ICON_CROSSOVER}
label="Hide Universes Beyond"
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
exclude
on={c.excludeUb}
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
/>
<IconToggle
icon={ICON_SCREEN}
label="Hide digital-only"
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
exclude
on={c.excludeDigital}
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
/>
<IconToggle
icon={ICON_SMILEY}
label="Hide Un-set cards"
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
exclude
on={c.excludeFunny}
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
/>
<span className="icon-toggles-sep" />
<IconToggle
icon={ICON_CROWN}
label="Only cards that can be a commander"
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
on={c.canBeCommander}
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
/>
<IconToggle
icon={ICON_BLANK_BOX}
label="Only vanilla creatures"
hint="Creatures with an empty rules box. Includes Adventure and Prepared cards whose creature half has no abilities."
on={c.vanillaOnly}
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
/>
{!hideNotOwned && (
<IconToggle
icon={ICON_PACKAGE}
label="Not owned"
hint="Only cards you don't have a single copy of yet."
on={c.notOwned}
onToggle={() => onChange({ notOwned: !c.notOwned })}
/>
)}
</div>
</div>
</>
);
}
/**
* 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 (
<button
type="button"
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
// Title carries the full explanation the old inline hint text did — the
// whole point of this row is to stop that prose eating the panel.
title={`${label}\n\n${hint}`}
aria-label={label}
aria-pressed={on}
onClick={onToggle}
>
{icon}
{/* A slash over the glyph when an exclusion is active, so "hidden" is
legible without reading the tooltip. */}
{exclude && on && <span className="icon-toggle-slash" />}
</button>
);
}
/** 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`;
}