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,
|
CardCriteria,
|
||||||
CardCriteriaFields,
|
CardCriteriaFields,
|
||||||
ColorToggles,
|
ColorToggles,
|
||||||
|
EMPTY_CRITERIA,
|
||||||
TagInput,
|
TagInput,
|
||||||
applyCardCriteria,
|
applyCardCriteria,
|
||||||
criteriaActive,
|
criteriaActive,
|
||||||
@@ -95,6 +96,8 @@ export interface CardFilterState {
|
|||||||
filter: LibraryFilter;
|
filter: LibraryFilter;
|
||||||
/** Whether anything is filtered on at all. */
|
/** Whether anything is filtered on at all. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Clear every field and criterion. */
|
||||||
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** State for a {@link CardFilterPanel}. `criteriaKey` persists the criteria
|
/** 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 json = JSON.stringify(toFilter(fields, criteria));
|
||||||
const filter = useMemo(() => JSON.parse(json) as LibraryFilter, [json]);
|
const filter = useMemo(() => JSON.parse(json) as LibraryFilter, [json]);
|
||||||
const active = fieldsActive(fields) || criteriaActive(criteria);
|
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,
|
facets,
|
||||||
decks,
|
decks,
|
||||||
hideNotOwned,
|
hideNotOwned,
|
||||||
|
hideName,
|
||||||
|
hideInDeck,
|
||||||
|
alwaysExpanded,
|
||||||
}: {
|
}: {
|
||||||
state: CardFilterState;
|
state: CardFilterState;
|
||||||
/** Suggestions for the type/subtype/keyword inputs. */
|
/** Suggestions for the type/subtype/keyword inputs. */
|
||||||
@@ -129,6 +139,13 @@ export function CardFilterPanel({
|
|||||||
decks: Deck[];
|
decks: Deck[];
|
||||||
/** Omit the "Not owned" toggle — for the Library, which only shows owned cards. */
|
/** Omit the "Not owned" toggle — for the Library, which only shows owned cards. */
|
||||||
hideNotOwned?: boolean;
|
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;
|
const { fields: f, patch } = state;
|
||||||
// Collapsed by default (relevant on narrow/mobile screens only).
|
// Collapsed by default (relevant on narrow/mobile screens only).
|
||||||
@@ -136,11 +153,13 @@ export function CardFilterPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<div className="field">
|
{!hideName && (
|
||||||
<label>Name contains</label>
|
<div className="field">
|
||||||
<input value={f.text} onChange={(e) => patch({ text: e.target.value })} placeholder="e.g. bolt" />
|
<label>Name contains</label>
|
||||||
</div>
|
<input value={f.text} onChange={(e) => patch({ text: e.target.value })} placeholder="e.g. bolt" />
|
||||||
<div className={`filters-more ${expanded ? "expanded" : ""}`}>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`filters-more ${expanded || alwaysExpanded ? "expanded" : ""}`}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Playable in commander (color identity ⊆)</label>
|
<label>Playable in commander (color identity ⊆)</label>
|
||||||
<ColorToggles
|
<ColorToggles
|
||||||
@@ -208,20 +227,22 @@ export function CardFilterPanel({
|
|||||||
placeholder="Flying…"
|
placeholder="Flying…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
{!hideInDeck && (
|
||||||
<label>In deck</label>
|
<div className="field">
|
||||||
<select
|
<label>In deck</label>
|
||||||
value={f.inDeck}
|
<select
|
||||||
onChange={(e) => patch({ inDeck: e.target.value === "" ? "" : Number(e.target.value) })}
|
value={f.inDeck}
|
||||||
>
|
onChange={(e) => patch({ inDeck: e.target.value === "" ? "" : Number(e.target.value) })}
|
||||||
<option value="">Any</option>
|
>
|
||||||
{decks.map((d) => (
|
<option value="">Any</option>
|
||||||
<option key={d.id} value={d.id}>
|
{decks.map((d) => (
|
||||||
{d.name}
|
<option key={d.id} value={d.id}>
|
||||||
</option>
|
{d.name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<input
|
<input
|
||||||
@@ -241,9 +262,11 @@ export function CardFilterPanel({
|
|||||||
<CardCriteriaFields value={state.criteria} onChange={state.patchCriteria} hideNotOwned={hideNotOwned} />
|
<CardCriteriaFields value={state.criteria} onChange={state.patchCriteria} hideNotOwned={hideNotOwned} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="filters-toggle" onClick={() => setExpanded((v) => !v)}>
|
{!alwaysExpanded && (
|
||||||
{expanded ? "▴ Fewer filters" : "▾ More filters"}
|
<button className="filters-toggle" onClick={() => setExpanded((v) => !v)}>
|
||||||
</button>
|
{expanded ? "▴ Fewer filters" : "▾ More filters"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const ICON_ARROW_UP = icon(
|
|||||||
<polyline points="5 12 12 5 19 12" />
|
<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(
|
const ICON_PLUS = icon(
|
||||||
<>
|
<>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
@@ -201,6 +202,7 @@ export const Icons = {
|
|||||||
check: ICON_CHECK,
|
check: ICON_CHECK,
|
||||||
arrowLeft: ICON_ARROW_LEFT,
|
arrowLeft: ICON_ARROW_LEFT,
|
||||||
arrowUp: ICON_ARROW_UP,
|
arrowUp: ICON_ARROW_UP,
|
||||||
|
filter: ICON_FILTER,
|
||||||
plus: ICON_PLUS,
|
plus: ICON_PLUS,
|
||||||
more: ICON_MORE,
|
more: ICON_MORE,
|
||||||
image: ICON_IMAGE,
|
image: ICON_IMAGE,
|
||||||
|
|||||||
@@ -3178,18 +3178,25 @@ button.pull-action {
|
|||||||
.section-nav-tabs button,
|
.section-nav-tabs button,
|
||||||
.section-nav-jump > button {
|
.section-nav-jump > button {
|
||||||
height: 32px;
|
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;
|
flex-shrink: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.section-nav-top svg {
|
.section-nav-top svg,
|
||||||
|
.section-nav-adv svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 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 {
|
.section-nav-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -3236,12 +3243,13 @@ button.pull-action {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
min-width: 140px;
|
min-width: 120px;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
/* Below this the longest tab set (Available / In other decks / Not owned /
|
/* Below this the longest tab set (Available / In other decks / Not owned /
|
||||||
Maybeboard, with counts) plus a minimum-width filter no longer fits. */
|
Maybeboard, with counts) plus ↑, a minimum-width filter and the
|
||||||
@container (max-width: 740px) {
|
advanced-filter button no longer fits. */
|
||||||
|
@container (max-width: 730px) {
|
||||||
.section-nav-tabs {
|
.section-nav-tabs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
DeckStatus,
|
DeckStatus,
|
||||||
isPhysical,
|
isPhysical,
|
||||||
libraryKey,
|
libraryKey,
|
||||||
|
LibraryFacets,
|
||||||
PreviewContext,
|
PreviewContext,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { CardFilterPanel, CardFilterState, NO_FACETS, useCardFilter } from "../cardFilter";
|
||||||
import {
|
import {
|
||||||
CardSearchBox,
|
CardSearchBox,
|
||||||
CardThumb,
|
CardThumb,
|
||||||
@@ -231,6 +233,8 @@ function SectionNav({
|
|||||||
onTop,
|
onTop,
|
||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onAdvanced,
|
||||||
|
advancedActive,
|
||||||
}: {
|
}: {
|
||||||
sections: NavSection[];
|
sections: NavSection[];
|
||||||
active: string;
|
active: string;
|
||||||
@@ -238,6 +242,9 @@ function SectionNav({
|
|||||||
onTop: () => void;
|
onTop: () => void;
|
||||||
filter: string;
|
filter: string;
|
||||||
onFilterChange: (q: string) => void;
|
onFilterChange: (q: string) => void;
|
||||||
|
onAdvanced: () => void;
|
||||||
|
/** An advanced filter is narrowing the cards. */
|
||||||
|
advancedActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const jumpRef = useRef<HTMLDivElement>(null);
|
const jumpRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -305,10 +312,56 @@ function SectionNav({
|
|||||||
onChange={(e) => onFilterChange(e.target.value)}
|
onChange={(e) => onFilterChange(e.target.value)}
|
||||||
placeholder="Filter cards in deck…"
|
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>
|
</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
|
* 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
|
* 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 [sortDir, setSortDir] = usePersistedState<SortDir>("deck-sort-dir", "asc");
|
||||||
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||||
const [filterQuery, setFilterQuery] = useState("");
|
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 [libQuery, setLibQuery] = useState("");
|
||||||
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
||||||
@@ -391,6 +450,38 @@ function DeckBuilder({
|
|||||||
loadContents();
|
loadContents();
|
||||||
}, [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
|
// Refetch on an external refresh signal without resetting the UI state
|
||||||
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
// 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
|
// 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
|
// filter only, distinct from the CardSearchBox boxes below (which search
|
||||||
// the library/reference db to find NEW cards to add).
|
// the library/reference db to find NEW cards to add).
|
||||||
function matchesFilter(e: DeckEntry): boolean {
|
function matchesFilter(e: DeckEntry): boolean {
|
||||||
|
if (advMatches && !advMatches.has(e.oracle_id)) return false;
|
||||||
const q = fold(filterQuery.trim());
|
const q = fold(filterQuery.trim());
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
||||||
@@ -1343,6 +1435,8 @@ function DeckBuilder({
|
|||||||
onTop={jumpToTop}
|
onTop={jumpToTop}
|
||||||
filter={filterQuery}
|
filter={filterQuery}
|
||||||
onFilterChange={setFilterQuery}
|
onFilterChange={setFilterQuery}
|
||||||
|
onAdvanced={() => setAdvOpen(true)}
|
||||||
|
advancedActive={advFilter.active}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedCards.size > 0 && (
|
{selectedCards.size > 0 && (
|
||||||
@@ -1512,6 +1606,15 @@ function DeckBuilder({
|
|||||||
{cardsGroup(maybeboard)}
|
{cardsGroup(maybeboard)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{advOpen && (
|
||||||
|
<AdvancedFilterDialog
|
||||||
|
state={advFilter}
|
||||||
|
matched={advMatches?.size ?? null}
|
||||||
|
total={contents.length}
|
||||||
|
error={advError}
|
||||||
|
onClose={() => setAdvOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{handOpen && (
|
{handOpen && (
|
||||||
<SampleHandModal
|
<SampleHandModal
|
||||||
hand={hand}
|
hand={hand}
|
||||||
|
|||||||
Reference in New Issue
Block a user