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();
|
||||
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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user