Add a "not owned" quick filter and horizontal tile view to Card Search
"Not owned" moves into the shared Quick filters icon-toggle row (hidden on the Library tab, where it'd always be a no-op since that view is owned-only already) instead of a standalone button. Tiles view gains a "Horizontal" toggle that rotates actually-horizontal cards (Battles, Planes, Phenomena) into their natural landscape orientation via a container-query-sized wrapper, spanning two grid columns so the rotated card isn't squeezed into a single portrait column's width and left too small to read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -254,6 +254,10 @@ 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);
|
||||||
|
|
||||||
|
if filter.not_owned {
|
||||||
|
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
|
||||||
|
}
|
||||||
|
|
||||||
sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
|
sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
|
||||||
vals.push(Value::Integer(limit as i64));
|
vals.push(Value::Integer(limit as i64));
|
||||||
|
|
||||||
@@ -765,6 +769,31 @@ mod tests {
|
|||||||
assert_eq!(bolt.quantity_owned, 0);
|
assert_eq!(bolt.quantity_owned, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_not_owned_excludes_owned_cards() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
cat.add_owned("sol-ring", 1).unwrap();
|
||||||
|
|
||||||
|
let unowned = cat.search_reference(&LibraryFilter {
|
||||||
|
not_owned: true,
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap();
|
||||||
|
assert!(!unowned.iter().any(|c| c.card.oracle_id == "sol-ring"));
|
||||||
|
// The other four fixture cards are all unowned and still show up.
|
||||||
|
assert_eq!(unowned.len(), 4);
|
||||||
|
|
||||||
|
// A card set to 0 owned (never actually acquired, or reset back to 0)
|
||||||
|
// counts as not-owned too, not just "no library_cards row at all".
|
||||||
|
cat.add_owned("lightning-bolt", 1).unwrap();
|
||||||
|
cat.set_owned("lightning-bolt", 0).unwrap();
|
||||||
|
let unowned = cat.search_reference(&LibraryFilter {
|
||||||
|
not_owned: true,
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap();
|
||||||
|
assert!(unowned.iter().any(|c| c.card.oracle_id == "lightning-bolt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_reference_respects_limit() {
|
fn search_reference_respects_limit() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -341,6 +341,12 @@ pub struct LibraryFilter {
|
|||||||
pub power_max: Option<f64>,
|
pub power_max: Option<f64>,
|
||||||
pub toughness_min: Option<f64>,
|
pub toughness_min: Option<f64>,
|
||||||
pub toughness_max: Option<f64>,
|
pub toughness_max: Option<f64>,
|
||||||
|
/// Only cards with zero owned copies. Meaningless against
|
||||||
|
/// [`Catalog::search_library`] (which already only returns owned cards),
|
||||||
|
/// so it's only honored by [`Catalog::search_reference`] — the Card
|
||||||
|
/// Search tab's "Not owned" quick filter.
|
||||||
|
#[serde(default)]
|
||||||
|
pub not_owned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export interface LibraryFilter {
|
|||||||
power_max?: number | null;
|
power_max?: number | null;
|
||||||
toughness_min?: number | null;
|
toughness_min?: number | null;
|
||||||
toughness_max?: number | null;
|
toughness_max?: number | null;
|
||||||
|
/** Only cards with zero owned copies. Only honored by `searchReference`
|
||||||
|
* (`searchLibrary` already returns owned cards exclusively). */
|
||||||
|
not_owned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export function CardTile({
|
|||||||
cornerBottomLeft,
|
cornerBottomLeft,
|
||||||
footer,
|
footer,
|
||||||
selected,
|
selected,
|
||||||
|
rotated,
|
||||||
}: {
|
}: {
|
||||||
card: CardRef;
|
card: CardRef;
|
||||||
corner?: ReactNode;
|
corner?: ReactNode;
|
||||||
@@ -1342,19 +1343,34 @@ export function CardTile({
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
/** Caller wants Horizontal view — only actually rotates when `card.horizontal`
|
||||||
|
* is also true, so mixed results (e.g. a set with both Sieges and normal
|
||||||
|
* cards) don't spin the ones that are already right-side up. */
|
||||||
|
rotated?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { open } = useClickPreview();
|
const { open } = useClickPreview();
|
||||||
|
const showRotated = !!rotated && card.horizontal;
|
||||||
return (
|
return (
|
||||||
<div className={`tile ${selected ? "tile-selected" : ""}`}>
|
<div className={`tile ${selected ? "tile-selected" : ""} ${showRotated ? "tile-rotated" : ""}`}>
|
||||||
<div className="tile-img-wrap">
|
<div className={showRotated ? "tile-img-wrap-rotated" : "tile-img-wrap"}>
|
||||||
{card.image_uri ? (
|
{card.image_uri ? (
|
||||||
<img
|
showRotated ? (
|
||||||
className="tile-img"
|
<img
|
||||||
src={card.image_uri}
|
className="tile-img-rotated"
|
||||||
alt={card.name}
|
src={card.image_uri}
|
||||||
loading="lazy"
|
alt={card.name}
|
||||||
onClick={() => open(card)}
|
loading="lazy"
|
||||||
/>
|
onClick={() => open(card)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className="tile-img"
|
||||||
|
src={card.image_uri}
|
||||||
|
alt={card.name}
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => open(card)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="tile-noimg">{card.name}</div>
|
<div className="tile-noimg">{card.name}</div>
|
||||||
)}
|
)}
|
||||||
@@ -1456,6 +1472,14 @@ const ICON_BLANK_BOX = icon(
|
|||||||
<line x1="3" y1="9" x2="21" y2="9" />
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
/** An open package — cards you don't have a copy of yet. */
|
||||||
|
const ICON_PACKAGE = icon(
|
||||||
|
<>
|
||||||
|
<path d="M21 8l-9-5-9 5 9 5 9-5z" />
|
||||||
|
<path d="M3 8v8l9 5 9-5V8" />
|
||||||
|
<path d="M12 13v8" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card-origin and numeric criteria shared by the Library and Card Search
|
* Card-origin and numeric criteria shared by the Library and Card Search
|
||||||
@@ -1475,6 +1499,7 @@ export type CardCriteria = {
|
|||||||
legalIn: string;
|
legalIn: string;
|
||||||
canBeCommander: boolean;
|
canBeCommander: boolean;
|
||||||
vanillaOnly: boolean;
|
vanillaOnly: boolean;
|
||||||
|
notOwned: boolean;
|
||||||
powerMin: string;
|
powerMin: string;
|
||||||
powerMax: string;
|
powerMax: string;
|
||||||
toughMin: string;
|
toughMin: string;
|
||||||
@@ -1490,6 +1515,7 @@ export const EMPTY_CRITERIA: CardCriteria = {
|
|||||||
legalIn: "",
|
legalIn: "",
|
||||||
canBeCommander: false,
|
canBeCommander: false,
|
||||||
vanillaOnly: false,
|
vanillaOnly: false,
|
||||||
|
notOwned: false,
|
||||||
powerMin: "",
|
powerMin: "",
|
||||||
powerMax: "",
|
powerMax: "",
|
||||||
toughMin: "",
|
toughMin: "",
|
||||||
@@ -1521,6 +1547,7 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
|||||||
legal_in: c.legalIn || null,
|
legal_in: c.legalIn || null,
|
||||||
can_be_commander: c.canBeCommander,
|
can_be_commander: c.canBeCommander,
|
||||||
vanilla_only: c.vanillaOnly,
|
vanilla_only: c.vanillaOnly,
|
||||||
|
not_owned: c.notOwned,
|
||||||
power_min: num(c.powerMin),
|
power_min: num(c.powerMin),
|
||||||
power_max: num(c.powerMax),
|
power_max: num(c.powerMax),
|
||||||
toughness_min: num(c.toughMin),
|
toughness_min: num(c.toughMin),
|
||||||
@@ -1533,13 +1560,14 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
|||||||
*
|
*
|
||||||
* The three "exclude" toggles deliberately don't count: they refine a result
|
* The three "exclude" toggles deliberately don't count: they refine a result
|
||||||
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
||||||
* make Card Search pull every card in the database. Choosing a format or a
|
* make Card Search pull every card in the database. Choosing a format, a
|
||||||
* power range is a real query, and does.
|
* power range, or "not owned" is a real query, and does.
|
||||||
*/
|
*/
|
||||||
export function criteriaActive(c: CardCriteria): boolean {
|
export function criteriaActive(c: CardCriteria): boolean {
|
||||||
return (
|
return (
|
||||||
c.canBeCommander ||
|
c.canBeCommander ||
|
||||||
c.vanillaOnly ||
|
c.vanillaOnly ||
|
||||||
|
c.notOwned ||
|
||||||
c.legalIn !== "" ||
|
c.legalIn !== "" ||
|
||||||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
||||||
(v) => v.trim() !== "",
|
(v) => v.trim() !== "",
|
||||||
@@ -1595,9 +1623,13 @@ function RangePair({
|
|||||||
export function CardCriteriaFields({
|
export function CardCriteriaFields({
|
||||||
value: c,
|
value: c,
|
||||||
onChange,
|
onChange,
|
||||||
|
hideNotOwned,
|
||||||
}: {
|
}: {
|
||||||
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
|
||||||
|
* nothing but owned cards, so the filter would just be a no-op there. */
|
||||||
|
hideNotOwned?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1681,6 +1713,15 @@ export function CardCriteriaFields({
|
|||||||
on={c.vanillaOnly}
|
on={c.vanillaOnly}
|
||||||
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
||||||
/>
|
/>
|
||||||
|
{!hideNotOwned && (
|
||||||
|
<IconToggle
|
||||||
|
icon={ICON_PACKAGE}
|
||||||
|
label="Not owned"
|
||||||
|
hint="Only cards you don't have a single copy of yet."
|
||||||
|
on={c.notOwned}
|
||||||
|
onToggle={() => onChange({ notOwned: !c.notOwned })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2151,6 +2151,13 @@ tbody tr:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
/* A rotated (landscape) card at a single portrait column's width renders
|
||||||
|
~2x smaller in area than a normal tile (a 85:61 box is short as well as
|
||||||
|
narrow) — hard to read. Spanning 2 columns gives it a comparable, legible
|
||||||
|
footprint instead. */
|
||||||
|
.tile-rotated {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
.tile-img-wrap {
|
.tile-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2173,37 @@ tbody tr:hover {
|
|||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
/* Horizontal view (see `CardTile`'s `rotated` prop): swaps the tile to a
|
||||||
|
landscape footprint via `aspect-ratio` — the width still comes from the
|
||||||
|
grid column, so no viewport math is needed here, unlike the click-preview
|
||||||
|
modal's rotated case. `container-type: size` lets the actual (still
|
||||||
|
portrait, pre-rotation) `<img>` inside reference that computed box via
|
||||||
|
`cqw`/`cqh` to size itself as the exact transpose, so rotating it 90° fills
|
||||||
|
the wrapper exactly. */
|
||||||
|
.tile-img-wrap-rotated {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 85 / 61;
|
||||||
|
container-type: size;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform var(--dur-fast), box-shadow var(--dur-fast);
|
||||||
|
}
|
||||||
|
.tile:hover .tile-img-wrap-rotated {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.tile-img-rotated {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100cqh;
|
||||||
|
height: 100cqw;
|
||||||
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
}
|
||||||
.tile-noimg {
|
.tile-noimg {
|
||||||
aspect-ratio: 488 / 680;
|
aspect-ratio: 488 / 680;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export function CardSearch({
|
|||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
||||||
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
||||||
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
||||||
|
// Tiles-only: rotates cards that are actually horizontal (Battles, Planes,
|
||||||
|
// Phenomena) into their natural landscape orientation — handy when a search
|
||||||
|
// is mostly/entirely those. Cards that aren't horizontal are unaffected.
|
||||||
|
const [horizontalView, setHorizontalView] = usePersistedState("cardsearch-horizontal-view", false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listDecks().then(setDecks);
|
api.listDecks().then(setDecks);
|
||||||
@@ -337,6 +341,7 @@ export function CardSearch({
|
|||||||
key={c.oracle_id}
|
key={c.oracle_id}
|
||||||
card={c}
|
card={c}
|
||||||
selected={selected.has(c.oracle_id)}
|
selected={selected.has(c.oracle_id)}
|
||||||
|
rotated={horizontalView}
|
||||||
cornerLeft={
|
cornerLeft={
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -567,6 +572,21 @@ export function CardSearch({
|
|||||||
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||||
/>
|
/>
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
<label
|
||||||
|
className="row"
|
||||||
|
style={{ gap: 4, cursor: view === "tiles" ? "pointer" : "default", opacity: view === "tiles" ? 1 : 0.5 }}
|
||||||
|
title="Tiles view: rotate Battles, Planes, and Phenomena to their natural landscape orientation"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={horizontalView}
|
||||||
|
onChange={(e) => setHorizontalView(e.target.checked)}
|
||||||
|
disabled={view !== "tiles"}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Horizontal
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -632,7 +632,7 @@ export function LibraryBrowser({
|
|||||||
placeholder="e.g. landfall"
|
placeholder="e.g. landfall"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user