Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation
Card Search browses the whole downloaded card universe (not just owned cards), reusing the Library tab's filter/group/sort/list/tiles UI; results show ownership and offer quick "own" and "add to deck" actions. Battles, Planes, and Phenomena store their Scryfall image portrait-shaped with the content itself rotated, so hover/click previews now detect this (a new `horizontal` flag threaded through ingest and every card query, with graceful fallback for a reference.db built before the column existed) and rotate the image back into place. Single-faced and non-DFC cases are verified fixed; a double-faced Battle's front face still needs another pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,19 @@ pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `column` exists on `table` in the attached reference db.
|
||||||
|
/// reference.db is rebuilt (not migrated) on every ingest, so a db built
|
||||||
|
/// before a given column shipped simply lacks it — this lets callers degrade
|
||||||
|
/// gracefully (or, for narrowly-used columns, fail with a clear "update card
|
||||||
|
/// data" message) instead of a raw "no such column" SQL error.
|
||||||
|
pub fn has_reference_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
|
||||||
|
Ok(conn.query_row(
|
||||||
|
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}', '{REF_DB}') WHERE name = ?1"),
|
||||||
|
[column],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply any library migrations newer than the recorded `user_version`.
|
/// Apply any library migrations newer than the recorded `user_version`.
|
||||||
pub fn migrate_library(conn: &Connection) -> Result<()> {
|
pub fn migrate_library(conn: &Connection) -> Result<()> {
|
||||||
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ impl Catalog {
|
|||||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
||||||
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||||
oc.legalities, {price}, dc.is_maybeboard
|
oc.legalities, {price}, dc.is_maybeboard, {horizontal}
|
||||||
FROM deck_cards dc
|
FROM deck_cards dc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||||
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||||
@@ -123,7 +123,8 @@ impl Catalog {
|
|||||||
ORDER BY oc.name COLLATE NOCASE",
|
ORDER BY oc.name COLLATE NOCASE",
|
||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
price = crate::db::price_subquery()
|
price = crate::db::price_subquery(),
|
||||||
|
horizontal = self.horizontal_col()
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![deck_id], |r| {
|
.query_map(params![deck_id], |r| {
|
||||||
@@ -144,6 +145,7 @@ impl Catalog {
|
|||||||
produces_colorless: r.get::<_, i64>(12)? != 0,
|
produces_colorless: r.get::<_, i64>(12)? != 0,
|
||||||
legalities: Legalities(r.get::<_, i64>(13)?),
|
legalities: Legalities(r.get::<_, i64>(13)?),
|
||||||
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
|
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
|
||||||
|
horizontal: r.get::<_, i64>(16)? != 0,
|
||||||
},
|
},
|
||||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,93 @@
|
|||||||
//! Criteria search over the owned library, compiled to SQL across the library
|
//! Criteria search over the owned library, and its sibling over the whole
|
||||||
//! and attached reference dbs.
|
//! reference db (every downloaded card, owned or not), compiled to SQL across
|
||||||
|
//! the library and attached reference dbs.
|
||||||
|
|
||||||
use rusqlite::types::Value;
|
use rusqlite::types::Value;
|
||||||
|
|
||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::{CoreError, Result};
|
||||||
use crate::model::{LibraryCard, LibraryFilter};
|
use crate::model::{LibraryCard, LibraryFilter};
|
||||||
use crate::Catalog;
|
use crate::Catalog;
|
||||||
|
|
||||||
|
/// Append the card-data predicates shared by [`Catalog::search_library`] and
|
||||||
|
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`,
|
||||||
|
/// which only make sense against the owned library. Caller has already
|
||||||
|
/// written the `FROM ... WHERE <ownership predicate>` prefix.
|
||||||
|
fn push_card_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
|
||||||
|
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||||
|
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||||
|
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||||
|
}
|
||||||
|
if let Some(c) = filter.colors_exact {
|
||||||
|
sql.push_str(" AND oc.colors = ?");
|
||||||
|
vals.push(Value::Integer(c.0 as i64));
|
||||||
|
}
|
||||||
|
if let Some(c) = filter.colors_include {
|
||||||
|
sql.push_str(" AND (oc.colors & ?) = ?");
|
||||||
|
vals.push(Value::Integer(c.0 as i64));
|
||||||
|
vals.push(Value::Integer(c.0 as i64));
|
||||||
|
}
|
||||||
|
if let Some(id) = filter.commander_identity {
|
||||||
|
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
|
||||||
|
sql.push_str(" AND (oc.color_identity & ?) = 0");
|
||||||
|
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
|
||||||
|
}
|
||||||
|
if let Some(min) = filter.cmc_min {
|
||||||
|
sql.push_str(" AND oc.mana_value >= ?");
|
||||||
|
vals.push(Value::Real(min));
|
||||||
|
}
|
||||||
|
if let Some(max) = filter.cmc_max {
|
||||||
|
sql.push_str(" AND oc.mana_value <= ?");
|
||||||
|
vals.push(Value::Real(max));
|
||||||
|
}
|
||||||
|
for t in &filter.types {
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
|
||||||
|
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
|
||||||
|
));
|
||||||
|
vals.push(Value::Text(t.clone()));
|
||||||
|
}
|
||||||
|
for s in &filter.subtypes {
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
|
||||||
|
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
|
||||||
|
));
|
||||||
|
vals.push(Value::Text(s.clone()));
|
||||||
|
}
|
||||||
|
for k in &filter.keywords {
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
|
||||||
|
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
|
||||||
|
));
|
||||||
|
vals.push(Value::Text(k.clone()));
|
||||||
|
}
|
||||||
|
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||||
|
sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?");
|
||||||
|
vals.push(Value::Text(format!("%{}%", txt.to_lowercase())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck
|
||||||
|
/// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`],
|
||||||
|
/// and [`Catalog::list_library`].
|
||||||
|
fn enrich_with_deck_context(
|
||||||
|
cat: &Catalog,
|
||||||
|
rows: Vec<(String, i64, String, crate::model::CardRef)>,
|
||||||
|
) -> Result<Vec<LibraryCard>> {
|
||||||
|
let deck_names = cat.deck_names_by_oracle()?;
|
||||||
|
let assigned = cat.assigned_totals_by_oracle()?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(oid, qty, location, card)| LibraryCard {
|
||||||
|
quantity_owned: qty,
|
||||||
|
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
||||||
|
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
||||||
|
location,
|
||||||
|
card,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
/// Search the owned library by the given criteria. An empty filter returns
|
/// Search the owned library by the given criteria. An empty filter returns
|
||||||
/// the whole owned library (same set as [`Catalog::list_library`]).
|
/// the whole owned library (same set as [`Catalog::list_library`]).
|
||||||
@@ -19,63 +99,18 @@ impl Catalog {
|
|||||||
let mut sql = format!(
|
let mut sql = format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
|
||||||
FROM library_cards lc
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0",
|
WHERE lc.quantity_owned > 0",
|
||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
price = crate::db::price_subquery()
|
price = crate::db::price_subquery(),
|
||||||
|
horizontal = self.horizontal_col()
|
||||||
);
|
);
|
||||||
let mut vals: Vec<Value> = Vec::new();
|
let mut vals: Vec<Value> = Vec::new();
|
||||||
|
push_card_filters(&mut sql, &mut vals, filter);
|
||||||
|
|
||||||
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
|
||||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
|
||||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
|
||||||
}
|
|
||||||
if let Some(c) = filter.colors_exact {
|
|
||||||
sql.push_str(" AND oc.colors = ?");
|
|
||||||
vals.push(Value::Integer(c.0 as i64));
|
|
||||||
}
|
|
||||||
if let Some(c) = filter.colors_include {
|
|
||||||
sql.push_str(" AND (oc.colors & ?) = ?");
|
|
||||||
vals.push(Value::Integer(c.0 as i64));
|
|
||||||
vals.push(Value::Integer(c.0 as i64));
|
|
||||||
}
|
|
||||||
if let Some(id) = filter.commander_identity {
|
|
||||||
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
|
|
||||||
sql.push_str(" AND (oc.color_identity & ?) = 0");
|
|
||||||
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
|
|
||||||
}
|
|
||||||
if let Some(min) = filter.cmc_min {
|
|
||||||
sql.push_str(" AND oc.mana_value >= ?");
|
|
||||||
vals.push(Value::Real(min));
|
|
||||||
}
|
|
||||||
if let Some(max) = filter.cmc_max {
|
|
||||||
sql.push_str(" AND oc.mana_value <= ?");
|
|
||||||
vals.push(Value::Real(max));
|
|
||||||
}
|
|
||||||
for t in &filter.types {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
|
|
||||||
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
|
|
||||||
));
|
|
||||||
vals.push(Value::Text(t.clone()));
|
|
||||||
}
|
|
||||||
for s in &filter.subtypes {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
|
|
||||||
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
|
|
||||||
));
|
|
||||||
vals.push(Value::Text(s.clone()));
|
|
||||||
}
|
|
||||||
for k in &filter.keywords {
|
|
||||||
sql.push_str(&format!(
|
|
||||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
|
|
||||||
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
|
|
||||||
));
|
|
||||||
vals.push(Value::Text(k.clone()));
|
|
||||||
}
|
|
||||||
if let Some(deck_id) = filter.in_deck {
|
if let Some(deck_id) = filter.in_deck {
|
||||||
sql.push_str(
|
sql.push_str(
|
||||||
" AND EXISTS (SELECT 1 FROM deck_cards dc
|
" AND EXISTS (SELECT 1 FROM deck_cards dc
|
||||||
@@ -87,10 +122,6 @@ impl Catalog {
|
|||||||
sql.push_str(" AND LOWER(lc.location) LIKE ?");
|
sql.push_str(" AND LOWER(lc.location) LIKE ?");
|
||||||
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
|
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
|
||||||
}
|
}
|
||||||
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
|
||||||
sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?");
|
|
||||||
vals.push(Value::Text(format!("%{}%", txt.to_lowercase())));
|
|
||||||
}
|
|
||||||
|
|
||||||
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
||||||
|
|
||||||
@@ -102,19 +133,56 @@ impl Catalog {
|
|||||||
)?
|
)?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
// Enrich with deck context (shared with list_library).
|
enrich_with_deck_context(self, rows)
|
||||||
let deck_names = self.deck_names_by_oracle()?;
|
}
|
||||||
let assigned = self.assigned_totals_by_oracle()?;
|
|
||||||
Ok(rows
|
/// Search the *whole* reference db (every downloaded card, owned or not)
|
||||||
.into_iter()
|
/// by the same card-data criteria as [`Catalog::search_library`], minus
|
||||||
.map(|(oid, qty, location, card)| LibraryCard {
|
/// `in_deck`/`location` (library-specific — silently ignored if set).
|
||||||
quantity_owned: qty,
|
/// Results are still [`LibraryCard`]s so ownership/deck context is
|
||||||
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
|
||||||
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
|
||||||
location,
|
/// rank (unranked last) so a broad filter surfaces the most-played
|
||||||
card,
|
/// matches rather than an arbitrary slice.
|
||||||
})
|
pub fn search_reference(&self, filter: &LibraryFilter, limit: usize) -> Result<Vec<LibraryCard>> {
|
||||||
.collect())
|
// `edhrec_rank` only exists in a reference.db built after the synergy
|
||||||
|
// feature shipped; an older ingest lacks it (reference.db is rebuilt,
|
||||||
|
// not migrated). Detect that and return a clear, actionable message
|
||||||
|
// instead of a raw "no such column" error — same guard as
|
||||||
|
// `Catalog::suggest_cards` in synergy.rs.
|
||||||
|
if !crate::db::has_reference_column(&self.conn, "oracle_cards", "edhrec_rank")? {
|
||||||
|
return Err(CoreError::Other(
|
||||||
|
"Card search needs updated card data — click \"Update card data\" to enable it.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sql = format!(
|
||||||
|
"SELECT oc.oracle_id, COALESCE(lc.quantity_owned, 0), COALESCE(lc.location, ''),
|
||||||
|
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||||
|
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
|
||||||
|
FROM {REF_DB}.oracle_cards oc
|
||||||
|
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||||
|
WHERE 1=1",
|
||||||
|
image = crate::db::image_subquery("image_uri"),
|
||||||
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
|
price = crate::db::price_subquery(),
|
||||||
|
horizontal = self.horizontal_col()
|
||||||
|
);
|
||||||
|
let mut vals: Vec<Value> = Vec::new();
|
||||||
|
push_card_filters(&mut sql, &mut vals, filter);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
let mut stmt = self.conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(
|
||||||
|
rusqlite::params_from_iter(vals),
|
||||||
|
crate::library::row_to_library_parts,
|
||||||
|
)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
enrich_with_deck_context(self, rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,4 +373,149 @@ mod tests {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
assert_eq!(all.len(), 5);
|
assert_eq!(all.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_finds_unowned_cards_search_library_does_not() {
|
||||||
|
// No ownership at all — the whole fixture is unowned.
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
|
||||||
|
let ref_bolt = names(&cat.search_reference(&LibraryFilter {
|
||||||
|
text: Some("bolt".into()),
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap());
|
||||||
|
assert_eq!(ref_bolt, vec!["Lightning Bolt"]);
|
||||||
|
|
||||||
|
// search_library sees nothing — nothing is owned.
|
||||||
|
let lib_bolt = cat.search_library(&LibraryFilter {
|
||||||
|
text: Some("bolt".into()),
|
||||||
|
..Default::default()
|
||||||
|
}).unwrap();
|
||||||
|
assert!(lib_bolt.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_reports_ownership_for_owned_cards() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
cat.add_owned("sol-ring", 3).unwrap();
|
||||||
|
|
||||||
|
let all = cat.search_reference(&LibraryFilter::default(), 50).unwrap();
|
||||||
|
let sol = all.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||||
|
assert_eq!(sol.quantity_owned, 3);
|
||||||
|
let bolt = all.iter().find(|c| c.card.oracle_id == "lightning-bolt").unwrap();
|
||||||
|
assert_eq!(bolt.quantity_owned, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_respects_limit() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let capped = cat.search_reference(&LibraryFilter::default(), 2).unwrap();
|
||||||
|
assert_eq!(capped.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_ignores_in_deck_and_location() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let deck = cat.create_deck("Some Deck", None).unwrap();
|
||||||
|
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
|
||||||
|
|
||||||
|
// Both library-only fields are set but should have no effect — the
|
||||||
|
// whole (unowned) fixture is still returned.
|
||||||
|
let got = cat.search_reference(&LibraryFilter {
|
||||||
|
in_deck: Some(deck.id),
|
||||||
|
location: Some("Box 1".into()),
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap();
|
||||||
|
assert_eq!(got.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `horizontal` is read by nearly every card query (see
|
||||||
|
/// `Catalog::horizontal_col`), so a reference.db built before that column
|
||||||
|
/// shipped must not break `search_library`/`search_reference`/
|
||||||
|
/// `list_library`/`deck_contents`/name search — they should all degrade
|
||||||
|
/// to treating every card as non-horizontal, not raw-SQL-error.
|
||||||
|
#[test]
|
||||||
|
fn card_queries_degrade_gracefully_without_the_horizontal_column() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let ref_path = dir.path().join("reference.db");
|
||||||
|
let lib_path = dir.path().join("library.db");
|
||||||
|
|
||||||
|
// Minimal schema mirroring reference_schema.sql as it existed before
|
||||||
|
// `horizontal` was added (themes/edhrec_rank present, since those
|
||||||
|
// shipped earlier — horizontal deliberately absent).
|
||||||
|
{
|
||||||
|
let rconn = rusqlite::Connection::open(&ref_path).unwrap();
|
||||||
|
rconn.execute_batch(
|
||||||
|
"CREATE TABLE oracle_cards (
|
||||||
|
oracle_id TEXT PRIMARY KEY, name TEXT NOT NULL, name_lower TEXT NOT NULL,
|
||||||
|
mana_value REAL NOT NULL DEFAULT 0, colors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color_identity INTEGER NOT NULL DEFAULT 0, type_line TEXT NOT NULL DEFAULT '',
|
||||||
|
oracle_text TEXT NOT NULL DEFAULT '', produced_mana INTEGER NOT NULL DEFAULT 0,
|
||||||
|
produces_colorless INTEGER NOT NULL DEFAULT 0, legalities INTEGER NOT NULL DEFAULT 0,
|
||||||
|
themes INTEGER NOT NULL DEFAULT 0, edhrec_rank INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE card_types (oracle_id TEXT NOT NULL, type TEXT NOT NULL);
|
||||||
|
CREATE TABLE card_subtypes (oracle_id TEXT NOT NULL, subtype TEXT NOT NULL);
|
||||||
|
CREATE TABLE card_keywords (oracle_id TEXT NOT NULL, keyword TEXT NOT NULL);
|
||||||
|
CREATE TABLE printings (
|
||||||
|
scryfall_id TEXT PRIMARY KEY, oracle_id TEXT NOT NULL,
|
||||||
|
image_uri TEXT NOT NULL DEFAULT '', back_image_uri TEXT NOT NULL DEFAULT '',
|
||||||
|
price_usd REAL NOT NULL DEFAULT 0
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rconn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO oracle_cards (oracle_id,name,name_lower,type_line) \
|
||||||
|
VALUES ('sol-ring','Sol Ring','sol ring','Artifact')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cat = crate::Catalog::open(&lib_path, &ref_path).unwrap();
|
||||||
|
cat.add_owned("sol-ring", 1).unwrap();
|
||||||
|
|
||||||
|
let lib = cat.search_library(&LibraryFilter::default()).unwrap();
|
||||||
|
assert_eq!(lib.len(), 1);
|
||||||
|
assert!(!lib[0].card.horizontal);
|
||||||
|
|
||||||
|
let listed = cat.list_library().unwrap();
|
||||||
|
assert!(!listed[0].card.horizontal);
|
||||||
|
|
||||||
|
let referenced = cat.search_reference(&LibraryFilter::default(), 50).unwrap();
|
||||||
|
assert_eq!(referenced.len(), 1);
|
||||||
|
assert!(!referenced[0].card.horizontal);
|
||||||
|
|
||||||
|
let deck = cat.create_deck("Test", None).unwrap();
|
||||||
|
cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap();
|
||||||
|
let contents = cat.deck_contents(deck.id).unwrap();
|
||||||
|
assert!(!contents[0].card.horizontal);
|
||||||
|
|
||||||
|
let index = cat.build_name_index().unwrap();
|
||||||
|
let found = index.search("Sol Ring", 5);
|
||||||
|
assert!(!found[0].horizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_reference_applies_the_same_card_criteria_as_search_library() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
|
||||||
|
let elves = names(&cat.search_reference(&LibraryFilter {
|
||||||
|
subtypes: vec!["Elf".into()],
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap());
|
||||||
|
assert_eq!(elves, vec!["Deathrite Shaman", "Llanowar Elves"]);
|
||||||
|
|
||||||
|
let mono_g = names(&cat.search_reference(&LibraryFilter {
|
||||||
|
commander_identity: Some(ColorSet::parse("G")),
|
||||||
|
..Default::default()
|
||||||
|
}, 50).unwrap());
|
||||||
|
assert!(mono_g.contains(&"Llanowar Elves".to_string()));
|
||||||
|
assert!(!mono_g.contains(&"Serra Angel".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,8 +461,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
let mut ins_card = conn.prepare(
|
let mut ins_card = conn.prepare(
|
||||||
"INSERT OR IGNORE INTO oracle_cards
|
"INSERT OR IGNORE INTO oracle_cards
|
||||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank)
|
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||||
)?;
|
)?;
|
||||||
let mut ins_fts =
|
let mut ins_fts =
|
||||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||||
@@ -543,6 +543,10 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
|
|
||||||
let legalities = Legalities::from_scryfall(&c.legalities);
|
let legalities = Legalities::from_scryfall(&c.legalities);
|
||||||
let themes = crate::synergy::compute_themes(&oracle_text);
|
let themes = crate::synergy::compute_themes(&oracle_text);
|
||||||
|
// Battles and Planes/Phenomena store their card image portrait-shaped
|
||||||
|
// with the content itself rotated — viewers must rotate it 90° to
|
||||||
|
// read it right-side up (verified against real Scryfall images).
|
||||||
|
let horizontal = matches!(c.layout.as_str(), "battle" | "planar");
|
||||||
|
|
||||||
let changed = ins_card.execute(params![
|
let changed = ins_card.execute(params![
|
||||||
oracle_id,
|
oracle_id,
|
||||||
@@ -558,6 +562,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
legalities.0,
|
legalities.0,
|
||||||
themes,
|
themes,
|
||||||
c.edhrec_rank,
|
c.edhrec_rank,
|
||||||
|
horizontal as i64,
|
||||||
])?;
|
])?;
|
||||||
// Only emit satellite rows the first time we see this oracle_id.
|
// Only emit satellite rows the first time we see this oracle_id.
|
||||||
if changed == 1 {
|
if changed == 1 {
|
||||||
@@ -1012,6 +1017,41 @@ mod tests {
|
|||||||
assert_eq!(van_rank, None);
|
assert_eq!(van_rank, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captures_horizontal_flag_for_battle_and_planar_layouts_only() {
|
||||||
|
let oracle = r#"[
|
||||||
|
{"oracle_id":"oid-battle","name":"Invasion of Zendikar","layout":"battle","cmc":4.0,
|
||||||
|
"type_line":"Battle — Siege","color_identity":["G"],"keywords":[]},
|
||||||
|
{"oracle_id":"oid-plane","name":"Academy at Tolaria West","layout":"planar","cmc":0.0,
|
||||||
|
"type_line":"Plane — Dominaria","color_identity":[],"keywords":[]},
|
||||||
|
{"oracle_id":"oid-scheme","name":"A Display of My Dark Power","layout":"scheme","cmc":0.0,
|
||||||
|
"type_line":"Scheme","color_identity":[],"keywords":[]},
|
||||||
|
{"oracle_id":"oid-normal","name":"Grizzly Bears","layout":"normal","cmc":2.0,
|
||||||
|
"type_line":"Creature — Bear","color_identity":["G"],"keywords":[]}
|
||||||
|
]"#;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let dest = dir.path().join("reference.db");
|
||||||
|
let conn = open_reference_for_build(&dest).unwrap();
|
||||||
|
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
|
||||||
|
|
||||||
|
let horizontal = |id: &str| -> bool {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT horizontal FROM oracle_cards WHERE oracle_id=?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
!= 0
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(horizontal("oid-battle"));
|
||||||
|
assert!(horizontal("oid-plane"));
|
||||||
|
// Schemes are oversized but not rotated — only battle/planar layouts are.
|
||||||
|
assert!(!horizontal("oid-scheme"));
|
||||||
|
assert!(!horizontal("oid-normal"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn captures_back_face_image_for_double_faced_cards() {
|
fn captures_back_face_image_for_double_faced_cards() {
|
||||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ pub use synergy::{CardSuggestion, SynergyReason};
|
|||||||
/// so the caller can cache it in app state.
|
/// so the caller can cache it in app state.
|
||||||
pub struct Catalog {
|
pub struct Catalog {
|
||||||
pub(crate) conn: Connection,
|
pub(crate) conn: Connection,
|
||||||
|
/// Whether `oracle_cards.horizontal` exists in the attached reference db.
|
||||||
|
/// Cached at open time (one pragma query) since it's consulted by nearly
|
||||||
|
/// every card-hydrating query — see [`Catalog::horizontal_col`].
|
||||||
|
pub(crate) has_horizontal_column: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
@@ -42,7 +46,23 @@ impl Catalog {
|
|||||||
/// reference db at `reference_path` attached.
|
/// reference db at `reference_path` attached.
|
||||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
|
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
|
||||||
let conn = db::open(library_path, reference_path)?;
|
let conn = db::open(library_path, reference_path)?;
|
||||||
Ok(Self { conn })
|
let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?;
|
||||||
|
Ok(Self { conn, has_horizontal_column })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT`
|
||||||
|
/// aliased `oc`. Degrades to the literal `0` (treat as non-horizontal)
|
||||||
|
/// when an older reference.db — built before this column shipped —
|
||||||
|
/// lacks it, rather than erroring: unlike `themes`/`edhrec_rank` (which
|
||||||
|
/// only gate the one Suggest Cards feature), `horizontal` is read by
|
||||||
|
/// almost every card query in the app, so a hard failure here would
|
||||||
|
/// break far more than one feature until the user re-ingests.
|
||||||
|
pub(crate) fn horizontal_col(&self) -> &'static str {
|
||||||
|
if self.has_horizontal_column {
|
||||||
|
"oc.horizontal"
|
||||||
|
} else {
|
||||||
|
"0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build (or rebuild) the in-memory fuzzy name index from the attached
|
/// Build (or rebuild) the in-memory fuzzy name index from the attached
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ use crate::Catalog;
|
|||||||
|
|
||||||
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
||||||
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
||||||
/// produces_colorless, legalities, price_usd)` into its library parts. Shared
|
/// produces_colorless, legalities, price_usd, horizontal)` into its library
|
||||||
/// by [`Catalog::list_library`] and the criteria search.
|
/// parts. Shared by [`Catalog::list_library`] and the criteria search. The
|
||||||
|
/// caller's `SELECT` must put `horizontal` (or [`Catalog::horizontal_col`]'s
|
||||||
|
/// `0` fallback) last, since it's not part of the base reference schema on
|
||||||
|
/// every db (see `Catalog::has_horizontal_column`).
|
||||||
pub(crate) fn row_to_library_parts(
|
pub(crate) fn row_to_library_parts(
|
||||||
r: &rusqlite::Row,
|
r: &rusqlite::Row,
|
||||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
||||||
@@ -34,6 +37,7 @@ pub(crate) fn row_to_library_parts(
|
|||||||
produces_colorless: r.get::<_, i64>(11)? != 0,
|
produces_colorless: r.get::<_, i64>(11)? != 0,
|
||||||
legalities: Legalities(r.get::<_, i64>(12)?),
|
legalities: Legalities(r.get::<_, i64>(12)?),
|
||||||
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
||||||
|
horizontal: r.get::<_, i64>(14)? != 0,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -76,14 +80,15 @@ impl Catalog {
|
|||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
|
||||||
FROM library_cards lc
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0
|
WHERE lc.quantity_owned > 0
|
||||||
ORDER BY oc.name COLLATE NOCASE",
|
ORDER BY oc.name COLLATE NOCASE",
|
||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
price = crate::db::price_subquery()
|
price = crate::db::price_subquery(),
|
||||||
|
horizontal = self.horizontal_col()
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], row_to_library_parts)?
|
.query_map([], row_to_library_parts)?
|
||||||
@@ -161,6 +166,17 @@ impl Catalog {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinct type/subtype/keyword values across the *whole* reference db
|
||||||
|
/// (every downloaded card, owned or not) — the Card Search tab's
|
||||||
|
/// equivalent of [`Catalog::library_facets`].
|
||||||
|
pub fn reference_facets(&self) -> Result<LibraryFacets> {
|
||||||
|
Ok(LibraryFacets {
|
||||||
|
types: self.distinct_facet("card_types", "type")?,
|
||||||
|
subtypes: self.distinct_facet("card_subtypes", "subtype")?,
|
||||||
|
keywords: self.distinct_facet("card_keywords", "keyword")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// `table`/`column` are always one of the three hardcoded call sites above
|
/// `table`/`column` are always one of the three hardcoded call sites above
|
||||||
/// (never user input), so interpolating them into the query is safe.
|
/// (never user input), so interpolating them into the query is safe.
|
||||||
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
|
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
|
||||||
@@ -174,6 +190,17 @@ impl Catalog {
|
|||||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same as [`Catalog::distinct_owned_facet`] but unfiltered by ownership.
|
||||||
|
fn distinct_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
|
||||||
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
|
"SELECT DISTINCT x.{column}
|
||||||
|
FROM {REF_DB}.{table} x
|
||||||
|
ORDER BY x.{column} COLLATE NOCASE"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the free-text storage location for a card (creates the library row if
|
/// Set the free-text storage location for a card (creates the library row if
|
||||||
/// absent). Empty string clears it.
|
/// absent). Empty string clears it.
|
||||||
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
|
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
|
||||||
@@ -256,6 +283,18 @@ mod tests {
|
|||||||
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
|
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_facets_include_unowned_cards() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
// Nothing owned at all — reference_facets should still see everything
|
||||||
|
// in the fixture, unlike library_facets.
|
||||||
|
let facets = cat.reference_facets().unwrap();
|
||||||
|
assert_eq!(facets.types, vec!["Artifact", "Creature", "Instant"]);
|
||||||
|
assert!(facets.keywords.contains(&"Flying".to_string()));
|
||||||
|
assert!(facets.keywords.contains(&"Vigilance".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn library_stats_aggregates_counts_and_colors() {
|
fn library_stats_aggregates_counts_and_colors() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ pub struct CardRef {
|
|||||||
pub colors: ColorSet,
|
pub colors: ColorSet,
|
||||||
pub color_identity: ColorSet,
|
pub color_identity: ColorSet,
|
||||||
pub type_line: String,
|
pub type_line: String,
|
||||||
|
/// Whether this card is printed/displayed sideways (Scryfall layout
|
||||||
|
/// "battle" or "planar" — Battles, Planes, and Phenomena). Its stored
|
||||||
|
/// image is itself portrait-shaped with the content rotated, so viewers
|
||||||
|
/// must rotate it 90° to display right-side up.
|
||||||
|
pub horizontal: bool,
|
||||||
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
||||||
/// or empty if none is available.
|
/// or empty if none is available.
|
||||||
pub image_uri: String,
|
pub image_uri: String,
|
||||||
|
|||||||
@@ -32,10 +32,17 @@ pub struct NameIndex {
|
|||||||
impl NameIndex {
|
impl NameIndex {
|
||||||
/// Load every oracle card from the attached reference db.
|
/// Load every oracle card from the attached reference db.
|
||||||
pub fn build(conn: &Connection) -> Result<Self> {
|
pub fn build(conn: &Connection) -> Result<Self> {
|
||||||
|
// See `Catalog::horizontal_col` — degrade to `0` on an older
|
||||||
|
// reference.db that predates this column, rather than erroring.
|
||||||
|
let horizontal = if crate::db::has_reference_column(conn, "oracle_cards", "horizontal")? {
|
||||||
|
"oc.horizontal"
|
||||||
|
} else {
|
||||||
|
"0"
|
||||||
|
};
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||||
oc.legalities, {price}
|
oc.legalities, {price}, {horizontal}
|
||||||
FROM {REF_DB}.oracle_cards oc",
|
FROM {REF_DB}.oracle_cards oc",
|
||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
@@ -57,6 +64,7 @@ impl NameIndex {
|
|||||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||||
|
horizontal: r.get::<_, i64>(12)? != 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
|||||||
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
||||||
legalities INTEGER NOT NULL DEFAULT 0, -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
legalities INTEGER NOT NULL DEFAULT 0, -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||||
themes INTEGER NOT NULL DEFAULT 0, -- archetype bitmask, see synergy::ARCHETYPES
|
themes INTEGER NOT NULL DEFAULT 0, -- archetype bitmask, see synergy::ARCHETYPES
|
||||||
edhrec_rank INTEGER -- Scryfall EDHREC popularity rank; NULL if unranked
|
edhrec_rank INTEGER, -- Scryfall EDHREC popularity rank; NULL if unranked
|
||||||
|
horizontal INTEGER NOT NULL DEFAULT 0 -- boolean: printed/displayed sideways (Scryfall layout "battle"/"planar")
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ impl Catalog {
|
|||||||
let mut sql = format!(
|
let mut sql = format!(
|
||||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line,
|
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line,
|
||||||
{image}, {back}, oc.produced_mana, oc.produces_colorless, oc.legalities, {price},
|
{image}, {back}, oc.produced_mana, oc.produces_colorless, oc.legalities, {price},
|
||||||
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0)
|
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0), {horizontal}
|
||||||
FROM {REF_DB}.oracle_cards oc
|
FROM {REF_DB}.oracle_cards oc
|
||||||
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||||
WHERE oc.oracle_id NOT IN (SELECT oracle_id FROM deck_cards WHERE deck_id = ?)
|
WHERE oc.oracle_id NOT IN (SELECT oracle_id FROM deck_cards WHERE deck_id = ?)
|
||||||
@@ -320,6 +320,7 @@ impl Catalog {
|
|||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
price = crate::db::price_subquery(),
|
price = crate::db::price_subquery(),
|
||||||
|
horizontal = self.horizontal_col(),
|
||||||
);
|
);
|
||||||
let mut vals: Vec<Value> = vec![
|
let mut vals: Vec<Value> = vec![
|
||||||
Value::Integer(deck_id),
|
Value::Integer(deck_id),
|
||||||
@@ -372,6 +373,7 @@ impl Catalog {
|
|||||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||||
|
horizontal: r.get::<_, i64>(15)? != 0,
|
||||||
},
|
},
|
||||||
themes: r.get(12)?,
|
themes: r.get(12)?,
|
||||||
edhrec_rank: r.get(13)?,
|
edhrec_rank: r.get(13)?,
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ pub fn search_library(db: Db, filter: LibraryFilter) -> Res<Vec<LibraryCard>> {
|
|||||||
s.catalog()?.search_library(&filter).map_err(|e| e.to_string())
|
s.catalog()?.search_library(&filter).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn search_reference(db: Db, filter: LibraryFilter, limit: usize) -> Res<Vec<LibraryCard>> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
s.catalog()?
|
||||||
|
.search_reference(&filter, limit)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_library_stats(db: Db) -> Res<LibraryStats> {
|
pub fn get_library_stats(db: Db) -> Res<LibraryStats> {
|
||||||
let s = db.lock().unwrap();
|
let s = db.lock().unwrap();
|
||||||
@@ -159,6 +167,12 @@ pub fn get_library_facets(db: Db) -> Res<LibraryFacets> {
|
|||||||
s.catalog()?.library_facets().map_err(|e| e.to_string())
|
s.catalog()?.library_facets().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_reference_facets(db: Db) -> Res<LibraryFacets> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
s.catalog()?.reference_facets().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_decks(db: Db) -> Res<Vec<Deck>> {
|
pub fn list_decks(db: Db) -> Res<Vec<Deck>> {
|
||||||
let s = db.lock().unwrap();
|
let s = db.lock().unwrap();
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ pub fn run() {
|
|||||||
commands::set_owned,
|
commands::set_owned,
|
||||||
commands::set_location,
|
commands::set_location,
|
||||||
commands::search_library,
|
commands::search_library,
|
||||||
|
commands::search_reference,
|
||||||
commands::get_library_stats,
|
commands::get_library_stats,
|
||||||
commands::get_library_facets,
|
commands::get_library_facets,
|
||||||
|
commands::get_reference_facets,
|
||||||
commands::list_decks,
|
commands::list_decks,
|
||||||
commands::create_deck,
|
commands::create_deck,
|
||||||
commands::delete_deck,
|
commands::delete_deck,
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -14,9 +14,10 @@ import {
|
|||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { CardEntry } from "./views/CardEntry";
|
import { CardEntry } from "./views/CardEntry";
|
||||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||||
|
import { CardSearch } from "./views/CardSearch";
|
||||||
import { DeckView } from "./views/DeckView";
|
import { DeckView } from "./views/DeckView";
|
||||||
|
|
||||||
type Tab = "add" | "library" | "decks";
|
type Tab = "add" | "library" | "search" | "decks";
|
||||||
|
|
||||||
const svg = (children: ReactNode, size = 20) => (
|
const svg = (children: ReactNode, size = 20) => (
|
||||||
<svg
|
<svg
|
||||||
@@ -94,6 +95,16 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
|||||||
</>,
|
</>,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
label: "Card Search",
|
||||||
|
icon: svg(
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "decks",
|
id: "decks",
|
||||||
label: "Decks",
|
label: "Decks",
|
||||||
@@ -439,6 +450,8 @@ export default function App() {
|
|||||||
<CardEntry />
|
<CardEntry />
|
||||||
) : tab === "library" ? (
|
) : tab === "library" ? (
|
||||||
<LibraryBrowser onOfferUndo={offerUndo} />
|
<LibraryBrowser onOfferUndo={offerUndo} />
|
||||||
|
) : tab === "search" ? (
|
||||||
|
<CardSearch onOfferUndo={offerUndo} />
|
||||||
) : (
|
) : (
|
||||||
<DeckView refreshSignal={deckRefreshSignal} />
|
<DeckView refreshSignal={deckRefreshSignal} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { api, CardRef, Deck } from "./api";
|
import { api, CardRef, Deck } from "./api";
|
||||||
import { DialogFrame, useClickPreview } from "./components";
|
import { DialogFrame, useClickPreview } from "./components";
|
||||||
|
|
||||||
type Tab = "add" | "library" | "decks";
|
type Tab = "add" | "library" | "search" | "decks";
|
||||||
|
|
||||||
interface PaletteItem {
|
interface PaletteItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -93,6 +93,7 @@ export function CommandPalette({
|
|||||||
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
|
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
|
||||||
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
||||||
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
||||||
|
{ key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") },
|
||||||
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||||
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
||||||
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface CardRef {
|
|||||||
colors: number; // WUBRG bitmask
|
colors: number; // WUBRG bitmask
|
||||||
color_identity: number; // WUBRG bitmask
|
color_identity: number; // WUBRG bitmask
|
||||||
type_line: string;
|
type_line: string;
|
||||||
|
// Printed/displayed sideways (Battles, Planes, Phenomena). The stored
|
||||||
|
// image itself is portrait-shaped with the content rotated, so viewers
|
||||||
|
// must rotate it 90° to display right-side up.
|
||||||
|
horizontal: boolean;
|
||||||
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
|
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
|
||||||
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
||||||
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
||||||
@@ -177,8 +181,11 @@ export const api = {
|
|||||||
|
|
||||||
searchLibrary: (filter: LibraryFilter) =>
|
searchLibrary: (filter: LibraryFilter) =>
|
||||||
invoke<LibraryCard[]>("search_library", { filter }),
|
invoke<LibraryCard[]>("search_library", { filter }),
|
||||||
|
searchReference: (filter: LibraryFilter, limit: number) =>
|
||||||
|
invoke<LibraryCard[]>("search_reference", { filter, limit }),
|
||||||
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
||||||
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
|
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
|
||||||
|
getReferenceFacets: () => invoke<LibraryFacets>("get_reference_facets"),
|
||||||
|
|
||||||
listDecks: () => invoke<Deck[]>("list_decks"),
|
listDecks: () => invoke<Deck[]>("list_decks"),
|
||||||
createDeck: (name: string, commanderOracleId: string | null) =>
|
createDeck: (name: string, commanderOracleId: string | null) =>
|
||||||
|
|||||||
@@ -756,20 +756,62 @@ const GAP = 24;
|
|||||||
const PAD = 12;
|
const PAD = 12;
|
||||||
|
|
||||||
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
|
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
|
||||||
|
// Battles/Planes/Phenomena store their image portrait-shaped with the
|
||||||
|
// content itself rotated, so the box footprint swaps (landscape) and the
|
||||||
|
// image inside gets rotated 90° back into place — see `CardRef.horizontal`.
|
||||||
|
const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W;
|
||||||
|
const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H;
|
||||||
// Prefer the right of the cursor; flip left if it would overflow.
|
// Prefer the right of the cursor; flip left if it would overflow.
|
||||||
let left = x + GAP;
|
let left = x + GAP;
|
||||||
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
|
if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP;
|
||||||
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
|
const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD));
|
||||||
return (
|
return (
|
||||||
<img
|
<div className="hover-card" style={{ left, top, width: boxW, height: boxH }}>
|
||||||
className="hover-card"
|
<img
|
||||||
src={card.image_uri}
|
className="hover-card-img"
|
||||||
alt={card.name}
|
src={card.image_uri}
|
||||||
style={{ left, top, width: PREVIEW_W }}
|
alt={card.name}
|
||||||
/>
|
style={
|
||||||
|
card.horizontal
|
||||||
|
? {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
width: PREVIEW_W,
|
||||||
|
height: PREVIEW_H,
|
||||||
|
transform: "translate(-50%, -50%) rotate(90deg)",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image —
|
||||||
|
// the raw file is portrait (488x680); rotated 90° it reads as 680x488.
|
||||||
|
const ROTATED_ASPECT = 680 / 488;
|
||||||
|
|
||||||
|
/** The largest landscape box (matching `.preview-modal-img`'s own
|
||||||
|
* `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated
|
||||||
|
* horizontal-card preview. Recomputed on resize so it stays live like the
|
||||||
|
* CSS-only sizing used for normal (portrait) cards. */
|
||||||
|
function useRotatedPreviewBox() {
|
||||||
|
const compute = () => {
|
||||||
|
const boundW = Math.min(720, window.innerWidth * 0.9);
|
||||||
|
const boundH = window.innerHeight * 0.9;
|
||||||
|
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
|
||||||
|
return { w, h: w / ROTATED_ASPECT };
|
||||||
|
};
|
||||||
|
const [box, setBox] = useState(compute);
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setBox(compute());
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
function ClickPreviewModal({
|
function ClickPreviewModal({
|
||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -780,6 +822,7 @@ function ClickPreviewModal({
|
|||||||
onFlip: () => void;
|
onFlip: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { card, showBack } = state;
|
const { card, showBack } = state;
|
||||||
|
const rotatedBox = useRotatedPreviewBox();
|
||||||
const hasBack = !!card.back_image_uri;
|
const hasBack = !!card.back_image_uri;
|
||||||
// Both faces are always in the DOM when the card has one; a wide-viewport
|
// Both faces are always in the DOM when the card has one; a wide-viewport
|
||||||
// media query shows them side by side (no flip needed), while a narrow one
|
// media query shows them side by side (no flip needed), while a narrow one
|
||||||
@@ -791,11 +834,28 @@ function ClickPreviewModal({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="preview-modal-faces">
|
<div className="preview-modal-faces">
|
||||||
<img
|
{/* Only the front face can be horizontal (Battles) — a transformed
|
||||||
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
Battle's back face is always a normal, correctly-oriented
|
||||||
src={card.image_uri}
|
creature card, per `CardRef.horizontal`'s doc comment. */}
|
||||||
alt={card.name}
|
{card.horizontal ? (
|
||||||
/>
|
<div
|
||||||
|
className={`preview-modal-img preview-modal-img-rotated ${showBack ? "" : "active"}`}
|
||||||
|
style={{ width: rotatedBox.w, height: rotatedBox.h }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="preview-modal-rotate-img"
|
||||||
|
src={card.image_uri}
|
||||||
|
alt={card.name}
|
||||||
|
style={{ width: rotatedBox.h, height: rotatedBox.w }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
||||||
|
src={card.image_uri}
|
||||||
|
alt={card.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasBack && (
|
{hasBack && (
|
||||||
<img
|
<img
|
||||||
className={`preview-modal-img ${showBack ? "active" : ""}`}
|
className={`preview-modal-img ${showBack ? "active" : ""}`}
|
||||||
@@ -848,6 +908,13 @@ function primaryType(typeLine: string): string {
|
|||||||
return words[words.length - 1] || "Other";
|
return words[words.length - 1] || "Other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order
|
||||||
|
* type groups and to sort cards by type. */
|
||||||
|
function typeRank(primary: string): number {
|
||||||
|
const i = TYPE_PRIORITY.indexOf(primary);
|
||||||
|
return i === -1 ? TYPE_PRIORITY.length : i;
|
||||||
|
}
|
||||||
|
|
||||||
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
|
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
|
||||||
* by (type, subtype) rather than colliding on a subtype shared across types. */
|
* by (type, subtype) rather than colliding on a subtype shared across types. */
|
||||||
function subtypeLabel(typeLine: string): string {
|
function subtypeLabel(typeLine: string): string {
|
||||||
@@ -887,11 +954,7 @@ export function groupCards<T extends CardRef>(
|
|||||||
|
|
||||||
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
|
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
|
||||||
if (mode === "type") {
|
if (mode === "type") {
|
||||||
const rank = (l: string) => {
|
groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label));
|
||||||
const i = TYPE_PRIORITY.indexOf(l);
|
|
||||||
return i === -1 ? TYPE_PRIORITY.length : i;
|
|
||||||
};
|
|
||||||
groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label));
|
|
||||||
} else if (mode === "cmc") {
|
} else if (mode === "cmc") {
|
||||||
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
|
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
|
||||||
} else {
|
} else {
|
||||||
@@ -922,6 +985,86 @@ export function GroupBySelect({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sorting ----------------------------------------------------------
|
||||||
|
|
||||||
|
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
|
||||||
|
|
||||||
|
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "mv", label: "Mana value" },
|
||||||
|
{ value: "color", label: "Color identity" },
|
||||||
|
{ value: "type", label: "Card type" },
|
||||||
|
{ value: "qty", label: "Quantity owned" },
|
||||||
|
{ value: "price", label: "Price" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
/** Sort cards by the chosen attribute; ties always break by name so the
|
||||||
|
* order stays stable and predictable regardless of `mode`. Grouping (see
|
||||||
|
* [`groupCards`]) determines section order independently — sort this array
|
||||||
|
* first, then group it, and each group keeps this order internally. */
|
||||||
|
export function sortCards<T extends CardRef & { quantity_owned: number }>(
|
||||||
|
cards: T[],
|
||||||
|
mode: SortMode,
|
||||||
|
dir: SortDir,
|
||||||
|
): T[] {
|
||||||
|
const sorted = [...cards].sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
mode === "name"
|
||||||
|
? a.name.localeCompare(b.name)
|
||||||
|
: mode === "mv"
|
||||||
|
? a.mana_value - b.mana_value || a.name.localeCompare(b.name)
|
||||||
|
: mode === "color"
|
||||||
|
? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) ||
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
: mode === "type"
|
||||||
|
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
: mode === "qty"
|
||||||
|
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
|
||||||
|
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
|
||||||
|
return dir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */
|
||||||
|
export function SortBySelect({
|
||||||
|
value,
|
||||||
|
dir,
|
||||||
|
onChangeValue,
|
||||||
|
onToggleDir,
|
||||||
|
}: {
|
||||||
|
value: SortMode;
|
||||||
|
dir: SortDir;
|
||||||
|
onChangeValue: (m: SortMode) => void;
|
||||||
|
onToggleDir: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="row" style={{ gap: 4 }}>
|
||||||
|
<label className="group-by">
|
||||||
|
<span className="muted">Sort by</span>
|
||||||
|
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sort-dir-btn"
|
||||||
|
onClick={onToggleDir}
|
||||||
|
title={dir === "asc" ? "Ascending — click for descending" : "Descending — click for ascending"}
|
||||||
|
>
|
||||||
|
{dir === "asc" ? "↑" : "↓"}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
|
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
|
||||||
|
|
||||||
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
|
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
|
||||||
|
|||||||
@@ -1481,10 +1481,17 @@ tbody tr:hover {
|
|||||||
behind the dialog it's in. */
|
behind the dialog it's in. */
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.hover-card-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-modal-backdrop {
|
.preview-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1527,6 +1534,29 @@ tbody tr:hover {
|
|||||||
.preview-modal-img.active {
|
.preview-modal-img.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
/* Horizontal cards (Battles/Planes/Phenomena, see `CardRef.horizontal`): the
|
||||||
|
stored image is portrait-shaped with the content itself rotated. The
|
||||||
|
wrapper's width/height (landscape) and the inner img's width/height
|
||||||
|
(the same box transposed, pre-rotation) are computed in JS —
|
||||||
|
`useRotatedPreviewBox` — and passed in as inline styles; this just
|
||||||
|
positions/rotates the img to fill the wrapper exactly. */
|
||||||
|
.preview-modal-img-rotated {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Override `.preview-modal-img`'s portrait-oriented max-width/max-height —
|
||||||
|
they'd otherwise clamp this wrapper's JS-computed (correctly landscape)
|
||||||
|
inline width/height down, while the un-clamped rotated img inside stays
|
||||||
|
at its full computed size and overflows, getting cut off by the
|
||||||
|
`overflow: hidden` above instead of shrinking to fit. */
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.preview-modal-rotate-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
}
|
||||||
/* Wide enough to show a double-faced card's front and back side by side —
|
/* Wide enough to show a double-faced card's front and back side by side —
|
||||||
no flip needed. */
|
no flip needed. */
|
||||||
@media (min-width: 860px) {
|
@media (min-width: 860px) {
|
||||||
@@ -1538,6 +1568,14 @@ tbody tr:hover {
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: min(430px, 43vw);
|
max-width: min(430px, 43vw);
|
||||||
}
|
}
|
||||||
|
/* A horizontal front face (e.g. a transformed Battle) shown side-by-side
|
||||||
|
with its back face: keep using the JS-computed inline width/height
|
||||||
|
(see `.preview-modal-img-rotated` above) rather than the narrower DFC
|
||||||
|
column width, which would clamp the wrapper and crop the rotated image
|
||||||
|
the same way the base max-width/max-height would. */
|
||||||
|
.preview-modal-dfc .preview-modal-img-rotated {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
.preview-modal-dfc .preview-modal-flip {
|
.preview-modal-dfc .preview-modal-flip {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1707,6 +1745,16 @@ tbody tr:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.sort-dir-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
|
|||||||
391
src/views/CardSearch.tsx
Normal file
391
src/views/CardSearch.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||||||
|
import {
|
||||||
|
CardThumb,
|
||||||
|
CardTile,
|
||||||
|
ColorPips,
|
||||||
|
ColorToggles,
|
||||||
|
GroupBySelect,
|
||||||
|
GroupMode,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
QtyStepper,
|
||||||
|
SortBySelect,
|
||||||
|
SortDir,
|
||||||
|
SortMode,
|
||||||
|
TagInput,
|
||||||
|
ViewToggle,
|
||||||
|
groupCards,
|
||||||
|
sortCards,
|
||||||
|
usePersistedState,
|
||||||
|
useViewMode,
|
||||||
|
} from "../components";
|
||||||
|
|
||||||
|
// Server-side cap on results — this browses the whole ~30k-card reference
|
||||||
|
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
||||||
|
const CAP = 300;
|
||||||
|
|
||||||
|
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse the whole downloaded card universe (every card in reference.db,
|
||||||
|
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
||||||
|
* list/tiles machinery. Results are annotated with ownership: an "Owned ×N"
|
||||||
|
* stepper and any decks the card is already in.
|
||||||
|
*
|
||||||
|
* `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the
|
||||||
|
* "Add to deck" undo is also reachable from the command palette.
|
||||||
|
*/
|
||||||
|
export function CardSearch({
|
||||||
|
onOfferUndo,
|
||||||
|
}: {
|
||||||
|
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [commander, setCommander] = useState(0);
|
||||||
|
const [colorsInclude, setColorsInclude] = useState(0);
|
||||||
|
const [cmcMin, setCmcMin] = useState("");
|
||||||
|
const [cmcMax, setCmcMax] = useState("");
|
||||||
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
|
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||||||
|
const [keywords, setKeywords] = useState<string[]>([]);
|
||||||
|
const [oracleText, setOracleText] = useState("");
|
||||||
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||||||
|
|
||||||
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
|
const [deckId, setDeckId] = useState<number | "">("");
|
||||||
|
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const [view, setView] = useViewMode("cardsearch-view");
|
||||||
|
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");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listDecks().then(setDecks);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasActiveFilter =
|
||||||
|
text.trim() !== "" ||
|
||||||
|
commander !== 0 ||
|
||||||
|
colorsInclude !== 0 ||
|
||||||
|
cmcMin !== "" ||
|
||||||
|
cmcMax !== "" ||
|
||||||
|
types.length > 0 ||
|
||||||
|
subtypes.length > 0 ||
|
||||||
|
keywords.length > 0 ||
|
||||||
|
oracleText.trim() !== "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveFilter) {
|
||||||
|
setRows([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filter: LibraryFilter = {
|
||||||
|
text: text.trim() || null,
|
||||||
|
commander_identity: commander || null,
|
||||||
|
colors_include: colorsInclude || null,
|
||||||
|
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||||||
|
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||||||
|
types,
|
||||||
|
subtypes,
|
||||||
|
keywords,
|
||||||
|
oracle_text: oracleText.trim() || null,
|
||||||
|
};
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.searchReference(filter, CAP)
|
||||||
|
.then((r) => {
|
||||||
|
setRows(r);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setRows([]);
|
||||||
|
setError(String(e));
|
||||||
|
});
|
||||||
|
}, 120);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [
|
||||||
|
hasActiveFilter,
|
||||||
|
text,
|
||||||
|
commander,
|
||||||
|
colorsInclude,
|
||||||
|
cmcMin,
|
||||||
|
cmcMax,
|
||||||
|
types,
|
||||||
|
subtypes,
|
||||||
|
keywords,
|
||||||
|
oracleText,
|
||||||
|
refreshKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function adjust(oracleId: string, delta: number) {
|
||||||
|
const qty = await api.addOwned(oracleId, delta);
|
||||||
|
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one copy of `c` to the deck picked in the toolbar. Snapshots the
|
||||||
|
* card's prior deck_cards row (or absence of one) first and offers an
|
||||||
|
* "Undo" that restores exactly that prior state — same pattern as
|
||||||
|
* `LibraryBrowser.bulkAddToDeck`.
|
||||||
|
*/
|
||||||
|
async function addToDeck(c: LibraryCard) {
|
||||||
|
if (deckId === "") return;
|
||||||
|
const deck = decks.find((d) => d.id === deckId);
|
||||||
|
if (!deck) return;
|
||||||
|
setBusy((b) => new Set(b).add(c.oracle_id));
|
||||||
|
try {
|
||||||
|
const before = await api.deckContents(deck.id);
|
||||||
|
const prev = before.find((e) => e.oracle_id === c.oracle_id);
|
||||||
|
await api.setDeckCard(deck.id, c.oracle_id, 1, false, false);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => {
|
||||||
|
await api.setDeckCard(
|
||||||
|
deck.id,
|
||||||
|
c.oracle_id,
|
||||||
|
prev?.quantity ?? 0,
|
||||||
|
prev?.is_commander ?? false,
|
||||||
|
prev?.is_maybeboard ?? false,
|
||||||
|
);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy((b) => {
|
||||||
|
const next = new Set(b);
|
||||||
|
next.delete(c.oracle_id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refTable(cards: LibraryCard[]) {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Colors</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>MV</th>
|
||||||
|
<th>Owned</th>
|
||||||
|
<th>In decks</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<tr key={c.oracle_id}>
|
||||||
|
<td>
|
||||||
|
<CardThumb card={c} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ColorPips mask={c.color_identity} />
|
||||||
|
</td>
|
||||||
|
<td className="card-name">{c.name}</td>
|
||||||
|
<td className="card-type">{c.type_line}</td>
|
||||||
|
<td>{c.mana_value}</td>
|
||||||
|
<td>
|
||||||
|
<QtyStepper
|
||||||
|
value={c.quantity_owned}
|
||||||
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||||
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{c.decks.map((d) => (
|
||||||
|
<span key={d} className="deck-chip">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
onClick={() => addToDeck(c)}
|
||||||
|
disabled={deckId === "" || busy.has(c.oracle_id)}
|
||||||
|
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
|
||||||
|
>
|
||||||
|
Add to deck
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refTiles(cards: LibraryCard[]) {
|
||||||
|
return (
|
||||||
|
<div className="tile-grid">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<CardTile
|
||||||
|
key={c.oracle_id}
|
||||||
|
card={c}
|
||||||
|
corner={
|
||||||
|
<QtyStepper
|
||||||
|
overlay
|
||||||
|
value={c.quantity_owned}
|
||||||
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||||
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
cornerBottomLeft={
|
||||||
|
<IconButton
|
||||||
|
icon={Icons.cards}
|
||||||
|
label="Add to deck"
|
||||||
|
onClick={() => addToDeck(c)}
|
||||||
|
disabled={deckId === "" || busy.has(c.oracle_id)}
|
||||||
|
overlay
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
c.decks.length > 0
|
||||||
|
? c.decks.map((d) => (
|
||||||
|
<span key={d} className="deck-chip">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
|
<h2 style={{ margin: 0 }}>
|
||||||
|
Card Search {hasActiveFilter && <span className="badge">{rows.length}</span>}
|
||||||
|
</h2>
|
||||||
|
<div className="row" style={{ gap: 16 }}>
|
||||||
|
<select
|
||||||
|
value={deckId}
|
||||||
|
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Add to deck…</option>
|
||||||
|
{decks.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
|
<SortBySelect
|
||||||
|
value={sortBy}
|
||||||
|
dir={sortDir}
|
||||||
|
onChangeValue={setSortBy}
|
||||||
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||||
|
/>
|
||||||
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filters">
|
||||||
|
<div className="field">
|
||||||
|
<label>Name contains</label>
|
||||||
|
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Playable in commander (color identity ⊆)</label>
|
||||||
|
<ColorToggles value={commander} onChange={setCommander} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Includes colors</label>
|
||||||
|
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Mana value</label>
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
style={{ width: 70 }}
|
||||||
|
placeholder="min"
|
||||||
|
value={cmcMin}
|
||||||
|
onChange={(e) => setCmcMin(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="muted">–</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
style={{ width: 70 }}
|
||||||
|
placeholder="max"
|
||||||
|
value={cmcMax}
|
||||||
|
onChange={(e) => setCmcMax(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Type(s)</label>
|
||||||
|
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Subtype(s)</label>
|
||||||
|
<TagInput
|
||||||
|
values={subtypes}
|
||||||
|
onChange={setSubtypes}
|
||||||
|
suggestions={facets.subtypes}
|
||||||
|
placeholder="Elf…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Keyword(s)</label>
|
||||||
|
<TagInput
|
||||||
|
values={keywords}
|
||||||
|
onChange={setKeywords}
|
||||||
|
suggestions={facets.keywords}
|
||||||
|
placeholder="Flying…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Oracle text</label>
|
||||||
|
<input
|
||||||
|
value={oracleText}
|
||||||
|
onChange={(e) => setOracleText(e.target.value)}
|
||||||
|
placeholder="e.g. landfall"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasActiveFilter ? (
|
||||||
|
<p className="hint">
|
||||||
|
Search the full card database — type a name or choose filters below to get started.
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="hint">{error}</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="hint">No cards match your filters.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rows.length === CAP && (
|
||||||
|
<p className="hint">
|
||||||
|
Showing the first {CAP} matches — refine your search to narrow it down.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
||||||
|
{g.label && (
|
||||||
|
<h3 className="group-header">
|
||||||
|
{g.label} <span className="badge">{g.cards.length}</span>
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{view === "list" ? refTable(g.cards) : refTiles(g.cards)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ import {
|
|||||||
LocationField,
|
LocationField,
|
||||||
PromptDialog,
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
|
SortBySelect,
|
||||||
|
SortDir,
|
||||||
|
SortMode,
|
||||||
TagInput,
|
TagInput,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
|
sortCards,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
@@ -56,6 +60,8 @@ export function LibraryBrowser({
|
|||||||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||||||
const [view, setView] = useViewMode("library-view");
|
const [view, setView] = useViewMode("library-view");
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
||||||
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
|
||||||
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
||||||
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
@@ -389,7 +395,7 @@ export function LibraryBrowser({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = groupCards(rows, groupBy);
|
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -414,6 +420,12 @@ export function LibraryBrowser({
|
|||||||
<div className="row" style={{ gap: 16 }}>
|
<div className="row" style={{ gap: 16 }}>
|
||||||
<button onClick={pickImportFile}>Import cards…</button>
|
<button onClick={pickImportFile}>Import cards…</button>
|
||||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
|
<SortBySelect
|
||||||
|
value={sortBy}
|
||||||
|
dir={sortDir}
|
||||||
|
onChangeValue={setSortBy}
|
||||||
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||||
|
/>
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user