diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index ac01cde..4f63b32 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -254,6 +254,10 @@ impl Catalog { let mut vals: Vec = 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(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index b35398b..622cb7d 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -341,6 +341,12 @@ pub struct LibraryFilter { pub power_max: Option, pub toughness_min: Option, pub toughness_max: Option, + /// 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)] diff --git a/src/api.ts b/src/api.ts index 2c142f5..38cec03 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 { diff --git a/src/components.tsx b/src/components.tsx index 4a0ead1..7cbaa1e 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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 ( -
-
+
+
{card.image_uri ? ( - {card.name} open(card)} - /> + showRotated ? ( + {card.name} open(card)} + /> + ) : ( + {card.name} open(card)} + /> + ) ) : (
{card.name}
)} @@ -1456,6 +1472,14 @@ const ICON_BLANK_BOX = icon( , ); +/** An open package — cards you don't have a copy of yet. */ +const ICON_PACKAGE = icon( + <> + + + + , +); /** * 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 { 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 { * * 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) => 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 && ( + onChange({ notOwned: !c.notOwned })} + /> + )}
diff --git a/src/styles.css b/src/styles.css index 21347ab..b620682 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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) `` 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); diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx index 7494e95..2bde51c 100644 --- a/src/views/CardSearch.tsx +++ b/src/views/CardSearch.tsx @@ -79,6 +79,10 @@ export function CardSearch({ const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none"); const [sortBy, setSortBy] = usePersistedState("cardsearch-sort", "name"); const [sortDir, setSortDir] = usePersistedState("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={ setSortDir(sortDir === "asc" ? "desc" : "asc")} /> +
diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index feec117..8f816d5 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -632,7 +632,7 @@ export function LibraryBrowser({ placeholder="e.g. landfall" /> - +