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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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