import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { api, CardRef, COLORS, maskToLetters } from "./api"; /** * 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 ( { 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). */ export function CardSearchBox({ placeholder, value, onChange, results, onPick, autoFocus, big, }: { 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; }) { 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 ( <> onChange(e.target.value)} onKeyDown={onKeyDown} /> {results.length > 0 && ( {results.map((c, i) => ( setSel(i)} onClick={() => pick(c)} > {c.name} {c.type_line} ))} )} > ); } // --- 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 ( ); } function ClickPreviewModal({ state, onClose, onFlip, }: { state: { card: CardRef; showBack: boolean }; onClose: () => void; onFlip: () => void; }) { const { card, showBack } = state; const src = showBack && card.back_image_uri ? card.back_image_uri : card.image_uri; return ( e.stopPropagation()}> β {card.back_image_uri && ( β² Flip )} ); } // --- 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"; } function firstSubtype(typeLine: string): string { const parts = typeLine.split("β"); if (parts.length < 2) return "No subtype"; return parts[1].trim().split(/\s+/)[0] || "No 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" ? firstSubtype(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 ( Group by onChange(e.target.value as GroupMode)}> {GROUP_OPTIONS.map((o) => ( {o.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 ( onChange("list")} title="List view" > β° List onChange("tiles")} title="Image tiles" > β¦ Tiles ); } /** * 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, footer, }: { card: CardRef; corner?: ReactNode; footer?: ReactNode; }) { const { open } = useClickPreview(); return ( {card.image_uri ? ( open(card)} /> ) : ( {card.name} )} {corner && {corner}} {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} ))} ); }