import { createContext, forwardRef, ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { api, CardRef, COLORS, maskToLetters } from "./api"; // --- 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 or backdrop click. */ 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]); 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: CardRef) => void; } const HoverCtx = createContext(null); const ClickCtx = createContext(null); /** * Provides card-image previews app-wide: a small floating preview that * tracks the cursor while hovering (see [`useHoverPreview`]), and a larger * centered modal on click (see [`useClickPreview`]) — with a flip button for * double-faced cards. Wrap the app once. */ export function HoverPreviewProvider({ children }: { children: ReactNode }) { const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null); const [clicked, setClicked] = useState<{ card: CardRef; showBack: boolean } | null>(null); const enter = useCallback((card: CardRef, e: React.MouseEvent) => { if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY }); }, []); const move = useCallback((e: React.MouseEvent) => { setHover((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s)); }, []); const leave = useCallback(() => setHover(null), []); const open = useCallback((card: CardRef) => { if (!card.image_uri) return; setHover(null); setClicked({ card, showBack: false }); }, []); const close = useCallback(() => setClicked(null), []); const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []); useEffect(() => { if (!clicked) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && close(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [clicked, close]); return ( {children} {!clicked && hover && } {clicked && } ); } /** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */ export function useHoverPreview() { const api = useContext(HoverCtx); return { handlers: (card: CardRef) => api ? { onMouseEnter: (e: React.MouseEvent) => api.enter(card, e), onMouseMove: (e: React.MouseEvent) => api.move(e), onMouseLeave: () => api.leave(), } : {}, }; } /** Returns `open(card)` to trigger the larger click-to-preview modal. */ export function useClickPreview() { const api = useContext(ClickCtx); return { open: (card: CardRef) => api?.open(card) }; } const PREVIEW_W = 320; const PREVIEW_H = 446; // ~ card aspect for the normal image const GAP = 24; const PAD = 12; function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) { // Battles/Planes/Phenomena store their image portrait-shaped with the // content itself rotated, so the box footprint swaps (landscape) and the // image inside gets rotated 90° back into place — see `CardRef.horizontal`. const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W; const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H; // Prefer the right of the cursor; flip left if it would overflow. let left = x + GAP; if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP; const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD)); return (
{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. */ function useRotatedPreviewBox() { const compute = () => { const boundW = Math.min(720, window.innerWidth * 0.9); const boundH = window.innerHeight * 0.9; const w = Math.min(boundH, boundW * ROTATED_ASPECT); return { w, h: w / ROTATED_ASPECT }; }; const [box, setBox] = useState(compute); useEffect(() => { const onResize = () => setBox(compute()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); return box; } function ClickPreviewModal({ state, onClose, onFlip, }: { state: { card: CardRef; showBack: boolean }; onClose: () => void; onFlip: () => void; }) { const { card, showBack } = state; const rotatedBox = useRotatedPreviewBox(); const hasBack = !!card.back_image_uri; // Both faces are always in the DOM when the card has one; a wide-viewport // media query shows them side by side (no flip needed), while a narrow one // shows only the "active" face and the flip button toggles between them. return (
e.stopPropagation()} >
{/* Only the front face can be horizontal (Battles) — a transformed Battle's back face is always a normal, correctly-oriented creature card, per `CardRef.horizontal`'s doc comment. */} {card.horizontal ? (
{card.name}
) : ( {card.name} )} {hasBack && ( {`${card.name} )}
{hasBack && ( )}
); } // --- Grouping ------------------------------------------------------------- export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors"; export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [ { value: "none", label: "No grouping" }, { value: "type", label: "Card type" }, { value: "subtype", label: "Subtype" }, { value: "cmc", label: "Mana value" }, { value: "colors", label: "Color identity" }, ]; // Priority for choosing a card's primary type from its (multi-type) type line. const TYPE_PRIORITY = [ "Creature", "Planeswalker", "Battle", "Land", "Artifact", "Enchantment", "Instant", "Sorcery", ]; function primaryType(typeLine: string): string { const left = typeLine.split("—")[0]; for (const t of TYPE_PRIORITY) if (left.includes(t)) return t; const words = left.trim().split(/\s+/); return words[words.length - 1] || "Other"; } /** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order * type groups and to sort cards by type. */ function typeRank(primary: string): number { const i = TYPE_PRIORITY.indexOf(primary); return i === -1 ? TYPE_PRIORITY.length : i; } /** e.g. "Creature — Chimera" — prefixed with the primary type so cards group * by (type, subtype) rather than colliding on a subtype shared across types. */ function subtypeLabel(typeLine: string): string { const parts = typeLine.split("—"); const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype"; return `${primaryType(typeLine)} — ${subtype}`; } function colorLabel(mask: number): string { const letters = maskToLetters(mask); return letters === "" ? "Colorless" : letters; } /** Group cards by the chosen attribute, returning ordered groups. */ export function groupCards( cards: T[], mode: GroupMode, ): { label: string; cards: T[] }[] { if (mode === "none") return [{ label: "", cards }]; const keyOf = (c: T) => mode === "type" ? primaryType(c.type_line) : mode === "subtype" ? subtypeLabel(c.type_line) : mode === "cmc" ? `Mana value ${c.mana_value}` : colorLabel(c.color_identity); const map = new Map(); for (const c of cards) { const k = keyOf(c); const arr = map.get(k); if (arr) arr.push(c); else map.set(k, [c]); } const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs })); if (mode === "type") { groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label)); } else if (mode === "cmc") { groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value); } else { groups.sort((a, b) => a.label.localeCompare(b.label)); } return groups; } /** Dropdown for choosing a [`GroupMode`]. */ export function GroupBySelect({ value, onChange, }: { value: GroupMode; onChange: (m: GroupMode) => void; }) { return ( ); } // --- Sorting ---------------------------------------------------------- export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price"; export const SORT_OPTIONS: { value: SortMode; label: string }[] = [ { value: "name", label: "Name" }, { value: "mv", label: "Mana value" }, { value: "color", label: "Color identity" }, { value: "type", label: "Card type" }, { value: "qty", label: "Quantity owned" }, { value: "price", label: "Price" }, ]; export type SortDir = "asc" | "desc"; /** Sort cards by the chosen attribute; ties always break by name so the * order stays stable and predictable regardless of `mode`. Grouping (see * [`groupCards`]) determines section order independently — sort this array * first, then group it, and each group keeps this order internally. */ export function sortCards( cards: T[], mode: SortMode, dir: SortDir, ): T[] { const sorted = [...cards].sort((a, b) => { const cmp = mode === "name" ? a.name.localeCompare(b.name) : mode === "mv" ? a.mana_value - b.mana_value || a.name.localeCompare(b.name) : mode === "color" ? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) || a.name.localeCompare(b.name) : mode === "type" ? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) || a.name.localeCompare(b.name) : mode === "qty" ? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name) : a.price_usd - b.price_usd || a.name.localeCompare(b.name); return dir === "asc" ? cmp : -cmp; }); return sorted; } /** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */ export function SortBySelect({ value, dir, onChangeValue, onToggleDir, }: { value: SortMode; dir: SortDir; onChangeValue: (m: SortMode) => void; onToggleDir: () => void; }) { return ( ); } // --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------ /** A single numeric stat in a small panel tile, e.g. "42 / unique". */ export function StatTile({ value, label }: { value: string | number; label: string }) { return (
{value} {label}
); } /** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */ export function ColorDistributionBar({ colorCounts, colorlessCount, }: { colorCounts: [number, number, number, number, number]; // W, U, B, R, G colorlessCount: number; }) { const segs: [string, number][] = [ ["W", colorCounts[0]], ["U", colorCounts[1]], ["B", colorCounts[2]], ["R", colorCounts[3]], ["G", colorCounts[4]], ["C", colorlessCount], ]; if (segs.every(([, count]) => count === 0)) return null; return (
{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. */ export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] { const [mode, setMode] = useState( () => (localStorage.getItem(key) as ViewMode) || "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, }: { 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; }) { const { open } = useClickPreview(); return (
{card.image_uri ? ( {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, }: { value: number; onChange: (mask: number) => void; }) { return ( {COLORS.map((c) => ( onChange(value ^ c.bit)} > {c.code} ))} ); }