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
This commit is contained in:
2026-10-23 22:46:45 -05:00
parent 89ef5d0848
commit e4956d8231
6 changed files with 332 additions and 415 deletions

View File

@@ -6,7 +6,6 @@ import {
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
@@ -15,7 +14,6 @@ import {
CardTile,
DialogFrame,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
@@ -26,26 +24,21 @@ import {
SortBySelect,
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
groupCards,
sortCards,
useCardCriteria,
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;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/**
* Browse & filter the owned library by name and card criteria.
*
@@ -59,25 +52,8 @@ export function LibraryBrowser({
}: {
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 [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("library-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 filterState = useCardFilter("library-criteria");
const { filter, criteria } = filterState;
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
@@ -142,43 +118,11 @@ export function LibraryBrowser({
}, [selected, rows]);
useEffect(() => {
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,
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
}, 120);
return () => clearTimeout(t);
}, [
text,
commander,
colorsInclude,
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
subtypes,
keywords,
inDeck,
location,
oracleText,
criteria,
refreshKey,
]);
}, [filter, refreshKey]);
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
@@ -661,110 +605,7 @@ export function LibraryBrowser({
)}
<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>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>
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned showSpares />
</div>
</div>
<button
className="filters-toggle"
onClick={() => setFiltersExpanded((v) => !v)}
>
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
<CardFilterPanel state={filterState} facets={facets} decks={decks} hideNotOwned />
<div className="workspace-bar">
<label className="workspace-bar-check" title="Select all filtered cards">