import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api"; import { CardThumb, CardTile, ColorPips, ColorToggles, GroupBySelect, GroupMode, InfoDialog, LocationField, PromptDialog, QtyStepper, SortBySelect, SortDir, SortMode, TagInput, ViewToggle, groupCards, sortCards, 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; const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] }; /** * Browse & filter the owned library by name and card criteria. * * `onOfferUndo` is threaded in from `App` (rather than owning an `UndoBar` * here) so the resulting "Undo" is also reachable from the command palette, * which is mounted at the app level and outlives this view across tab * switches. */ export function LibraryBrowser({ 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 [inDeck, setInDeck] = useState(""); const [location, setLocation] = useState(""); const [oracleText, setOracleText] = useState(""); // 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 [rows, setRows] = useState([]); const [view, setView] = useViewMode("library-view"); const [groupBy, setGroupBy] = usePersistedState("library-group", "none"); const [sortBy, setSortBy] = usePersistedState("library-sort", "name"); const [sortDir, setSortDir] = usePersistedState("library-sort-dir", "asc"); const [refreshKey, setRefreshKey] = useState(0); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); const [selected, setSelected] = useState>(new Set()); const [bulkDeckId, setBulkDeckId] = useState(""); const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false); const [bulkDelta, setBulkDelta] = useState(1); const [locationDialogOpen, setLocationDialogOpen] = useState(false); const [bulkBusy, setBulkBusy] = useState(false); const [importResult, setImportResult] = useState(null); const selectAllRef = useRef(null); useEffect(() => { api.listDecks().then(setDecks); }, []); // Refetch whenever refreshKey bumps too, so newly-owned cards' types // become suggestible without restarting the app. useEffect(() => { api.getLibraryFacets().then(setFacets).catch(() => setFacets(NO_FACETS)); }, [refreshKey]); // Cancel any in-flight removal timers on unmount so they don't fire against // an unmounted component. useEffect(() => () => removalTimers.current.forEach(clearTimeout), []); // Keep selection in sync with what's actually visible — drop ids that fall // out of the filtered set (filter changed, or a card's qty hit 0 and its // grace period expired). 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]); 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, subtypes, keywords, in_deck: inDeck === "" ? null : Number(inDeck), location: location.trim() || null, oracle_text: oracleText.trim() || null, }; const t = setTimeout(() => { api.searchLibrary(filter).then(setRows).catch(() => setRows([])); }, 120); return () => clearTimeout(t); }, [ text, commander, colorsInclude, cmcMin, cmcMax, types, subtypes, keywords, inDeck, location, 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))); // 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), ); } } 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)))); } /** * Set the in-deck quantity of every selected card to either 1 or the full * owned count (e.g. select a handful of basic lands, "Add All" to put * every copy you own into the deck at once) — overwrites the quantity for * cards already in the deck rather than skipping them, so it doubles as a * bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the * deck's maybeboard instead of the mainboard. * * Since this overwrites rather than skips, it can clobber an existing * in-deck quantity/board — so it snapshots each target's prior deck_cards * row (or absence of one) first and offers an "Undo" that restores exactly * that prior state (deleting the row again for cards that weren't in the * deck before). */ async function bulkAddToDeck(mode: "one" | "all") { if (bulkDeckId === "") return; const deck = decks.find((d) => d.id === bulkDeckId); 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, mode === "all" ? r.quantity_owned : 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 bulkSetLocation(value: string) { setBulkBusy(true); try { await Promise.all([...selected].map((id) => api.setLocation(id, value))); setLocationDialogOpen(false); setRefreshKey((k) => k + 1); } finally { setBulkBusy(false); } } async function bulkAdjust(delta: number) { setBulkBusy(true); try { await Promise.all([...selected].map((id) => adjust(id, delta))); } finally { setBulkBusy(false); } } async function exportSelected() { const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id); if (targets.length === 0) return; const path = await save({ title: "Export library cards", defaultPath: "library-export.txt", filters: [{ name: "Decklist", extensions: ["txt"] }], }); if (typeof path === "string") await api.exportLibraryCards(targets, path); } async function pickImportFile() { const path = await open({ title: "Import library cards", filters: [{ name: "Decklist", extensions: ["txt"] }], }); if (typeof path !== "string") return; const result = await api.importLibraryCards(path); setImportResult(result); setRefreshKey((k) => k + 1); } 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
toggleOne(c.oracle_id)} /> {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) => ( toggleOne(c.oracle_id)} /> } corner={ 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(sortCards(rows, sortBy, sortDir), groupBy); return (

Library {rows.length}

{selected.size > 0 && (
{selected.size} selected
setBulkDelta(Math.max(1, Number(e.target.value) || 1))} />
)} {locationDialogOpen && ( setLocationDialogOpen(false)} /> )} {importResult && ( 0 ? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:` : `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.` } items={importResult.unresolved} onClose={() => setImportResult(null)} /> )}
setText(e.target.value)} placeholder="e.g. bolt" />
setCmcMin(e.target.value)} /> – setCmcMax(e.target.value)} />
setLocation(e.target.value)} placeholder="e.g. Box 1" />
setOracleText(e.target.value)} placeholder="e.g. landfall" />
{rows.length} card{rows.length === 1 ? "" : "s"} setSortDir(sortDir === "asc" ? "desc" : "asc")} />
{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)}
)) )}
); }