import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; import { api, Deck, LibraryCard, LibraryFacets, LibraryImportResult, PreviewContext, } from "../api"; import { CardThumb, CardTile, DialogFrame, ColorPips, GroupBySelect, GroupMode, InfoDialog, LocationField, ProgressDialog, PromptDialog, QtyStepper, SortBySelect, SortDir, SortMode, TileColumnsControl, ViewToggle, groupCards, sortCards, usePersistedState, useTileColumns, useViewMode, } from "../components"; import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter"; // 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. * * `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 filterState = useCardFilter("library-criteria"); const { filter, criteria } = filterState; const [facets, setFacets] = useState(NO_FACETS); const [decks, setDecks] = useState([]); const [rows, setRows] = useState([]); const [view, setView] = useViewMode("library-view"); const tiles = useTileColumns(); 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 [importError, setImportError] = useState(null); // The "what can I import?" explainer shown before the file picker. const [importIntroOpen, setImportIntroOpen] = useState(false); // The file picked to import, while asking where its cards are kept. const [importPath, setImportPath] = useState(null); // Lines done / total while an import runs; null when idle. const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null); // Only show the progress dialog for an import that's taking a while, so a // quick one doesn't flash it up. const [showImportProgress, setShowImportProgress] = useState(false); 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 t = setTimeout(() => { api.searchLibrary(filter).then(setRows).catch(() => setRows([])); }, 120); return () => clearTimeout(t); }, [filter, 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() { setImportIntroOpen(false); const path = await open({ title: "Import library cards", filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }], }); if (typeof path === "string") setImportPath(path); } async function runImport(path: string, location: string) { setImportPath(null); setImportProgress({ done: 0, total: 0 }); const reveal = setTimeout(() => setShowImportProgress(true), 300); const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) => setImportProgress(e.payload), ); try { setImportResult(await api.importLibraryCards(path, location)); } catch (err) { setImportError(String(err)); } finally { unlisten(); clearTimeout(reveal); setShowImportProgress(false); setImportProgress(null); setRefreshKey((k) => k + 1); } } const deckOrLocation = (c: LibraryCard) => c.decks.length === 0 ? ( setRefreshKey((k) => k + 1)} /> ) : ( c.decks.map((d) => ( {d} )) ); // Under the spares filter, how many copies are free — only worth saying for // a card some deck uses, since otherwise it's every copy shown. const spareNote = (c: LibraryCard) => criteria.sparesOnly && c.quantity_assigned > 0 && ( {c.quantity_owned - c.quantity_assigned} spare ); // Lets the click preview track which printings the copies are. const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) }; 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 )} {spareNote(c)} {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)} {spareNote(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)} /> )} {importIntroOpen && ( setImportIntroOpen(false)} wide>

Adds the cards in a file to your library. Nothing you already own is replaced or removed. You can import:

  • A card list (.txt), one card per line, like 4 Lightning Bolt. A line can name a printing, like 1 Sol Ring (2X2) 315 *F*, where{" "} *F* is foil and *E* etched. Library export, Moxfield and Arena all write this format.
  • A ManaBox export of a collection or binder, either the .csv or the .txt. The CSV records each copy's exact printing and finish, so it's the better choice. ManaBox's condition, language and price fields aren't kept.

After you choose a file, you can file its cards under a location, like the binder they came from. Any cards that can't be matched are listed afterwards.

)} {importPath && ( runImport(importPath, location)} onCancel={() => setImportPath(null)} /> )} {importProgress && showImportProgress && ( )} {importError && ( setImportError(null)} /> )} {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)} /> )}
{rows.length} card{rows.length === 1 ? "" : "s"} setSortDir(sortDir === "asc" ? "desc" : "asc")} /> {view === "tiles" && }
{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)}
)) )}
); } /** A picked file's name without its folder or extension — a ManaBox export * is named after its binder ("Green Bulk.csv"). Copes with Android content * URIs, whose last segment is a percent-encoded "primary:Download/x.csv". */ function fileStem(path: string): string { let name = path; try { name = decodeURIComponent(path); } catch { // Not percent-encoded after all; use it as it is. } name = name.split(/[/\\:]/).pop() ?? ""; return name.replace(/\.[^.]*$/, "").trim(); } /** Asks where an import's cards are kept, pre-filled with the file's name. * Blank imports them without touching any card's location. */ function ImportLocationDialog({ path, onImport, onCancel, }: { path: string; onImport: (location: string) => void; onCancel: () => void; }) { const [value, setValue] = useState(() => fileStem(path)); const inputRef = useRef(null); useEffect(() => inputRef.current?.select(), []); const submit = () => onImport(value.trim()); return (

Every imported card is filed under this location, replacing any it already had. Leave it blank to keep cards' current locations.

); }