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

@@ -6,6 +6,7 @@ import {
CardCriteria,
CardCriteriaFields,
ColorToggles,
EMPTY_CRITERIA,
TagInput,
applyCardCriteria,
criteriaActive,
@@ -95,6 +96,8 @@ export interface CardFilterState {
filter: LibraryFilter;
/** Whether anything is filtered on at all. */
active: boolean;
/** Clear every field and criterion. */
reset: () => void;
}
/** State for a {@link CardFilterPanel}. `criteriaKey` persists the criteria
@@ -108,7 +111,11 @@ export function useCardFilter(criteriaKey: string): CardFilterState {
const json = JSON.stringify(toFilter(fields, criteria));
const filter = useMemo(() => JSON.parse(json) as LibraryFilter, [json]);
const active = fieldsActive(fields) || criteriaActive(criteria);
return { fields, patch, criteria, patchCriteria, filter, active };
const reset = () => {
setFields(EMPTY_FIELDS);
patchCriteria(EMPTY_CRITERIA);
};
return { fields, patch, criteria, patchCriteria, filter, active, reset };
}
/**
@@ -121,6 +128,9 @@ export function CardFilterPanel({
facets,
decks,
hideNotOwned,
hideName,
hideInDeck,
alwaysExpanded,
}: {
state: CardFilterState;
/** Suggestions for the type/subtype/keyword inputs. */
@@ -129,6 +139,13 @@ export function CardFilterPanel({
decks: Deck[];
/** Omit the "Not owned" toggle — for the Library, which only shows owned cards. */
hideNotOwned?: boolean;
/** Omit "Name contains" — for a caller with its own name box. */
hideName?: boolean;
/** Omit "In deck" — for a caller already scoped to one deck. */
hideInDeck?: boolean;
/** Show every field at every width, with no "More filters" toggle — for a
* popup, which has the room. */
alwaysExpanded?: boolean;
}) {
const { fields: f, patch } = state;
// Collapsed by default (relevant on narrow/mobile screens only).
@@ -136,11 +153,13 @@ export function CardFilterPanel({
return (
<>
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={f.text} onChange={(e) => patch({ text: e.target.value })} placeholder="e.g. bolt" />
</div>
<div className={`filters-more ${expanded ? "expanded" : ""}`}>
{!hideName && (
<div className="field">
<label>Name contains</label>
<input value={f.text} onChange={(e) => patch({ text: e.target.value })} placeholder="e.g. bolt" />
</div>
)}
<div className={`filters-more ${expanded || alwaysExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles
@@ -208,20 +227,22 @@ export function CardFilterPanel({
placeholder="Flying…"
/>
</div>
<div className="field">
<label>In deck</label>
<select
value={f.inDeck}
onChange={(e) => patch({ inDeck: 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>
{!hideInDeck && (
<div className="field">
<label>In deck</label>
<select
value={f.inDeck}
onChange={(e) => patch({ inDeck: 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
@@ -241,9 +262,11 @@ export function CardFilterPanel({
<CardCriteriaFields value={state.criteria} onChange={state.patchCriteria} hideNotOwned={hideNotOwned} />
</div>
</div>
<button className="filters-toggle" onClick={() => setExpanded((v) => !v)}>
{expanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
{!alwaysExpanded && (
<button className="filters-toggle" onClick={() => setExpanded((v) => !v)}>
{expanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
)}
</>
);
}

View File

@@ -119,6 +119,7 @@ const ICON_ARROW_UP = icon(
<polyline points="5 12 12 5 19 12" />
</>,
);
const ICON_FILTER = icon(<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />);
const ICON_PLUS = icon(
<>
<line x1="12" y1="5" x2="12" y2="19" />
@@ -201,6 +202,7 @@ export const Icons = {
check: ICON_CHECK,
arrowLeft: ICON_ARROW_LEFT,
arrowUp: ICON_ARROW_UP,
filter: ICON_FILTER,
plus: ICON_PLUS,
more: ICON_MORE,
image: ICON_IMAGE,

View File

@@ -3178,18 +3178,25 @@ button.pull-action {
.section-nav-tabs button,
.section-nav-jump > button {
height: 32px;
padding: 0 10px;
padding: 0 8px;
}
.section-nav .section-nav-top {
.section-nav .section-nav-top,
.section-nav .section-nav-adv {
flex-shrink: 0;
justify-content: center;
width: 32px;
padding: 0;
}
.section-nav-top svg {
.section-nav-top svg,
.section-nav-adv svg {
width: 16px;
height: 16px;
}
.section-nav .section-nav-adv.active {
background: var(--accent-subtle);
border-color: var(--accent-glow);
color: var(--accent-hover);
}
.section-nav-tabs {
display: flex;
gap: var(--space-1);
@@ -3236,12 +3243,13 @@ button.pull-action {
box-sizing: border-box;
flex: 1;
margin-left: auto;
min-width: 140px;
min-width: 120px;
max-width: 240px;
}
/* Below this the longest tab set (Available / In other decks / Not owned /
Maybeboard, with counts) plus a minimum-width filter no longer fits. */
@container (max-width: 740px) {
Maybeboard, with counts) plus ↑, a minimum-width filter and the
advanced-filter button no longer fits. */
@container (max-width: 730px) {
.section-nav-tabs {
display: none;
}

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}