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:
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