diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index f57c523..7dc8162 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -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 ` prefix. +/// `spares_only`, which read the library tables (see +/// [`push_collection_filters`]). Caller has already written the +/// `FROM ... WHERE ` 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, 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, @@ -223,21 +244,7 @@ impl Catalog { ); let mut vals: Vec = 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`/ diff --git a/src/cardFilter.tsx b/src/cardFilter.tsx new file mode 100644 index 0000000..3d12dde --- /dev/null +++ b/src/cardFilter.tsx @@ -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) => void; + criteria: CardCriteria; + patchCriteria: (p: Partial) => 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(EMPTY_FIELDS); + const [criteria, patchCriteria] = useCardCriteria(criteriaKey); + const patch = useCallback((p: Partial) => 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 ( + <> +
+
+ + patch({ text: e.target.value })} placeholder="e.g. bolt" /> +
+
+
+ + patch({ commander })} + colorless={f.colorlessIdentity} + onColorlessChange={(colorlessIdentity) => patch({ colorlessIdentity })} + colorlessTitle="Colorless commander — only cards with no color identity" + /> +
+
+ + patch({ colorsInclude })} + colorless={f.colorlessColors} + onColorlessChange={(colorlessColors) => patch({ colorlessColors })} + colorlessTitle="Colorless cards only" + /> +
+
+ +
+ patch({ cmcMin: e.target.value })} + /> + – + patch({ cmcMax: e.target.value })} + /> +
+
+
+ + patch({ types })} + suggestions={facets.types} + placeholder="Creature…" + /> +
+
+ + patch({ subtypes })} + suggestions={facets.subtypes} + placeholder="Elf…" + /> +
+
+ + patch({ keywords })} + suggestions={facets.keywords} + placeholder="Flying…" + /> +
+
+ + +
+
+ + patch({ location: e.target.value })} + placeholder="e.g. Box 1" + /> +
+
+ + patch({ oracleText: e.target.value })} + placeholder="e.g. landfall" + /> +
+ +
+
+ + + ); +} diff --git a/src/components.tsx b/src/components.tsx index e444975..054621d 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -2181,16 +2181,12 @@ export function CardCriteriaFields({ value: c, onChange, hideNotOwned, - showSpares, }: { value: CardCriteria; onChange: (patch: Partial) => 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 && ( - onChange({ sparesOnly: !c.sparesOnly })} - /> - )} + onChange({ sparesOnly: !c.sparesOnly })} + /> {!hideNotOwned && ( void | Promise) => 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([]); - const [subtypes, setSubtypes] = useState([]); - const [keywords, setKeywords] = useState([]); - 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(NO_FACETS); const [decks, setDecks] = useState([]); @@ -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({ )}
-
-
- - setText(e.target.value)} placeholder="e.g. bolt" /> -
-
-
- - -
-
- - -
-
- -
- setCmcMin(e.target.value)} - /> - – - setCmcMax(e.target.value)} - /> -
-
-
- - -
-
- - -
-
- - -
-
- - setOracleText(e.target.value)} - placeholder="e.g. landfall" - /> -
- -
-
- +
{rows.length > 0 && ( diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 315bccd..91b35d4 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -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; }) { - 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([]); - const [subtypes, setSubtypes] = useState([]); - const [keywords, setKeywords] = useState([]); - const [inDeck, setInDeck] = useState(""); - 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(NO_FACETS); const [decks, setDecks] = useState([]); @@ -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({ )}
-
-
- - setText(e.target.value)} placeholder="e.g. bolt" /> -
-
-
- - -
-
- - -
-
- -
- setCmcMin(e.target.value)} - /> - – - setCmcMax(e.target.value)} - /> -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - setLocation(e.target.value)} - placeholder="e.g. Box 1" - /> -
-
- - setOracleText(e.target.value)} - placeholder="e.g. landfall" - /> -
- -
-
- +