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) => ( {children} ); const ICON_TRASH = icon( <> , ); const ICON_COPY = icon( <> , ); const ICON_PENCIL = icon(); const ICON_CLOSE = icon( <> , ); const ICON_HELP_CIRCLE = icon( <> , ); const ICON_ARROW_LEFT_CIRCLE = icon( <> , ); const ICON_CARDS = icon( <> , ); const ICON_DOWNLOAD = icon( <> , ); const ICON_UPLOAD = icon( <> , ); /** * 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 ( ); } 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 (
e.stopPropagation()}>

{title}

{children}
); } /** 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 (

{message}

); } /** 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(null); useEffect(() => inputRef.current?.select(), []); const confirm = () => { const v = value.trim(); if (v) onConfirm(v); }; return ( {error &&

{error}

}
); } /** 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 ( {card.name} { 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 && (
    {results.map((c, i) => (
  • setSel(i)} onClick={() => pick(c)} > {c.name} {c.type_line}
  • ))}
)} ); }); /** * 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 (
{values.map((v) => ( {v} ))} setText(e.target.value)} onKeyDown={onKeyDown} placeholder={values.length === 0 ? placeholder : ""} />
{filtered.length > 0 && (
    {filtered.map((s, i) => (
  • setSel(i)} onClick={() => addValue(s)} > {s}
  • ))}
)}
); } // --- 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 (
{card.name}
); } // 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 (
{LEGALITY_FORMATS.map((f) => { const status = legalStatusFor(legalities, f.label); return ( {f.label} ); })}
); } /** 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 (
{card.name}
{card.type_line}
Mana value {card.mana_value}
{card.price_usd > 0 && (
${card.price_usd.toFixed(2)}
)} {card.quantity_owned !== undefined && (
Owned {card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
)} {card.decks && card.decks.length > 0 && (
In decks
{card.decks.map((d) => ( {d} ))}
)}
Legalities
); }); 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 ? (
{card.name}
) : ( {card.name} )} {hasBack && ( {`${card.name} )} {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 (
{segs.map(([code, count]) => count > 0 ? ( ) : null, )}
); } /** 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 (
{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 (
{b.count || ""}
{segs.map(([code, count]) => count > 0 ? ( ) : null, )}
{b.label}
); })}
); } // --- 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 ( ); } /** * 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 (
{card.image_uri ? ( showRotated ? ( {card.name} open(card)} /> ) : ( {card.name} open(card)} /> ) ) : (
{card.name}
)} {cornerLeft &&
{cornerLeft}
} {corner &&
{corner}
} {cornerBottomLeft &&
{cornerBottomLeft}
} {card.price_usd > 0 && (
${card.price_usd.toFixed(2)}
)}
{footer &&
{footer}
}
); } /** 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 (
{prefix && {prefix}} onMin(e.target.value)} /> – {prefix && {prefix}} onMax(e.target.value)} />
); } 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`; }