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:
@@ -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
249
src/cardFilter.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user