Files
mtgc/src/views/LibraryBrowser.tsx
Garrett Mills e4956d8231 Shared card filter panel: News set browser gets every Library filter
Extract the filter fields and LibraryFilter-building state into
src/cardFilter.tsx (CardFilterPanel + useCardFilter) and use it in the
Library, Card Search and News set browser, replacing three diverging
copies. search_reference now honors in_deck/location too, so every
filter works in every tab.

Closes #13
2026-10-23 22:46:45 -05:00

713 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>) => void;
}) {
const filterState = useCardFilter("library-criteria");
const { filter, criteria } = filterState;
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
const [refreshKey, setRefreshKey] = useState(0);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const [bulkDelta, setBulkDelta] = useState(1);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(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<string | null>(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<HTMLInputElement>(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 ? (
<LocationField
oracleId={c.oracle_id}
location={c.location}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
);
// 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 && (
<span className="muted" title="Copies no deck is using">
{c.quantity_owned - c.quantity_assigned} spare
</span>
);
// Lets the click preview track which printings the copies are.
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
function libTable(cards: LibraryCard[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>Decks / Location</th>
</tr>
</thead>
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td className="cell-check">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td data-label="MV">{c.mana_value}</td>
<td
data-label="Owned"
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
>
<span className="row" style={{ gap: 8 }}>
<QtyStepper
value={c.quantity_owned}
onDecrement={() => 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 && (
<span title="Assigned to more decks than you own">
⚠ {c.quantity_assigned} assigned
</span>
)}
{spareNote(c)}
</span>
</td>
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
</table>
);
}
function libTiles(cards: LibraryCard[]) {
return (
<div {...tiles.gridProps}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
preview={preview}
selected={selected.has(c.oracle_id)}
cornerLeft={
<span className="tile-select">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</span>
}
corner={
<QtyStepper
overlay
value={c.quantity_owned}
onDecrement={() => 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)}
</>
}
/>
))}
</div>
);
}
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
return (
<div>
<div className="page-header">
<h2 className="page-header-title">
Library <span className="badge">{rows.length}</span>
</h2>
<div className="page-header-actions">
<button
onClick={() => setImportIntroOpen(true)}
disabled={importProgress !== null}
>
Import cards…
</button>
</div>
</div>
{selected.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selected.size} selected</span>
<div className="bulk-bar-actions">
<span className="row" style={{ gap: 6 }}>
<select
value={bulkDeckId}
onChange={(e) => setBulkDeckId(e.target.value === "" ? "" : Number(e.target.value))}
disabled={bulkBusy}
>
<option value="">Add to deck…</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<button
onClick={() => bulkAddToDeck("one")}
disabled={bulkBusy || bulkDeckId === ""}
title="Set each selected card to 1 copy in the deck"
>
Add 1
</button>
<button
onClick={() => bulkAddToDeck("all")}
disabled={bulkBusy || bulkDeckId === ""}
title="Set each selected card to every copy you own"
>
Add All
</button>
<label className="row" style={{ gap: 4, cursor: "pointer" }} title="Send to the maybeboard instead of the mainboard">
<input
type="checkbox"
checked={bulkToMaybeboard}
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
disabled={bulkBusy}
/>
<span className="muted" style={{ fontSize: 12 }}>
Maybeboard
</span>
</label>
</span>
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
Set location…
</button>
<span className="row" style={{ gap: 4 }}>
<button
onClick={() => bulkAdjust(-bulkDelta)}
disabled={bulkBusy}
title="Remove copies from each selected card"
>
−
</button>
<input
className="bulk-bar-delta"
type="number"
min={1}
value={bulkDelta}
onChange={(e) => setBulkDelta(Math.max(1, Number(e.target.value) || 1))}
/>
<button
onClick={() => bulkAdjust(bulkDelta)}
disabled={bulkBusy}
title="Add copies to each selected card"
>
+
</button>
</span>
<button
onClick={exportSelected}
disabled={bulkBusy}
title="Export the selected cards to a decklist file"
>
Export selected
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
{locationDialogOpen && (
<PromptDialog
title="Set location"
label={`Location for ${selected.size} card${selected.size === 1 ? "" : "s"}`}
defaultValue=""
confirmLabel="Apply"
onConfirm={bulkSetLocation}
onCancel={() => setLocationDialogOpen(false)}
/>
)}
{importIntroOpen && (
<DialogFrame title="Import cards" onCancel={() => setImportIntroOpen(false)} wide>
<p className="dialog-message">
Adds the cards in a file to your library. Nothing you already own is replaced or
removed. You can import:
</p>
<ul className="dialog-list">
<li>
<strong>A card list</strong> (.txt), one card per line, like <code>4 Lightning Bolt</code>.
A line can name a printing, like <code>1 Sol Ring (2X2) 315 *F*</code>, where{" "}
<code>*F*</code> is foil and <code>*E*</code> etched. Library export, Moxfield and
Arena all write this format.
</li>
<li>
<strong>A ManaBox export</strong> 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.
</li>
</ul>
<p className="dialog-message">
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.
</p>
<div className="dialog-actions">
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
<button className="primary" onClick={pickImportFile} autoFocus>
Choose file…
</button>
</div>
</DialogFrame>
)}
{importPath && (
<ImportLocationDialog
path={importPath}
onImport={(location) => runImport(importPath, location)}
onCancel={() => setImportPath(null)}
/>
)}
{importProgress && showImportProgress && (
<ProgressDialog
title="Importing cards…"
done={importProgress.done}
total={importProgress.total}
unit="lines"
/>
)}
{importError && (
<InfoDialog
title="Couldn't import that file"
message={importError}
onClose={() => setImportError(null)}
/>
)}
{importResult && (
<InfoDialog
title="Cards imported"
message={
importResult.unresolved.length > 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)}
/>
)}
<div className="panel workspace">
<CardFilterPanel state={filterState} facets={facets} decks={decks} hideNotOwned />
<div className="workspace-bar">
<label className="workspace-bar-check" title="Select all filtered cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
disabled={rows.length === 0}
/>
Select all
</label>
<span className="workspace-bar-count">
{rows.length} card{rows.length === 1 ? "" : "s"}
</span>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
</div>
{rows.length === 0 ? (
<p className="hint">
No matching cards owned. Add some in the Add Cards tab, or loosen the filters.
</p>
) : (
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
</div>
))
)}
</div>
);
}
/** 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<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const submit = () => onImport(value.trim());
return (
<DialogFrame title="Where are these cards?" onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">Location</span>
<input
ref={inputRef}
autoFocus
value={value}
placeholder="e.g. Box 1"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
</label>
<p className="dialog-message">
Every imported card is filed under this location, replacing any it already had. Leave it
blank to keep cards' current locations.
</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={submit}>
Import
</button>
</div>
</DialogFrame>
);
}