);
}
/** 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}
))}
)}
);
}
/** Modal progress for work that can't be cancelled or dismissed (e.g. a
* library import): a title, a bar, and "N of M ". `total` 0 shows an
* empty bar while the work is still starting. */
export function ProgressDialog({
title,
done,
total,
unit,
}: {
title: string;
done: number;
total: number;
unit: string;
}) {
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
return (
{title}
{total > 0 ? `${done.toLocaleString()} of ${total.toLocaleString()} ${unit}` : "Starting…"}
);
}
/** 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, preview }: { card: PreviewCard; preview?: PreviewContext }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
if (!card.image_uri) {
return (
🂠
);
}
return (
{
e.stopPropagation();
open(card, preview);
}}
{...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, ctx?: PreviewContext) => void;
}
type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean };
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(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, ctx?: PreviewContext) => {
if (!card.image_uri) return;
setHover(null);
setClicked({ card, ctx, 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, ctx?)` to trigger the larger click-to-preview modal.
* Passing a `ctx` adds the Printings section to its info tile. */
export function useClickPreview() {
const api = useContext(ClickCtx);
return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) };
}
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 (
{picking && printings && (
setPicking(null)}
/>
)}
>
);
}
/**
* A grid of a card's printings to pick from. With `chooseFinish`, each
* printing also offers its finishes (clicking the art takes the first).
*
* Opens above the click preview, which closes on any Escape it hears on
* `window` — so this listens in the capture phase and stops the event there,
* and only the picker closes.
*/
function PrintingPicker({
name,
printings,
chooseFinish,
current,
onPick,
onCancel,
}: {
name: string;
printings: PrintingRef[];
chooseFinish: boolean;
current: string | null;
onPick: (p: PrintingRef, finish: string) => void;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
onCancel();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [onCancel]);
useBackButton(onCancel);
return createPortal(
{
e.stopPropagation();
onCancel();
}}
>
e.stopPropagation()}>
{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}
,
document.body,
);
}
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: ClickedState;
onClose: () => void;
onFlip: () => void;
}) {
const { ctx, showBack } = state;
// A printing just chosen for this deck slot: show its art right away
// rather than the art the preview was opened with.
const [art, setArt] = useState(null);
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
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 (
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives
* remounts and app restarts. */
export function usePersistedState(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState(() => {
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(() => {
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 (
);
}
// --- Tiles per row --------------------------------------------------------
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
// styles.css — used to work out how many columns that default fills.
const TILE_MIN_WIDTH = 190;
const TILE_GAP = 16;
const TILE_MAX_COLUMNS = 12;
export interface TileColumns {
/** Columns currently shown (the width-based default plus the adjustment). */
columns: number;
/** True while the user has moved away from the width-based default. */
adjusted: boolean;
adjust: (delta: number) => void;
reset: () => void;
/** Spread onto each `.tile-grid` (`
`). */
gridProps: { ref: (el: HTMLDivElement | null) => void; className: string; style?: React.CSSProperties };
}
/** Per-row tile count for the Tiles view, adjustable with `TileColumnsControl`.
*
* The default stays the CSS auto-fill (as many ~190px tiles as fit), so the
* persisted setting is an *offset* from that rather than a fixed count: +1
* means "one more than would fit by default" on a phone and a desktop alike,
* and it keeps tracking the width across resizes and rotation. One offset is
* shared by every tiles view. */
export function useTileColumns(): TileColumns {
const [offset, setOffset] = usePersistedState("tile-columns-offset", 0);
const [auto, setAuto] = useState(0);
const observer = useRef(null);
// Every grid in a view (one per group) shares the same width, so whichever
// mounted last is the one measured.
const ref = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
observer.current?.disconnect();
// A grid that's been unmounted (e.g. switching to List) reports width 0;
// keep the last real measurement instead.
const measure = (w: number) => {
if (w > 0) setAuto(Math.max(1, Math.floor((w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
};
measure(el.clientWidth);
observer.current = new ResizeObserver((entries) => measure(entries[0].contentRect.width));
observer.current.observe(el);
}, []);
useEffect(() => () => observer.current?.disconnect(), []);
const base = auto || 1;
const columns = Math.min(TILE_MAX_COLUMNS, Math.max(1, base + offset));
const adjusted = offset !== 0 && auto > 0;
return {
columns,
adjusted,
// Clamp against the visible count, so pressing − at one column doesn't
// bank an offset that has to be undone before + does anything.
adjust: (delta) => {
const next = Math.min(TILE_MAX_COLUMNS, Math.max(1, columns + delta));
setOffset(next - base);
},
reset: () => setOffset(0),
gridProps: adjusted
? {
ref,
className: columns === 1 ? "tile-grid tile-grid-single" : "tile-grid",
style: { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` },
}
: { ref, className: "tile-grid" },
};
}
/** − N + stepper for the tiles-per-row count; clicking N resets to the default. */
export function TileColumnsControl({ tiles, disabled }: { tiles: TileColumns; disabled?: boolean }) {
return (
);
}
/**
* 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,
preview,
}: {
card: PreviewCard;
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;
/** Lets the click preview edit this card's printings. */
preview?: PreviewContext;
}) {
const { open } = useClickPreview();
const showRotated = !!rotated && card.horizontal;
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(
<>
>,
);
/** A card with another peeking out behind it — a copy no deck is using. */
const ICON_SPARE = 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;
sparesOnly: boolean;
/** Scryfall rarity names; empty means any rarity. */
rarities: string[];
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,
sparesOnly: false,
rarities: [],
powerMin: "",
powerMax: "",
toughMin: "",
toughMax: "",
};
const RARITIES = [
{ key: "common", code: "C", label: "Common" },
{ key: "uncommon", code: "U", label: "Uncommon" },
{ key: "rare", code: "R", label: "Rare" },
{ key: "mythic", code: "M", label: "Mythic rare" },
];
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,
spares_only: c.sparesOnly,
rarities: c.rarities,
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.sparesOnly ||
c.rarities.length > 0 ||
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 (
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 })}
/>
onChange({ sparesOnly: !c.sparesOnly })}
/>
{!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`;
}