import { useEffect, useState } from "react"; import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api"; import { CardThumb, CardTile, ColorPips, ColorToggles, GroupBySelect, GroupMode, IconButton, Icons, QtyStepper, SortBySelect, SortDir, SortMode, TagInput, ViewToggle, groupCards, sortCards, usePersistedState, useViewMode, } from "../components"; // Server-side cap on results — this browses the whole ~30k-card reference // db, not just what's owned, so an unbounded/loose filter needs a limit. const CAP = 300; const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] }; /** * Browse the whole downloaded card universe (every card in reference.db, * owned or not) — the Library tab's sibling, reusing its filter/group/sort/ * list/tiles machinery. Results are annotated with ownership: an "Owned ×N" * stepper and any decks the card is already in. * * `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the * "Add to deck" undo is also reachable from the command palette. */ export function CardSearch({ onOfferUndo, }: { onOfferUndo: (message: string, run: () => void | Promise) => void; }) { const [text, setText] = useState(""); const [commander, setCommander] = useState(0); const [colorsInclude, setColorsInclude] = useState(0); const [cmcMin, setCmcMin] = useState(""); const [cmcMax, setCmcMax] = useState(""); const [types, setTypes] = useState([]); const [subtypes, setSubtypes] = useState([]); const [keywords, setKeywords] = useState([]); const [oracleText, setOracleText] = useState(""); const [facets, setFacets] = useState(NO_FACETS); const [decks, setDecks] = useState([]); const [deckId, setDeckId] = useState(""); const [rows, setRows] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState>(new Set()); const [refreshKey, setRefreshKey] = useState(0); const [view, setView] = useViewMode("cardsearch-view"); const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none"); const [sortBy, setSortBy] = usePersistedState("cardsearch-sort", "name"); const [sortDir, setSortDir] = usePersistedState("cardsearch-sort-dir", "asc"); useEffect(() => { api.listDecks().then(setDecks); }, []); useEffect(() => { api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS)); }, []); const hasActiveFilter = text.trim() !== "" || commander !== 0 || colorsInclude !== 0 || cmcMin !== "" || cmcMax !== "" || types.length > 0 || subtypes.length > 0 || keywords.length > 0 || oracleText.trim() !== ""; useEffect(() => { if (!hasActiveFilter) { setRows([]); setError(null); return; } const filter: LibraryFilter = { text: text.trim() || null, commander_identity: commander || null, colors_include: colorsInclude || null, cmc_min: cmcMin === "" ? null : Number(cmcMin), cmc_max: cmcMax === "" ? null : Number(cmcMax), types, subtypes, keywords, oracle_text: oracleText.trim() || null, }; const t = setTimeout(() => { api .searchReference(filter, CAP) .then((r) => { setRows(r); setError(null); }) .catch((e) => { setRows([]); setError(String(e)); }); }, 120); return () => clearTimeout(t); }, [ hasActiveFilter, text, commander, colorsInclude, cmcMin, cmcMax, types, subtypes, keywords, oracleText, refreshKey, ]); async function adjust(oracleId: string, delta: number) { const qty = await api.addOwned(oracleId, delta); setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c))); } /** * Add one copy of `c` to the deck picked in the toolbar. Snapshots the * card's prior deck_cards row (or absence of one) first and offers an * "Undo" that restores exactly that prior state — same pattern as * `LibraryBrowser.bulkAddToDeck`. */ async function addToDeck(c: LibraryCard) { if (deckId === "") return; const deck = decks.find((d) => d.id === deckId); if (!deck) return; setBusy((b) => new Set(b).add(c.oracle_id)); try { const before = await api.deckContents(deck.id); const prev = before.find((e) => e.oracle_id === c.oracle_id); await api.setDeckCard(deck.id, c.oracle_id, 1, false, false); setRefreshKey((k) => k + 1); onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => { await api.setDeckCard( deck.id, c.oracle_id, prev?.quantity ?? 0, prev?.is_commander ?? false, prev?.is_maybeboard ?? false, ); setRefreshKey((k) => k + 1); }); } finally { setBusy((b) => { const next = new Set(b); next.delete(c.oracle_id); return next; }); } } function refTable(cards: LibraryCard[]) { return ( {cards.map((c) => ( ))}
Colors Name Type MV Owned In decks
{c.name} {c.type_line} {c.mana_value} adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} /> {c.decks.map((d) => ( {d} ))}
); } function refTiles(cards: LibraryCard[]) { return (
{cards.map((c) => ( adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} /> } cornerBottomLeft={ addToDeck(c)} disabled={deckId === "" || busy.has(c.oracle_id)} overlay /> } footer={ c.decks.length > 0 ? c.decks.map((d) => ( {d} )) : undefined } /> ))}
); } const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy); return (

Card Search {hasActiveFilter && {rows.length}}

setSortDir(sortDir === "asc" ? "desc" : "asc")} />
setText(e.target.value)} placeholder="e.g. bolt" />
setCmcMin(e.target.value)} /> – setCmcMax(e.target.value)} />
setOracleText(e.target.value)} placeholder="e.g. landfall" />
{!hasActiveFilter ? (

Search the full card database — type a name or choose filters below to get started.

) : error ? (

{error}

) : rows.length === 0 ? (

No cards match your filters.

) : ( <> {rows.length === CAP && (

Showing the first {CAP} matches — refine your search to narrow it down.

)} {groups.map((g) => (
{g.label && (

{g.label} {g.cards.length}

)} {view === "list" ? refTable(g.cards) : refTiles(g.cards)}
))} )}
); }