From 6e524ccefa9050c4202f45cb9d0680918f0a95ee Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Mon, 26 Oct 2026 21:31:28 -0500 Subject: [PATCH] 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 --- src/cardFilter.tsx | 69 ++++++++++++++++++--------- src/components.tsx | 2 + src/styles.css | 20 +++++--- src/views/DeckView.tsx | 103 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 29 deletions(-) diff --git a/src/cardFilter.tsx b/src/cardFilter.tsx index 3d12dde..30f4190 100644 --- a/src/cardFilter.tsx +++ b/src/cardFilter.tsx @@ -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 ( <>
-
- - patch({ text: e.target.value })} placeholder="e.g. bolt" /> -
-
+ {!hideName && ( +
+ + patch({ text: e.target.value })} placeholder="e.g. bolt" /> +
+ )} +
-
- - -
+ {!hideInDeck && ( +
+ + +
+ )}
- + {!alwaysExpanded && ( + + )} ); } diff --git a/src/components.tsx b/src/components.tsx index 8f1c10d..35fbaf9 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -119,6 +119,7 @@ const ICON_ARROW_UP = icon( , ); +const ICON_FILTER = icon(); const ICON_PLUS = icon( <> @@ -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, diff --git a/src/styles.css b/src/styles.css index a1fd0b8..6238cba 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 3c33043..fc5efe8 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -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(null); @@ -305,10 +312,56 @@ function SectionNav({ onChange={(e) => onFilterChange(e.target.value)} placeholder="Filter cards in deck…" /> +
); } +/** 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(NO_FACETS); + useEffect(() => { + api.getReferenceFacets().then(setFacets).catch(() => {}); + }, []); + return ( + + + {error &&

{error}

} +
+ + {matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`} + + + +
+
+ ); +} + /** * 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("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 | null>(null); + const [advError, setAdvError] = useState(null); const [libQuery, setLibQuery] = useState(""); const [libResults, setLibResults] = useState([]); @@ -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)} + {advOpen && ( + setAdvOpen(false)} + /> + )} {handOpen && (