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

@@ -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>
</>