Deck builder: advanced card filter in a popup

A filter button beside the deck's quick filter opens the Library's full
card filter as a popup. The deck's sections narrow to the cards it
matches, found with the same reference search scoped to the deck
(main board and maybeboard). The button lights up while a filter is on,
and the popup shows how many cards match, with Clear filters and Done.
CardFilterPanel gains options to hide Name / In deck and to stay
expanded.

Closes #6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:31:28 -05:00
parent 1e0cd50c7d
commit 6e524ccefa
4 changed files with 165 additions and 29 deletions

View File

@@ -11,8 +11,10 @@ import {
DeckStatus,
isPhysical,
libraryKey,
LibraryFacets,
PreviewContext,
} from "../api";
import { CardFilterPanel, CardFilterState, NO_FACETS, useCardFilter } from "../cardFilter";
import {
CardSearchBox,
CardThumb,
@@ -231,6 +233,8 @@ function SectionNav({
onTop,
filter,
onFilterChange,
onAdvanced,
advancedActive,
}: {
sections: NavSection[];
active: string;
@@ -238,6 +242,9 @@ function SectionNav({
onTop: () => void;
filter: string;
onFilterChange: (q: string) => void;
onAdvanced: () => void;
/** An advanced filter is narrowing the cards. */
advancedActive: boolean;
}) {
const [open, setOpen] = useState(false);
const jumpRef = useRef<HTMLDivElement>(null);
@@ -305,10 +312,56 @@ function SectionNav({
onChange={(e) => onFilterChange(e.target.value)}
placeholder="Filter cards in deck…"
/>
<button
className={advancedActive ? "section-nav-adv active" : "section-nav-adv"}
onClick={onAdvanced}
title={advancedActive ? "Advanced filter (on)" : "Advanced filter"}
aria-label={advancedActive ? "Advanced filter (on)" : "Advanced filter"}
>
{Icons.filter}
</button>
</div>
);
}
/** The advanced filter as a popup, so it doesn't take over the builder.
* Changes apply as they're made; `matched` is null until a filter is on. */
function AdvancedFilterDialog({
state,
matched,
total,
error,
onClose,
}: {
state: CardFilterState;
matched: number | null;
total: number;
error: string | null;
onClose: () => void;
}) {
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
useEffect(() => {
api.getReferenceFacets().then(setFacets).catch(() => {});
}, []);
return (
<DialogFrame title="Filter deck cards" onCancel={onClose} wide>
<CardFilterPanel state={state} facets={facets} decks={[]} hideName hideInDeck alwaysExpanded />
{error && <p className="sync-bad">{error}</p>}
<div className="dialog-actions">
<span className="muted" style={{ marginRight: "auto" }}>
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`}
</span>
<button onClick={state.reset} disabled={!state.active}>
Clear filters
</button>
<button className="primary" onClick={onClose}>
Done
</button>
</div>
</DialogFrame>
);
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
@@ -352,6 +405,12 @@ function DeckBuilder({
const [sortDir, setSortDir] = usePersistedState<SortDir>("deck-sort-dir", "asc");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
// The Library's full card filter, narrowing the sections to the deck cards
// it matches. Its criteria (rarity, price, …) persist like the Library's.
const advFilter = useCardFilter("deck-criteria");
const [advOpen, setAdvOpen] = useState(false);
const [advMatches, setAdvMatches] = useState<Set<string> | null>(null);
const [advError, setAdvError] = useState<string | null>(null);
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState<CardRef[]>([]);
@@ -391,6 +450,38 @@ function DeckBuilder({
loadContents();
}, [loadContents]);
// Which of the deck's cards the advanced filter matches: the same search
// the Library runs, scoped to this deck (main board and maybeboard). Null
// when no filter is on, or the search failed, so nothing is hidden.
const contentIds = contents.map((e) => e.oracle_id).join(",");
useEffect(() => {
if (!advFilter.active) {
setAdvMatches(null);
setAdvError(null);
return;
}
let live = true;
const t = setTimeout(async () => {
try {
const cards = await api.searchReference(
{ ...advFilter.filter, in_deck: deckId },
Math.max(1, contentIds.split(",").length),
);
if (!live) return;
setAdvMatches(new Set(cards.map((c) => c.oracle_id)));
setAdvError(null);
} catch (e) {
if (!live) return;
setAdvMatches(null);
setAdvError(String(e));
}
}, 150);
return () => {
live = false;
clearTimeout(t);
};
}, [advFilter.filter, advFilter.active, deckId, contentIds]);
// Refetch on an external refresh signal without resetting the UI state
// above (unlike a deck switch, this shouldn't clear the user's filter,
// selection, or open sample hand). Skips the first render so it doesn't
@@ -770,6 +861,7 @@ function DeckBuilder({
// filter only, distinct from the CardSearchBox boxes below (which search
// the library/reference db to find NEW cards to add).
function matchesFilter(e: DeckEntry): boolean {
if (advMatches && !advMatches.has(e.oracle_id)) return false;
const q = fold(filterQuery.trim());
if (!q) return true;
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
@@ -1343,6 +1435,8 @@ function DeckBuilder({
onTop={jumpToTop}
filter={filterQuery}
onFilterChange={setFilterQuery}
onAdvanced={() => setAdvOpen(true)}
advancedActive={advFilter.active}
/>
{selectedCards.size > 0 && (
@@ -1512,6 +1606,15 @@ function DeckBuilder({
{cardsGroup(maybeboard)}
</section>
{advOpen && (
<AdvancedFilterDialog
state={advFilter}
matched={advMatches?.size ?? null}
total={contents.length}
error={advError}
onClose={() => setAdvOpen(false)}
/>
)}
{handOpen && (
<SampleHandModal
hand={hand}