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:
2026-08-22 19:14:27 -05:00
parent e3725cee55
commit df2cbb372a
7 changed files with 149 additions and 12 deletions

View File

@@ -254,6 +254,10 @@ 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 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 ?");
vals.push(Value::Integer(limit as i64));
@@ -765,6 +769,31 @@ mod tests {
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]
fn search_reference_respects_limit() {
let fx = test_support::make();

View File

@@ -341,6 +341,12 @@ pub struct LibraryFilter {
pub power_max: Option<f64>,
pub toughness_min: 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)]

View File

@@ -130,6 +130,9 @@ export interface LibraryFilter {
power_max?: number | null;
toughness_min?: 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 {

View File

@@ -1332,6 +1332,7 @@ export function CardTile({
cornerBottomLeft,
footer,
selected,
rotated,
}: {
card: CardRef;
corner?: ReactNode;
@@ -1342,19 +1343,34 @@ export function CardTile({
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
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 showRotated = !!rotated && card.horizontal;
return (
<div className={`tile ${selected ? "tile-selected" : ""}`}>
<div className="tile-img-wrap">
<div className={`tile ${selected ? "tile-selected" : ""} ${showRotated ? "tile-rotated" : ""}`}>
<div className={showRotated ? "tile-img-wrap-rotated" : "tile-img-wrap"}>
{card.image_uri ? (
<img
className="tile-img"
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
/>
showRotated ? (
<img
className="tile-img-rotated"
src={card.image_uri}
alt={card.name}
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>
)}
@@ -1456,6 +1472,14 @@ const ICON_BLANK_BOX = icon(
<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
@@ -1475,6 +1499,7 @@ export type CardCriteria = {
legalIn: string;
canBeCommander: boolean;
vanillaOnly: boolean;
notOwned: boolean;
powerMin: string;
powerMax: string;
toughMin: string;
@@ -1490,6 +1515,7 @@ export const EMPTY_CRITERIA: CardCriteria = {
legalIn: "",
canBeCommander: false,
vanillaOnly: false,
notOwned: false,
powerMin: "",
powerMax: "",
toughMin: "",
@@ -1521,6 +1547,7 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
legal_in: c.legalIn || null,
can_be_commander: c.canBeCommander,
vanilla_only: c.vanillaOnly,
not_owned: c.notOwned,
power_min: num(c.powerMin),
power_max: num(c.powerMax),
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
* 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
* power range is a real query, and does.
* make Card Search pull every card in the database. Choosing a format, a
* power range, or "not owned" is a real query, and does.
*/
export function criteriaActive(c: CardCriteria): boolean {
return (
c.canBeCommander ||
c.vanillaOnly ||
c.notOwned ||
c.legalIn !== "" ||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
(v) => v.trim() !== "",
@@ -1595,9 +1623,13 @@ function RangePair({
export function CardCriteriaFields({
value: c,
onChange,
hideNotOwned,
}: {
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;
}) {
return (
<>
@@ -1681,6 +1713,15 @@ export function CardCriteriaFields({
on={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>
</>

View File

@@ -2151,6 +2151,13 @@ tbody tr:hover {
flex-direction: column;
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 {
position: relative;
}
@@ -2166,6 +2173,37 @@ tbody tr:hover {
transform: translateY(-3px);
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 {
aspect-ratio: 488 / 680;
border: 1px solid var(--border);

View File

@@ -79,6 +79,10 @@ export function CardSearch({
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
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(() => {
api.listDecks().then(setDecks);
@@ -337,6 +341,7 @@ export function CardSearch({
key={c.oracle_id}
card={c}
selected={selected.has(c.oracle_id)}
rotated={horizontalView}
cornerLeft={
<input
type="checkbox"
@@ -567,6 +572,21 @@ export function CardSearch({
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<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>

View File

@@ -632,7 +632,7 @@ export function LibraryBrowser({
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned />
</div>
</div>
<button