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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user