Files
mtgc/src/views/CardSearch.tsx
garrettmills df2cbb372a Add a "not owned" quick filter and horizontal tile view to Card Search
"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>
2026-08-22 19:14:27 -05:00

623 lines
20 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 { 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>
);
}