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 }) { // Prefer the right of the cursor; flip left if it would overflow. let left = x + GAP; if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP; const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD)); return ( {card.name} ); } function ClickPreviewModal({ state, onClose, onFlip, }: { state: { card: CardRef; showBack: boolean }; onClose: () => void; onFlip: () => void; }) { const { card, showBack } = state; 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()} >
{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"; } /** 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") { const rank = (l: string) => { const i = TYPE_PRIORITY.indexOf(l); return i === -1 ? TYPE_PRIORITY.length : i; }; groups.sort((a, b) => rank(a.label) - rank(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 ( ); } // --- 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} ))} ); }