"Not owned" moves into the shared Quick filters icon-toggle row (hidden on the Library tab, where it'd always be a no-op since that view is owned-only already) instead of a standalone button. Tiles view gains a "Horizontal" toggle that rotates actually-horizontal cards (Battles, Planes, Phenomena) into their natural landscape orientation via a container-query-sized wrapper, spanning two grid columns so the rotated card isn't squeezed into a single portrait column's width and left too small to read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
623 lines
20 KiB
TypeScript
623 lines
20 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||
import {
|
||
CardThumb,
|
||
CardTile,
|
||
ColorPips,
|
||
ColorToggles,
|
||
GroupBySelect,
|
||
GroupMode,
|
||
IconButton,
|
||
Icons,
|
||
QtyStepper,
|
||
SortBySelect,
|
||
SortDir,
|
||
SortMode,
|
||
TagInput,
|
||
ViewToggle,
|
||
CardCriteriaFields,
|
||
applyCardCriteria,
|
||
criteriaActive,
|
||
groupCards,
|
||
sortCards,
|
||
useCardCriteria,
|
||
usePersistedState,
|
||
useViewMode,
|
||
} from "../components";
|
||
|
||
// Server-side cap on results — this browses the whole ~30k-card reference
|
||
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
||
const CAP = 300;
|
||
|
||
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
||
|
||
/**
|
||
* Browse the whole downloaded card universe (every card in reference.db,
|
||
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
||
* list/tiles machinery. Results are annotated with ownership: an "Owned ×N"
|
||
* stepper and any decks the card is already in.
|
||
*
|
||
* `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the
|
||
* "Add to deck" undo is also reachable from the command palette.
|
||
*/
|
||
export function CardSearch({
|
||
onOfferUndo,
|
||
}: {
|
||
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||
}) {
|
||
const [text, setText] = useState("");
|
||
const [commander, setCommander] = useState(0);
|
||
const [colorsInclude, setColorsInclude] = useState(0);
|
||
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
|
||
const [colorlessIdentity, setColorlessIdentity] = useState(false);
|
||
const [colorlessColors, setColorlessColors] = useState(false);
|
||
const [cmcMin, setCmcMin] = useState("");
|
||
const [cmcMax, setCmcMax] = useState("");
|
||
const [types, setTypes] = useState<string[]>([]);
|
||
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||
const [keywords, setKeywords] = useState<string[]>([]);
|
||
const [oracleText, setOracleText] = useState("");
|
||
const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria");
|
||
// 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<LibraryFacets>(NO_FACETS);
|
||
|
||
const [decks, setDecks] = useState<Deck[]>([]);
|
||
const [deckId, setDeckId] = useState<number | "">("");
|
||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [bulkBusy, setBulkBusy] = useState(false);
|
||
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
|
||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [view, setView] = useViewMode("cardsearch-view");
|
||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
||
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
||
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
||
// Tiles-only: rotates cards that are actually horizontal (Battles, Planes,
|
||
// Phenomena) into their natural landscape orientation — handy when a search
|
||
// is mostly/entirely those. Cards that aren't horizontal are unaffected.
|
||
const [horizontalView, setHorizontalView] = usePersistedState("cardsearch-horizontal-view", false);
|
||
|
||
useEffect(() => {
|
||
api.listDecks().then(setDecks);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
||
}, []);
|
||
|
||
const hasActiveFilter =
|
||
text.trim() !== "" ||
|
||
commander !== 0 ||
|
||
colorsInclude !== 0 ||
|
||
colorlessIdentity ||
|
||
colorlessColors ||
|
||
cmcMin !== "" ||
|
||
cmcMax !== "" ||
|
||
types.length > 0 ||
|
||
subtypes.length > 0 ||
|
||
keywords.length > 0 ||
|
||
oracleText.trim() !== "" ||
|
||
criteriaActive(criteria);
|
||
|
||
useEffect(() => {
|
||
if (!hasActiveFilter) {
|
||
setRows([]);
|
||
setError(null);
|
||
return;
|
||
}
|
||
const filter: LibraryFilter = {
|
||
text: text.trim() || null,
|
||
commander_identity: commander || null,
|
||
colors_include: colorsInclude || null,
|
||
colorless_identity: colorlessIdentity,
|
||
colorless_colors: colorlessColors,
|
||
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||
types,
|
||
subtypes,
|
||
keywords,
|
||
oracle_text: oracleText.trim() || null,
|
||
...applyCardCriteria(criteria),
|
||
};
|
||
const t = setTimeout(() => {
|
||
api
|
||
.searchReference(filter, CAP)
|
||
.then((r) => {
|
||
setRows(r);
|
||
setError(null);
|
||
})
|
||
.catch((e) => {
|
||
setRows([]);
|
||
setError(String(e));
|
||
});
|
||
}, 120);
|
||
return () => clearTimeout(t);
|
||
}, [
|
||
hasActiveFilter,
|
||
text,
|
||
commander,
|
||
colorsInclude,
|
||
colorlessIdentity,
|
||
colorlessColors,
|
||
cmcMin,
|
||
cmcMax,
|
||
types,
|
||
subtypes,
|
||
keywords,
|
||
oracleText,
|
||
criteria,
|
||
refreshKey,
|
||
]);
|
||
|
||
// Drop selections that fall out of the result set when the filter changes,
|
||
// so a hidden card can't be silently added to a deck later.
|
||
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]);
|
||
|
||
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))));
|
||
}
|
||
|
||
/**
|
||
* Add every selected card to the deck chosen in the header, at one copy each.
|
||
*
|
||
* Mirrors the Library tab's bulk add, including that it *sets* the quantity
|
||
* rather than incrementing — so re-running it is idempotent — and offers a
|
||
* single undo restoring every card's prior deck entry at once.
|
||
*/
|
||
async function bulkAddToDeck() {
|
||
if (deckId === "") return;
|
||
const deck = decks.find((d) => d.id === deckId);
|
||
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, 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 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)));
|
||
}
|
||
|
||
/**
|
||
* Add one copy of `c` to the deck picked in the toolbar. Snapshots the
|
||
* card's prior deck_cards row (or absence of one) first and offers an
|
||
* "Undo" that restores exactly that prior state — same pattern as
|
||
* `LibraryBrowser.bulkAddToDeck`.
|
||
*/
|
||
async function addToDeck(c: LibraryCard) {
|
||
if (deckId === "") return;
|
||
const deck = decks.find((d) => d.id === deckId);
|
||
if (!deck) return;
|
||
setBusy((b) => new Set(b).add(c.oracle_id));
|
||
try {
|
||
const before = await api.deckContents(deck.id);
|
||
const prev = before.find((e) => e.oracle_id === c.oracle_id);
|
||
await api.setDeckCard(deck.id, c.oracle_id, 1, false, false);
|
||
setRefreshKey((k) => k + 1);
|
||
onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => {
|
||
await api.setDeckCard(
|
||
deck.id,
|
||
c.oracle_id,
|
||
prev?.quantity ?? 0,
|
||
prev?.is_commander ?? false,
|
||
prev?.is_maybeboard ?? false,
|
||
);
|
||
setRefreshKey((k) => k + 1);
|
||
});
|
||
} finally {
|
||
setBusy((b) => {
|
||
const next = new Set(b);
|
||
next.delete(c.oracle_id);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
|
||
function refTable(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>In decks</th>
|
||
<th></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} />
|
||
</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">
|
||
<QtyStepper
|
||
value={c.quantity_owned}
|
||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||
/>
|
||
</td>
|
||
<td data-label="In decks">
|
||
{c.decks.map((d) => (
|
||
<span key={d} className="deck-chip">
|
||
{d}
|
||
</span>
|
||
))}
|
||
</td>
|
||
<td>
|
||
<button
|
||
onClick={() => addToDeck(c)}
|
||
disabled={deckId === "" || busy.has(c.oracle_id)}
|
||
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
|
||
>
|
||
Add to deck
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function refTiles(cards: LibraryCard[]) {
|
||
return (
|
||
<div className="tile-grid">
|
||
{cards.map((c) => (
|
||
<CardTile
|
||
key={c.oracle_id}
|
||
card={c}
|
||
selected={selected.has(c.oracle_id)}
|
||
rotated={horizontalView}
|
||
cornerLeft={
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(c.oracle_id)}
|
||
onChange={() => toggleOne(c.oracle_id)}
|
||
/>
|
||
}
|
||
corner={
|
||
<QtyStepper
|
||
overlay
|
||
value={c.quantity_owned}
|
||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||
/>
|
||
}
|
||
cornerBottomLeft={
|
||
<IconButton
|
||
icon={Icons.cards}
|
||
label="Add to deck"
|
||
onClick={() => addToDeck(c)}
|
||
disabled={deckId === "" || busy.has(c.oracle_id)}
|
||
overlay
|
||
/>
|
||
}
|
||
footer={
|
||
c.decks.length > 0
|
||
? c.decks.map((d) => (
|
||
<span key={d} className="deck-chip">
|
||
{d}
|
||
</span>
|
||
))
|
||
: undefined
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-header">
|
||
<h2 className="page-header-title">
|
||
Card Search {hasActiveFilter && <span className="badge">{rows.length}</span>}
|
||
</h2>
|
||
<div className="page-header-actions">
|
||
<select
|
||
value={deckId}
|
||
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||
>
|
||
<option value="">Add to deck…</option>
|
||
{decks.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{selected.size > 0 && (
|
||
<div className="bulk-bar">
|
||
<span className="bulk-bar-count">{selected.size} selected</span>
|
||
<div className="bulk-bar-actions">
|
||
{/* Bound to the same deck as the header picker rather than a
|
||
separate one — two controls that disagreed about the target
|
||
deck would be worse than the redundancy. It's repeated here
|
||
because a disabled button whose reason lives elsewhere on the
|
||
page reads as broken. */}
|
||
<select
|
||
value={deckId}
|
||
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||
disabled={bulkBusy}
|
||
>
|
||
<option value="">Choose a deck…</option>
|
||
{decks.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
className="primary"
|
||
onClick={bulkAddToDeck}
|
||
disabled={bulkBusy || deckId === ""}
|
||
title={
|
||
deckId === ""
|
||
? "Choose a deck first"
|
||
: "Set each selected card to 1 copy in the deck"
|
||
}
|
||
>
|
||
Add to deck
|
||
</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>
|
||
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="panel workspace">
|
||
<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={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
||
<div className="field">
|
||
<label>Playable in commander (color identity ⊆)</label>
|
||
<ColorToggles
|
||
value={commander}
|
||
onChange={setCommander}
|
||
colorless={colorlessIdentity}
|
||
onColorlessChange={setColorlessIdentity}
|
||
colorlessTitle="Colorless commander — only cards with no color identity"
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Includes colors</label>
|
||
<ColorToggles
|
||
value={colorsInclude}
|
||
onChange={setColorsInclude}
|
||
colorless={colorlessColors}
|
||
onColorlessChange={setColorlessColors}
|
||
colorlessTitle="Colorless cards only"
|
||
/>
|
||
</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>Oracle text</label>
|
||
<input
|
||
value={oracleText}
|
||
onChange={(e) => setOracleText(e.target.value)}
|
||
placeholder="e.g. landfall"
|
||
/>
|
||
</div>
|
||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="filters-toggle"
|
||
onClick={() => setFiltersExpanded((v) => !v)}
|
||
>
|
||
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
|
||
</button>
|
||
|
||
<div className="workspace-bar">
|
||
{rows.length > 0 && (
|
||
<label className="workspace-bar-check" title="Select all matching cards">
|
||
<input
|
||
ref={selectAllRef}
|
||
type="checkbox"
|
||
checked={rows.length > 0 && selected.size === rows.length}
|
||
onChange={toggleSelectAll}
|
||
/>
|
||
Select all
|
||
</label>
|
||
)}
|
||
{hasActiveFilter && (
|
||
<span className="workspace-bar-count">
|
||
{rows.length} result{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} />
|
||
<label
|
||
className="row"
|
||
style={{ gap: 4, cursor: view === "tiles" ? "pointer" : "default", opacity: view === "tiles" ? 1 : 0.5 }}
|
||
title="Tiles view: rotate Battles, Planes, and Phenomena to their natural landscape orientation"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={horizontalView}
|
||
onChange={(e) => setHorizontalView(e.target.checked)}
|
||
disabled={view !== "tiles"}
|
||
/>
|
||
<span className="muted" style={{ fontSize: 12 }}>
|
||
Horizontal
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{!hasActiveFilter ? (
|
||
<p className="hint">
|
||
Search the full card database — type a name or choose filters below to get started.
|
||
</p>
|
||
) : error ? (
|
||
<p className="hint">{error}</p>
|
||
) : rows.length === 0 ? (
|
||
<p className="hint">No cards match your filters.</p>
|
||
) : (
|
||
<>
|
||
{rows.length === CAP && (
|
||
<p className="hint">
|
||
Showing the first {CAP} matches — refine your search to narrow it down.
|
||
</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" ? refTable(g.cards) : refTiles(g.cards)}
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|