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
|
/// Append the card-data predicates shared by [`Catalog::search_library`] and
|
||||||
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/
|
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/
|
||||||
/// `spares_only`, which read the library tables. Caller has already
|
/// `spares_only`, which read the library tables (see
|
||||||
/// written the `FROM ... WHERE <ownership predicate>` prefix.
|
/// [`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(
|
fn push_card_filters(
|
||||||
sql: &mut String,
|
sql: &mut String,
|
||||||
vals: &mut Vec<Value>,
|
vals: &mut Vec<Value>,
|
||||||
@@ -223,21 +244,7 @@ impl Catalog {
|
|||||||
);
|
);
|
||||||
let mut vals: Vec<Value> = Vec::new();
|
let mut vals: Vec<Value> = Vec::new();
|
||||||
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla);
|
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla);
|
||||||
|
push_collection_filters(&mut sql, &mut vals, filter);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
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)
|
/// Search the *whole* reference db (every downloaded card, owned or not)
|
||||||
/// by the same card-data criteria as [`Catalog::search_library`], minus
|
/// by the same criteria as [`Catalog::search_library`], plus `not_owned`.
|
||||||
/// `in_deck`/`location` (library-specific — silently ignored if set).
|
|
||||||
/// Results are still [`LibraryCard`]s so ownership/deck context is
|
/// Results are still [`LibraryCard`]s so ownership/deck context is
|
||||||
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
|
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
|
||||||
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
|
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
|
||||||
@@ -290,9 +296,7 @@ impl Catalog {
|
|||||||
if filter.not_owned {
|
if filter.not_owned {
|
||||||
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
|
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
|
||||||
}
|
}
|
||||||
if filter.spares_only {
|
push_collection_filters(&mut sql, &mut vals, filter);
|
||||||
sql.push_str(SPARES_PREDICATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
sql.push_str(" ORDER BY ");
|
sql.push_str(" ORDER BY ");
|
||||||
// Same reasoning as `NameIndex::search`: a card whose name *is* the query
|
// Same reasoning as `NameIndex::search`: a card whose name *is* the query
|
||||||
@@ -841,6 +845,28 @@ mod tests {
|
|||||||
assert_eq!(bolt.quantity_owned, 0);
|
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]
|
#[test]
|
||||||
fn search_reference_not_owned_excludes_owned_cards() {
|
fn search_reference_not_owned_excludes_owned_cards() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
@@ -970,23 +996,6 @@ mod tests {
|
|||||||
assert_eq!(capped.len(), 2);
|
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
|
/// `horizontal` is read by nearly every card query (see
|
||||||
/// `Catalog::horizontal_col`), so a reference.db built before that column
|
/// `Catalog::horizontal_col`), so a reference.db built before that column
|
||||||
/// shipped must not break `search_library`/`search_reference`/
|
/// 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,
|
value: c,
|
||||||
onChange,
|
onChange,
|
||||||
hideNotOwned,
|
hideNotOwned,
|
||||||
showSpares,
|
|
||||||
}: {
|
}: {
|
||||||
value: CardCriteria;
|
value: CardCriteria;
|
||||||
onChange: (patch: Partial<CardCriteria>) => void;
|
onChange: (patch: Partial<CardCriteria>) => void;
|
||||||
/** Omit the "Not owned" toggle — for the Library tab, which already shows
|
/** 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. */
|
* nothing but owned cards, so the filter would just be a no-op there. */
|
||||||
hideNotOwned?: boolean;
|
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) =>
|
const toggleRarity = (key: string) =>
|
||||||
onChange({
|
onChange({
|
||||||
@@ -2296,15 +2292,13 @@ export function CardCriteriaFields({
|
|||||||
on={c.vanillaOnly}
|
on={c.vanillaOnly}
|
||||||
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
||||||
/>
|
/>
|
||||||
{showSpares && (
|
<IconToggle
|
||||||
<IconToggle
|
icon={ICON_SPARE}
|
||||||
icon={ICON_SPARE}
|
label="Only spare copies"
|
||||||
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."
|
||||||
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}
|
||||||
on={c.sparesOnly}
|
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
|
||||||
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hideNotOwned && (
|
{!hideNotOwned && (
|
||||||
<IconToggle
|
<IconToggle
|
||||||
icon={ICON_PACKAGE}
|
icon={ICON_PACKAGE}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, PreviewContext } from "../api";
|
import { api, Deck, LibraryCard, LibraryFacets, PreviewContext } from "../api";
|
||||||
import {
|
import {
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
ColorToggles,
|
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
GroupMode,
|
GroupMode,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -13,26 +12,20 @@ import {
|
|||||||
SortBySelect,
|
SortBySelect,
|
||||||
SortDir,
|
SortDir,
|
||||||
SortMode,
|
SortMode,
|
||||||
TagInput,
|
|
||||||
TileColumnsControl,
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
CardCriteriaFields,
|
|
||||||
applyCardCriteria,
|
|
||||||
criteriaActive,
|
|
||||||
groupCards,
|
groupCards,
|
||||||
sortCards,
|
sortCards,
|
||||||
useCardCriteria,
|
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
useTileColumns,
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
||||||
|
|
||||||
// Server-side cap on results — this browses the whole ~30k-card reference
|
// 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.
|
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
||||||
const CAP = 300;
|
const CAP = 300;
|
||||||
|
|
||||||
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse the whole downloaded card universe (every card in reference.db,
|
* Browse the whole downloaded card universe (every card in reference.db,
|
||||||
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
* 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;
|
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState("");
|
const filterState = useCardFilter("cardsearch-criteria");
|
||||||
const [commander, setCommander] = useState(0);
|
const { filter, active: hasActiveFilter } = filterState;
|
||||||
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 [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||||||
|
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
@@ -95,40 +73,12 @@ export function CardSearch({
|
|||||||
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hasActiveFilter) {
|
if (!hasActiveFilter) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
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(() => {
|
const t = setTimeout(() => {
|
||||||
api
|
api
|
||||||
.searchReference(filter, CAP)
|
.searchReference(filter, CAP)
|
||||||
@@ -142,22 +92,7 @@ export function CardSearch({
|
|||||||
});
|
});
|
||||||
}, 120);
|
}, 120);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [
|
}, [hasActiveFilter, filter, refreshKey]);
|
||||||
hasActiveFilter,
|
|
||||||
text,
|
|
||||||
commander,
|
|
||||||
colorsInclude,
|
|
||||||
colorlessIdentity,
|
|
||||||
colorlessColors,
|
|
||||||
cmcMin,
|
|
||||||
cmcMax,
|
|
||||||
types,
|
|
||||||
subtypes,
|
|
||||||
keywords,
|
|
||||||
oracleText,
|
|
||||||
criteria,
|
|
||||||
refreshKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Drop selections that fall out of the result set when the filter changes,
|
// 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.
|
// 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="panel workspace">
|
||||||
<div className="filters">
|
<CardFilterPanel state={filterState} facets={facets} decks={decks} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="workspace-bar">
|
<div className="workspace-bar">
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Deck,
|
Deck,
|
||||||
LibraryCard,
|
LibraryCard,
|
||||||
LibraryFacets,
|
LibraryFacets,
|
||||||
LibraryFilter,
|
|
||||||
LibraryImportResult,
|
LibraryImportResult,
|
||||||
PreviewContext,
|
PreviewContext,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
CardTile,
|
CardTile,
|
||||||
DialogFrame,
|
DialogFrame,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
ColorToggles,
|
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
GroupMode,
|
GroupMode,
|
||||||
InfoDialog,
|
InfoDialog,
|
||||||
@@ -26,26 +24,21 @@ import {
|
|||||||
SortBySelect,
|
SortBySelect,
|
||||||
SortDir,
|
SortDir,
|
||||||
SortMode,
|
SortMode,
|
||||||
TagInput,
|
|
||||||
TileColumnsControl,
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
CardCriteriaFields,
|
|
||||||
applyCardCriteria,
|
|
||||||
groupCards,
|
groupCards,
|
||||||
sortCards,
|
sortCards,
|
||||||
useCardCriteria,
|
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
useTileColumns,
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
||||||
|
|
||||||
// How long a card that's been decremented to 0 lingers (dimmed, still
|
// How long a card that's been decremented to 0 lingers (dimmed, still
|
||||||
// adjustable) before actually dropping out of the library view — gives the
|
// adjustable) before actually dropping out of the library view — gives the
|
||||||
// user a window to undo an accidental removal by clicking + again.
|
// user a window to undo an accidental removal by clicking + again.
|
||||||
const REMOVAL_GRACE_MS = 5000;
|
const REMOVAL_GRACE_MS = 5000;
|
||||||
|
|
||||||
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse & filter the owned library by name and card criteria.
|
* 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;
|
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState("");
|
const filterState = useCardFilter("library-criteria");
|
||||||
const [commander, setCommander] = useState(0);
|
const { filter, criteria } = filterState;
|
||||||
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 [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||||||
|
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
@@ -142,43 +118,11 @@ export function LibraryBrowser({
|
|||||||
}, [selected, rows]);
|
}, [selected, rows]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const t = setTimeout(() => {
|
||||||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||||||
}, 120);
|
}, 120);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [
|
}, [filter, refreshKey]);
|
||||||
text,
|
|
||||||
commander,
|
|
||||||
colorsInclude,
|
|
||||||
colorlessIdentity,
|
|
||||||
colorlessColors,
|
|
||||||
cmcMin,
|
|
||||||
cmcMax,
|
|
||||||
types,
|
|
||||||
subtypes,
|
|
||||||
keywords,
|
|
||||||
inDeck,
|
|
||||||
location,
|
|
||||||
oracleText,
|
|
||||||
criteria,
|
|
||||||
refreshKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function adjust(oracleId: string, delta: number) {
|
async function adjust(oracleId: string, delta: number) {
|
||||||
const qty = await api.addOwned(oracleId, delta);
|
const qty = await api.addOwned(oracleId, delta);
|
||||||
@@ -661,110 +605,7 @@ export function LibraryBrowser({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="panel workspace">
|
<div className="panel workspace">
|
||||||
<div className="filters">
|
<CardFilterPanel state={filterState} facets={facets} decks={decks} hideNotOwned />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="workspace-bar">
|
<div className="workspace-bar">
|
||||||
<label className="workspace-bar-check" title="Select all filtered cards">
|
<label className="workspace-bar-check" title="Select all filtered cards">
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api";
|
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, News, SetCard, SetInfo } from "../api";
|
||||||
import {
|
import { CardTile, QtyStepper, TileColumnsControl, useTileColumns } from "../components";
|
||||||
CardCriteriaFields,
|
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
||||||
CardTile,
|
|
||||||
ColorToggles,
|
|
||||||
QtyStepper,
|
|
||||||
TileColumnsControl,
|
|
||||||
applyCardCriteria,
|
|
||||||
useCardCriteria,
|
|
||||||
useTileColumns,
|
|
||||||
} from "../components";
|
|
||||||
import { useBackButton } from "../backButton";
|
import { useBackButton } from "../backButton";
|
||||||
|
|
||||||
/** Sets whose release date is still ahead of us, newest-announced first. */
|
/** 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
|
// 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
|
// runs through search_reference rather than its own path, so every filter
|
||||||
// works here identically instead of being reimplemented.
|
// works here identically instead of being reimplemented.
|
||||||
const [setText, setSetText] = useState("");
|
const setFilterState = useCardFilter("news-set-criteria");
|
||||||
const [setColors, setSetColors] = useState(0);
|
const setFilter = setFilterState.filter;
|
||||||
const [setColorless, setSetColorless] = useState(false);
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||||||
const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria");
|
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) => {
|
const load = useCallback(async (refresh: boolean) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -155,13 +154,7 @@ export function NewsView() {
|
|||||||
// filter panels so typing doesn't fire a query per keystroke.
|
// filter panels so typing doesn't fire a query per keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openSet || source !== "local") return;
|
if (!openSet || source !== "local") return;
|
||||||
const filter: LibraryFilter = {
|
const filter: LibraryFilter = { ...setFilter, set_code: openSet.code };
|
||||||
set_code: openSet.code,
|
|
||||||
text: setText.trim() || null,
|
|
||||||
colors_include: setColors || null,
|
|
||||||
colorless_colors: setColorless,
|
|
||||||
...applyCardCriteria(setCriteria),
|
|
||||||
};
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
api
|
api
|
||||||
@@ -181,7 +174,7 @@ export function NewsView() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
};
|
};
|
||||||
}, [openSet, source, setText, setColors, setColorless, setCriteria]);
|
}, [openSet, source, setFilter]);
|
||||||
|
|
||||||
async function adjust(oracleId: string, delta: number) {
|
async function adjust(oracleId: string, delta: number) {
|
||||||
const qty = await api.addOwned(oracleId, delta);
|
const qty = await api.addOwned(oracleId, delta);
|
||||||
@@ -214,27 +207,7 @@ export function NewsView() {
|
|||||||
worse than not showing one. */}
|
worse than not showing one. */}
|
||||||
{source === "local" && (
|
{source === "local" && (
|
||||||
<div className="panel workspace">
|
<div className="panel workspace">
|
||||||
<div className="filters">
|
<CardFilterPanel state={setFilterState} facets={facets} decks={decks} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user