Files
mtgc/src/views/LibraryBrowser.tsx

644 lines
21 KiB
TypeScript
Raw Normal View History

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>) => 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<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
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 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 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 ? (
<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>
))
);
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>
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td>
<CardThumb card={c} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td 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>
)}
</span>
</td>
<td>{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
</table>
);
}
function libTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
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)}
/>
))}
</div>
);
}
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<div className="row" style={{ gap: 14 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all filtered cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
disabled={rows.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<h2 style={{ margin: 0 }}>
Library <span className="badge">{rows.length}</span>
</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<button onClick={pickImportFile}>Import cards…</button>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</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)}
/>
)}
{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="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
</div>
<div className="field">
<label>Subtype(s)</label>
<TagInput
values={subtypes}
onChange={setSubtypes}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<TagInput
values={keywords}
onChange={setKeywords}
suggestions={facets.keywords}
placeholder="Flying…"
/>
</div>
<div className="field">
<label>In deck</label>
<select value={inDeck} onChange={(e) => setInDeck(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Any</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Location</label>
<input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Box 1"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</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>
);
}