import { useEffect, useRef, 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, CardCriteriaFields, applyCardCriteria, criteriaActive, groupCards, sortCards, useCardCriteria, 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); // Colourlessness can't ride in the bitmask — 0 there means "no filter". const [colorlessIdentity, setColorlessIdentity] = useState(false); const [colorlessColors, setColorlessColors] = useState(false); 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 [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria"); // Collapsed by default (relevant on narrow/mobile screens only — see // .filters-more's CSS, which is always expanded above that breakpoint // regardless of this). const [filtersExpanded, setFiltersExpanded] = useState(false); 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 [selected, setSelected] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false); const selectAllRef = useRef(null); 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"); // Tiles-only: rotates cards that are actually horizontal (Battles, Planes, // Phenomena) into their natural landscape orientation — handy when a search // is mostly/entirely those. Cards that aren't horizontal are unaffected. const [horizontalView, setHorizontalView] = usePersistedState("cardsearch-horizontal-view", false); useEffect(() => { api.listDecks().then(setDecks); }, []); useEffect(() => { api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS)); }, []); const hasActiveFilter = text.trim() !== "" || commander !== 0 || colorsInclude !== 0 || colorlessIdentity || colorlessColors || cmcMin !== "" || cmcMax !== "" || types.length > 0 || subtypes.length > 0 || keywords.length > 0 || oracleText.trim() !== "" || criteriaActive(criteria); useEffect(() => { if (!hasActiveFilter) { setRows([]); setError(null); return; } const filter: LibraryFilter = { text: text.trim() || null, commander_identity: commander || null, colors_include: colorsInclude || null, colorless_identity: colorlessIdentity, colorless_colors: colorlessColors, cmc_min: cmcMin === "" ? null : Number(cmcMin), cmc_max: cmcMax === "" ? null : Number(cmcMax), types, subtypes, keywords, oracle_text: oracleText.trim() || null, ...applyCardCriteria(criteria), }; 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, colorlessIdentity, colorlessColors, cmcMin, cmcMax, types, subtypes, keywords, oracleText, criteria, refreshKey, ]); // Drop selections that fall out of the result set when the filter changes, // so a hidden card can't be silently added to a deck later. useEffect(() => { setSelected((s) => { const visible = new Set(rows.map((r) => r.oracle_id)); const next = new Set([...s].filter((id) => visible.has(id))); return next.size === s.size ? s : next; }); }, [rows]); useEffect(() => { if (selectAllRef.current) { selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length; } }, [selected, rows]); function toggleOne(oracleId: string) { setSelected((s) => { const next = new Set(s); if (next.has(oracleId)) next.delete(oracleId); else next.add(oracleId); return next; }); } function toggleSelectAll() { setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id)))); } /** * Add every selected card to the deck chosen in the header, at one copy each. * * Mirrors the Library tab's bulk add, including that it *sets* the quantity * rather than incrementing — so re-running it is idempotent — and offers a * single undo restoring every card's prior deck entry at once. */ async function bulkAddToDeck() { if (deckId === "") return; const deck = decks.find((d) => d.id === deckId); if (!deck) return; setBulkBusy(true); try { const targets = rows.filter((r) => selected.has(r.oracle_id)); const before = await api.deckContents(deck.id); const prevByOracle = new Map(before.map((e) => [e.oracle_id, e])); await Promise.all( targets.map((r) => api.setDeckCard(deck.id, r.oracle_id, 1, false, bulkToMaybeboard)), ); setRefreshKey((k) => k + 1); onOfferUndo( `Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`, async () => { await Promise.all( targets.map((r) => { const prev = prevByOracle.get(r.oracle_id); return api.setDeckCard( deck.id, r.oracle_id, prev?.quantity ?? 0, prev?.is_commander ?? false, prev?.is_maybeboard ?? false, ); }), ); setRefreshKey((k) => k + 1); }, ); } finally { setBulkBusy(false); } } 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
toggleOne(c.oracle_id)} /> {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) => ( toggleOne(c.oracle_id)} /> } corner={ 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}}

{selected.size > 0 && (
{selected.size} selected
{/* Bound to the same deck as the header picker rather than a separate one — two controls that disagreed about the target deck would be worse than the redundancy. It's repeated here because a disabled button whose reason lives elsewhere on the page reads as broken. */}
)}
setText(e.target.value)} placeholder="e.g. bolt" />
setCmcMin(e.target.value)} /> – setCmcMax(e.target.value)} />
setOracleText(e.target.value)} placeholder="e.g. landfall" />
{rows.length > 0 && ( )} {hasActiveFilter && ( {rows.length} result{rows.length === 1 ? "" : "s"} )} setSortDir(sortDir === "asc" ? "desc" : "asc")} />
{!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)}
))} )}
); }