import { useEffect, useRef, useState } from "react"; import { api, Deck, LibraryCard, LibraryFilter } from "../api"; import { CardThumb, CardTile, ColorPips, ColorToggles, GroupBySelect, GroupMode, LocationField, QtyStepper, ViewToggle, groupCards, usePersistedState, useViewMode, } from "../components"; // How long a card that's been decremented to 0 lingers (dimmed, still // adjustable) before actually dropping out of the library view — gives the // user a window to undo an accidental removal by clicking + again. const REMOVAL_GRACE_MS = 5000; /** Browse & filter the owned library by name and card criteria. */ export function LibraryBrowser() { const [text, setText] = useState(""); const [commander, setCommander] = useState(0); const [colorsInclude, setColorsInclude] = useState(0); const [cmcMin, setCmcMin] = useState(""); const [cmcMax, setCmcMax] = useState(""); const [type, setType] = useState(""); const [subtype, setSubtype] = useState(""); const [keyword, setKeyword] = useState(""); const [inDeck, setInDeck] = useState(""); const [location, setLocation] = useState(""); const [decks, setDecks] = useState([]); const [rows, setRows] = useState([]); const [view, setView] = useViewMode("library-view"); const [groupBy, setGroupBy] = usePersistedState("library-group", "none"); const [refreshKey, setRefreshKey] = useState(0); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); useEffect(() => { api.listDecks().then(setDecks); }, []); // Cancel any in-flight removal timers on unmount so they don't fire against // an unmounted component. useEffect(() => () => removalTimers.current.forEach(clearTimeout), []); useEffect(() => { 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: splitCsv(type), subtypes: splitCsv(subtype), keywords: splitCsv(keyword), in_deck: inDeck === "" ? null : Number(inDeck), location: location.trim() || null, }; const t = setTimeout(() => { api.searchLibrary(filter).then(setRows).catch(() => setRows([])); }, 120); return () => clearTimeout(t); }, [ text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, location, 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))); // Re-adding during the grace period below cancels the pending removal. const timer = removalTimers.current.get(oracleId); if (timer) { clearTimeout(timer); removalTimers.current.delete(oracleId); setPendingRemoval((p) => { const next = new Set(p); next.delete(oracleId); return next; }); } if (qty <= 0) { // search_library only returns owned (>0) cards — rather than dropping // the row immediately, dim it and give the user a window to undo a // mistaken decrement (via +) before it actually disappears. setPendingRemoval((p) => new Set(p).add(oracleId)); removalTimers.current.set( oracleId, setTimeout(() => { setRows((rs) => rs.filter((c) => c.oracle_id !== oracleId)); setPendingRemoval((p) => { const next = new Set(p); next.delete(oracleId); return next; }); removalTimers.current.delete(oracleId); }, REMOVAL_GRACE_MS), ); } } const deckOrLocation = (c: LibraryCard) => c.decks.length === 0 ? ( setRefreshKey((k) => k + 1)} /> ) : ( c.decks.map((d) => ( {d} )) ); function libTable(cards: LibraryCard[]) { return ( {cards.map((c) => ( ))}
Colors Name Type MV Owned Decks / Location
{c.name} {c.type_line} {c.mana_value} c.quantity_owned ? "over-assigned" : ""}> adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} warn={c.quantity_assigned > c.quantity_owned} pending={pendingRemoval.has(c.oracle_id)} /> {c.quantity_assigned > c.quantity_owned && ( ⚠ {c.quantity_assigned} assigned )} {deckOrLocation(c)}
); } function libTiles(cards: LibraryCard[]) { return (
{cards.map((c) => ( adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} warn={c.quantity_assigned > c.quantity_owned} pending={pendingRemoval.has(c.oracle_id)} /> } footer={deckOrLocation(c)} /> ))}
); } const groups = groupCards(rows, groupBy); return (

Library {rows.length}

setText(e.target.value)} placeholder="e.g. bolt" />
setCmcMin(e.target.value)} /> – setCmcMax(e.target.value)} />
setType(e.target.value)} placeholder="Creature, Legendary" />
setSubtype(e.target.value)} placeholder="Elf" />
setKeyword(e.target.value)} placeholder="Flying" />
setLocation(e.target.value)} placeholder="e.g. Box 1" />
{rows.length === 0 ? (

No matching cards owned. Add some in the Add Cards tab, or loosen the filters.

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

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

)} {view === "list" ? libTable(g.cards) : libTiles(g.cards)}
)) )}
); } function splitCsv(s: string): string[] { return s .split(",") .map((x) => x.trim()) .filter(Boolean); }