Shared card filter panel: News set browser gets every Library filter

Extract the filter fields and LibraryFilter-building state into
src/cardFilter.tsx (CardFilterPanel + useCardFilter) and use it in the
Library, Card Search and News set browser, replacing three diverging
copies. search_reference now honors in_deck/location too, so every
filter works in every tab.

Closes #13
This commit is contained in:
2026-10-23 22:46:45 -05:00
parent 89ef5d0848
commit e4956d8231
6 changed files with 332 additions and 415 deletions

View File

@@ -11,8 +11,29 @@ use crate::Catalog;
/// Append the card-data predicates shared by [`Catalog::search_library`] and
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/
/// `spares_only`, which read the library tables. Caller has already
/// written the `FROM ... WHERE <ownership predicate>` prefix.
/// `spares_only`, which read the library tables (see
/// [`push_collection_filters`]). Caller has already written the
/// `FROM ... WHERE <ownership predicate>` prefix.
/// Append the predicates that read the library tables: `in_deck`, `location`
/// and `spares_only`. Expects `library_cards` joined as `lc` (a LEFT JOIN is
/// fine — an unowned card has no location, so a location filter drops it).
fn push_collection_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) {
sql.push_str(" AND LOWER(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
}
fn push_card_filters(
sql: &mut String,
vals: &mut Vec<Value>,
@@ -223,21 +244,7 @@ impl Catalog {
);
let mut vals: Vec<Value> = Vec::new();
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla);
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) {
sql.push_str(" AND LOWER(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
push_collection_filters(&mut sql, &mut vals, filter);
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
@@ -253,8 +260,7 @@ impl Catalog {
}
/// Search the *whole* reference db (every downloaded card, owned or not)
/// by the same card-data criteria as [`Catalog::search_library`], minus
/// `in_deck`/`location` (library-specific — silently ignored if set).
/// by the same criteria as [`Catalog::search_library`], plus `not_owned`.
/// Results are still [`LibraryCard`]s so ownership/deck context is
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
@@ -290,9 +296,7 @@ impl Catalog {
if filter.not_owned {
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
push_collection_filters(&mut sql, &mut vals, filter);
sql.push_str(" ORDER BY ");
// Same reasoning as `NameIndex::search`: a card whose name *is* the query
@@ -841,6 +845,28 @@ mod tests {
assert_eq!(bolt.quantity_owned, 0);
}
#[test]
fn search_reference_honors_in_deck_and_location() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Burn", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
let in_deck = cat.search_reference(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()
}, 50).unwrap();
assert_eq!(names(&in_deck), vec!["Lightning Bolt"], "unowned deck cards count too");
let boxed = cat.search_reference(&LibraryFilter {
location: Some("box".into()),
..Default::default()
}, 50).unwrap();
assert_eq!(names(&boxed), vec!["Sol Ring"]);
}
#[test]
fn search_reference_not_owned_excludes_owned_cards() {
let fx = test_support::make();
@@ -970,23 +996,6 @@ mod tests {
assert_eq!(capped.len(), 2);
}
#[test]
fn search_reference_ignores_in_deck_and_location() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Some Deck", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
// Both library-only fields are set but should have no effect — the
// whole (unowned) fixture is still returned.
let got = cat.search_reference(&LibraryFilter {
in_deck: Some(deck.id),
location: Some("Box 1".into()),
..Default::default()
}, 50).unwrap();
assert_eq!(got.len(), 5);
}
/// `horizontal` is read by nearly every card query (see
/// `Catalog::horizontal_col`), so a reference.db built before that column
/// shipped must not break `search_library`/`search_reference`/

249
src/cardFilter.tsx Normal file
View File

@@ -0,0 +1,249 @@
// The card filter panel shared by the Library, Card Search and News set
// browser: one set of fields, one way of turning them into a `LibraryFilter`.
import { useCallback, useMemo, useState } from "react";
import { Deck, LibraryFacets, LibraryFilter } from "./api";
import {
CardCriteria,
CardCriteriaFields,
ColorToggles,
TagInput,
applyCardCriteria,
criteriaActive,
useCardCriteria,
} from "./components";
/** The panel's own fields; the rest live in `CardCriteria`. */
export interface CardFilterFields {
text: string;
commander: number;
colorsInclude: number;
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
colorlessIdentity: boolean;
colorlessColors: boolean;
cmcMin: string;
cmcMax: string;
types: string[];
subtypes: string[];
keywords: string[];
inDeck: number | "";
location: string;
oracleText: string;
}
const EMPTY_FIELDS: CardFilterFields = {
text: "",
commander: 0,
colorsInclude: 0,
colorlessIdentity: false,
colorlessColors: false,
cmcMin: "",
cmcMax: "",
types: [],
subtypes: [],
keywords: [],
inDeck: "",
location: "",
oracleText: "",
};
export const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
function toFilter(f: CardFilterFields, criteria: CardCriteria): LibraryFilter {
return {
text: f.text.trim() || null,
commander_identity: f.commander || null,
colors_include: f.colorsInclude || null,
colorless_identity: f.colorlessIdentity,
colorless_colors: f.colorlessColors,
cmc_min: f.cmcMin === "" ? null : Number(f.cmcMin),
cmc_max: f.cmcMax === "" ? null : Number(f.cmcMax),
types: f.types,
subtypes: f.subtypes,
keywords: f.keywords,
in_deck: f.inDeck === "" ? null : Number(f.inDeck),
location: f.location.trim() || null,
oracle_text: f.oracleText.trim() || null,
...applyCardCriteria(criteria),
};
}
function fieldsActive(f: CardFilterFields): boolean {
return (
f.text.trim() !== "" ||
f.commander !== 0 ||
f.colorsInclude !== 0 ||
f.colorlessIdentity ||
f.colorlessColors ||
f.cmcMin !== "" ||
f.cmcMax !== "" ||
f.types.length > 0 ||
f.subtypes.length > 0 ||
f.keywords.length > 0 ||
f.inDeck !== "" ||
f.location.trim() !== "" ||
f.oracleText.trim() !== ""
);
}
export interface CardFilterState {
fields: CardFilterFields;
patch: (p: Partial<CardFilterFields>) => void;
criteria: CardCriteria;
patchCriteria: (p: Partial<CardCriteria>) => void;
/** The filter to search with. Keeps its identity until a value actually
* changes, so it can go straight into an effect's dependencies. */
filter: LibraryFilter;
/** Whether anything is filtered on at all. */
active: boolean;
}
/** State for a {@link CardFilterPanel}. `criteriaKey` persists the criteria
* (rarity, legality, price, …) per view; the other fields reset per visit. */
export function useCardFilter(criteriaKey: string): CardFilterState {
const [fields, setFields] = useState<CardFilterFields>(EMPTY_FIELDS);
const [criteria, patchCriteria] = useCardCriteria(criteriaKey);
const patch = useCallback((p: Partial<CardFilterFields>) => setFields((f) => ({ ...f, ...p })), []);
// `useCardCriteria` hands back a fresh object every render, so key the
// memo on the filter's content rather than its inputs' identity.
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 };
}
/**
* The filter fields. "Name contains" always shows; on narrow screens the rest
* fold away behind a "More filters" toggle (see `.filters-more`'s CSS, which
* keeps them expanded above that breakpoint regardless).
*/
export function CardFilterPanel({
state,
facets,
decks,
hideNotOwned,
}: {
state: CardFilterState;
/** Suggestions for the type/subtype/keyword inputs. */
facets: LibraryFacets;
/** Choices for "In deck". */
decks: Deck[];
/** Omit the "Not owned" toggle — for the Library, which only shows owned cards. */
hideNotOwned?: boolean;
}) {
const { fields: f, patch } = state;
// Collapsed by default (relevant on narrow/mobile screens only).
const [expanded, setExpanded] = useState(false);
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" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles
value={f.commander}
onChange={(commander) => patch({ commander })}
colorless={f.colorlessIdentity}
onColorlessChange={(colorlessIdentity) => patch({ colorlessIdentity })}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={f.colorsInclude}
onChange={(colorsInclude) => patch({ colorsInclude })}
colorless={f.colorlessColors}
onColorlessChange={(colorlessColors) => patch({ colorlessColors })}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={f.cmcMin}
onChange={(e) => patch({ cmcMin: e.target.value })}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={f.cmcMax}
onChange={(e) => patch({ cmcMax: e.target.value })}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<TagInput
values={f.types}
onChange={(types) => patch({ types })}
suggestions={facets.types}
placeholder="Creature…"
/>
</div>
<div className="field">
<label>Subtype(s)</label>
<TagInput
values={f.subtypes}
onChange={(subtypes) => patch({ subtypes })}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<TagInput
values={f.keywords}
onChange={(keywords) => patch({ keywords })}
suggestions={facets.keywords}
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>
<div className="field">
<label>Location</label>
<input
value={f.location}
onChange={(e) => patch({ location: e.target.value })}
placeholder="e.g. Box 1"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={f.oracleText}
onChange={(e) => patch({ oracleText: e.target.value })}
placeholder="e.g. landfall"
/>
</div>
<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>
</>
);
}

View File

@@ -2181,16 +2181,12 @@ export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
showSpares,
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => void;
/** Omit the "Not owned" toggle — for the Library tab, which already shows
* nothing but owned cards, so the filter would just be a no-op there. */
hideNotOwned?: boolean;
/** Offer the "spare copies" toggle — a question about your collection, so
* it belongs in the Library rather than in a search of every card. */
showSpares?: boolean;
}) {
const toggleRarity = (key: string) =>
onChange({
@@ -2296,15 +2292,13 @@ export function CardCriteriaFields({
on={c.vanillaOnly}
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
/>
{showSpares && (
<IconToggle
icon={ICON_SPARE}
label="Only spare copies"
hint="Cards you own more copies of than your decks use — free for a trade or a new deck. Maybeboards don't count."
on={c.sparesOnly}
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
/>
)}
<IconToggle
icon={ICON_SPARE}
label="Only spare copies"
hint="Cards you own more copies of than your decks use — free for a trade or a new deck. Maybeboards don't count."
on={c.sparesOnly}
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
/>
{!hideNotOwned && (
<IconToggle
icon={ICON_PACKAGE}

View File

@@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, PreviewContext } from "../api";
import { api, Deck, LibraryCard, LibraryFacets, PreviewContext } from "../api";
import {
CardThumb,
CardTile,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
IconButton,
@@ -13,26 +12,20 @@ import {
SortBySelect,
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
criteriaActive,
groupCards,
sortCards,
useCardCriteria,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
// Server-side cap on results — this browses the whole ~30k-card reference
// db, not just what's owned, so an unbounded/loose filter needs a limit.
const CAP = 300;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/**
* Browse the whole downloaded card universe (every card in reference.db,
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
@@ -47,23 +40,8 @@ export function CardSearch({
}: {
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
}) {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
const [colorlessIdentity, setColorlessIdentity] = useState(false);
const [colorlessColors, setColorlessColors] = useState(false);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
const [filtersExpanded, setFiltersExpanded] = useState(false);
const filterState = useCardFilter("cardsearch-criteria");
const { filter, active: hasActiveFilter } = filterState;
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
@@ -95,40 +73,12 @@ export function CardSearch({
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
}, []);
const hasActiveFilter =
text.trim() !== "" ||
commander !== 0 ||
colorsInclude !== 0 ||
colorlessIdentity ||
colorlessColors ||
cmcMin !== "" ||
cmcMax !== "" ||
types.length > 0 ||
subtypes.length > 0 ||
keywords.length > 0 ||
oracleText.trim() !== "" ||
criteriaActive(criteria);
useEffect(() => {
if (!hasActiveFilter) {
setRows([]);
setError(null);
return;
}
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
colorless_identity: colorlessIdentity,
colorless_colors: colorlessColors,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
subtypes,
keywords,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api
.searchReference(filter, CAP)
@@ -142,22 +92,7 @@ export function CardSearch({
});
}, 120);
return () => clearTimeout(t);
}, [
hasActiveFilter,
text,
commander,
colorsInclude,
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
subtypes,
keywords,
oracleText,
criteria,
refreshKey,
]);
}, [hasActiveFilter, filter, refreshKey]);
// Drop selections that fall out of the result set when the filter changes,
// so a hidden card can't be silently added to a deck later.
@@ -467,91 +402,7 @@ export function CardSearch({
)}
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles
value={commander}
onChange={setCommander}
colorless={colorlessIdentity}
onColorlessChange={setColorlessIdentity}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={colorsInclude}
onChange={setColorsInclude}
colorless={colorlessColors}
onColorlessChange={setColorlessColors}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
</div>
<div className="field">
<label>Subtype(s)</label>
<TagInput
values={subtypes}
onChange={setSubtypes}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<TagInput
values={keywords}
onChange={setKeywords}
suggestions={facets.keywords}
placeholder="Flying…"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
</div>
</div>
<button
className="filters-toggle"
onClick={() => setFiltersExpanded((v) => !v)}
>
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
<CardFilterPanel state={filterState} facets={facets} decks={decks} />
<div className="workspace-bar">
{rows.length > 0 && (

View File

@@ -6,7 +6,6 @@ import {
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
@@ -15,7 +14,6 @@ import {
CardTile,
DialogFrame,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
@@ -26,26 +24,21 @@ import {
SortBySelect,
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
groupCards,
sortCards,
useCardCriteria,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
// How long a card that's been decremented to 0 lingers (dimmed, still
// adjustable) before actually dropping out of the library view — gives the
// user a window to undo an accidental removal by clicking + again.
const REMOVAL_GRACE_MS = 5000;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/**
* Browse & filter the owned library by name and card criteria.
*
@@ -59,25 +52,8 @@ export function LibraryBrowser({
}: {
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
}) {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
const [colorlessIdentity, setColorlessIdentity] = useState(false);
const [colorlessColors, setColorlessColors] = useState(false);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("library-criteria");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
const [filtersExpanded, setFiltersExpanded] = useState(false);
const filterState = useCardFilter("library-criteria");
const { filter, criteria } = filterState;
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
@@ -142,43 +118,11 @@ export function LibraryBrowser({
}, [selected, rows]);
useEffect(() => {
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
colorless_identity: colorlessIdentity,
colorless_colors: colorlessColors,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
subtypes,
keywords,
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
}, 120);
return () => clearTimeout(t);
}, [
text,
commander,
colorsInclude,
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
subtypes,
keywords,
inDeck,
location,
oracleText,
criteria,
refreshKey,
]);
}, [filter, refreshKey]);
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
@@ -661,110 +605,7 @@ export function LibraryBrowser({
)}
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles
value={commander}
onChange={setCommander}
colorless={colorlessIdentity}
onColorlessChange={setColorlessIdentity}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={colorsInclude}
onChange={setColorsInclude}
colorless={colorlessColors}
onColorlessChange={setColorlessColors}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
</div>
<div className="field">
<label>Subtype(s)</label>
<TagInput
values={subtypes}
onChange={setSubtypes}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<TagInput
values={keywords}
onChange={setKeywords}
suggestions={facets.keywords}
placeholder="Flying…"
/>
</div>
<div className="field">
<label>In deck</label>
<select value={inDeck} onChange={(e) => setInDeck(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
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Box 1"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned showSpares />
</div>
</div>
<button
className="filters-toggle"
onClick={() => setFiltersExpanded((v) => !v)}
>
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
<CardFilterPanel state={filterState} facets={facets} decks={decks} hideNotOwned />
<div className="workspace-bar">
<label className="workspace-bar-check" title="Select all filtered cards">

View File

@@ -1,16 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api";
import {
CardCriteriaFields,
CardTile,
ColorToggles,
QtyStepper,
TileColumnsControl,
applyCardCriteria,
useCardCriteria,
useTileColumns,
} from "../components";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, News, SetCard, SetInfo } from "../api";
import { CardTile, QtyStepper, TileColumnsControl, useTileColumns } from "../components";
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
import { useBackButton } from "../backButton";
/** Sets whose release date is still ahead of us, newest-announced first. */
@@ -81,10 +73,17 @@ export function NewsView() {
// The same criteria the Library and Card Search panels use — the set browser
// runs through search_reference rather than its own path, so every filter
// works here identically instead of being reimplemented.
const [setText, setSetText] = useState("");
const [setColors, setSetColors] = useState(0);
const [setColorless, setSetColorless] = useState(false);
const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria");
const setFilterState = useCardFilter("news-set-criteria");
const setFilter = setFilterState.filter;
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
// Only the filter panel needs these, and it only shows inside a set.
useEffect(() => {
if (!openSet) return;
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
api.listDecks().then(setDecks).catch(() => setDecks([]));
}, [openSet]);
const load = useCallback(async (refresh: boolean) => {
setBusy(true);
@@ -155,13 +154,7 @@ export function NewsView() {
// filter panels so typing doesn't fire a query per keystroke.
useEffect(() => {
if (!openSet || source !== "local") return;
const filter: LibraryFilter = {
set_code: openSet.code,
text: setText.trim() || null,
colors_include: setColors || null,
colorless_colors: setColorless,
...applyCardCriteria(setCriteria),
};
const filter: LibraryFilter = { ...setFilter, set_code: openSet.code };
let cancelled = false;
const t = setTimeout(() => {
api
@@ -181,7 +174,7 @@ export function NewsView() {
cancelled = true;
clearTimeout(t);
};
}, [openSet, source, setText, setColors, setColorless, setCriteria]);
}, [openSet, source, setFilter]);
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
@@ -214,27 +207,7 @@ export function NewsView() {
worse than not showing one. */}
{source === "local" && (
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input
value={setText}
onChange={(e) => setSetText(e.target.value)}
placeholder="e.g. bolt"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={setColors}
onChange={setSetColors}
colorless={setColorless}
onColorlessChange={setSetColorless}
colorlessTitle="Colorless cards only"
/>
</div>
<CardCriteriaFields value={setCriteria} onChange={patchSetCriteria} />
</div>
<CardFilterPanel state={setFilterState} facets={facets} decks={decks} />
</div>
)}