diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 18bc5bd..0246868 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -112,7 +112,7 @@ impl Catalog { let mut stmt = self.conn.prepare(&format!( "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.type_line, {image}, {back} + oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless FROM deck_cards dc 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 @@ -136,6 +136,8 @@ impl Catalog { type_line: r.get(8)?, image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(10)?.unwrap_or_default(), + produced_mana: ColorSet(r.get::<_, i64>(11)? as u8), + produces_colorless: r.get::<_, i64>(12)? != 0, }, }) })? diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index b6fe720..127c8ad 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -18,7 +18,8 @@ impl Catalog { pub fn search_library(&self, filter: &LibraryFilter) -> Result> { let mut sql = format!( "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 FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0", @@ -85,6 +86,10 @@ impl Catalog { sql.push_str(" AND LOWER(lc.location) LIKE ?"); 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"); @@ -265,4 +270,38 @@ mod tests { }).unwrap(); assert_eq!(all.len(), 5); } + + #[test] + fn oracle_text_filter_is_case_insensitive_substring() { + let fx = owned_fixture(); + let cat = &fx.catalog; + + // "Add {G}." (Llanowar Elves) and "Add {C}{C}." (Sol Ring) both mention "add". + let add_matches = names(&cat.search_library(&LibraryFilter { + oracle_text: Some("add".into()), + ..Default::default() + }).unwrap()); + assert_eq!(add_matches, vec!["Llanowar Elves", "Sol Ring"]); + + // Case-insensitive. + let damage = names(&cat.search_library(&LibraryFilter { + oracle_text: Some("DAMAGE".into()), + ..Default::default() + }).unwrap()); + assert_eq!(damage, vec!["Lightning Bolt"]); + + // Deathrite Shaman has empty oracle_text in the fixture, so it never matches. + let none = cat.search_library(&LibraryFilter { + oracle_text: Some("nonexistent ability".into()), + ..Default::default() + }).unwrap(); + assert!(none.is_empty()); + + // Empty/whitespace text is treated as "no filter". + let all = cat.search_library(&LibraryFilter { + oracle_text: Some(" ".into()), + ..Default::default() + }).unwrap(); + assert_eq!(all.len(), 5); + } } diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index e2b95ea..6d9c530 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -430,6 +430,10 @@ struct ScryOracle { keywords: Vec, #[serde(default)] card_faces: Option>, + /// What colors of mana this card can add (lands, mana rocks, dorks). A + /// top-level-only Scryfall field — not per-face, even on DFCs. + #[serde(default)] + produced_mana: Option>, } #[derive(Deserialize)] @@ -438,6 +442,8 @@ struct ScryFace { type_line: Option, #[serde(default)] colors: Option>, + #[serde(default)] + oracle_text: Option, } fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result { @@ -446,8 +452,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) { let mut ins_card = conn.prepare( "INSERT OR IGNORE INTO oracle_cards - (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text, + produced_mana,produces_colorless) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; @@ -502,6 +509,30 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) .unwrap_or_default() }); + // Oracle text: top-level, else join faces — double-faced cards (MDFC, + // transform, etc.) never carry a top-level oracle_text in Scryfall's + // data, only per-face text. + let oracle_text = c.oracle_text.clone().unwrap_or_else(|| { + c.card_faces + .as_ref() + .map(|faces| { + faces + .iter() + .filter_map(|f| f.oracle_text.clone()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() + }); + + // Mana production: colored letters map to the WUBRG bitmask (same as + // colors/identity); "C" (colorless) is tracked separately since it + // isn't a WUBRG bit. Top-level only — Scryfall doesn't put this on + // individual card_faces. + let produced = c.produced_mana.as_deref().unwrap_or(&[]); + let produced_mana = ColorSet::from_letters(produced); + let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C")); + let changed = ins_card.execute(params![ oracle_id, c.name, @@ -510,7 +541,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) colors.0 as i64, identity.0 as i64, type_line, - c.oracle_text.clone().unwrap_or_default(), + oracle_text, + produced_mana.0 as i64, + produces_colorless as i64, ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { @@ -797,6 +830,83 @@ mod tests { assert_eq!(g as u8, crate::model::G); } + #[test] + fn double_faced_cards_fall_back_to_card_faces_for_type_colors_and_text() { + // Modal DFCs (and transform cards) never carry a top-level type_line, + // colors, or oracle_text in Scryfall's oracle-cards bulk — only + // per-face fields. Regression test for a bug where oracle_text had no + // fallback (unlike type_line/colors), silently leaving every + // double-faced card's oracle_text empty and unsearchable. + let oracle = r#"[ + {"oracle_id":"oid-dfc","name":"Elesh Norn // The Argent Etchings", + "cmc":5.0,"color_identity":["W"],"keywords":[], + "card_faces":[ + {"type_line":"Legendary Creature — Phyrexian Praetor","colors":["W"], + "oracle_text":"Whenever a source an opponent controls deals damage to you or a permanent you control, exile that source."}, + {"type_line":"Battle — Siege","colors":[], + "oracle_text":"When you defeated The Argent Etchings, draw a card."} + ]} + ]"#; + + 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 (type_line, colors, oracle_text): (String, i64, String) = conn + .query_row( + "SELECT type_line, colors, oracle_text FROM oracle_cards WHERE oracle_id='oid-dfc'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + + assert_eq!(type_line, "Legendary Creature — Phyrexian Praetor // Battle — Siege"); + assert_eq!(colors as u8, crate::model::W); + assert!(oracle_text.contains("an opponent controls deals damage")); + assert!(oracle_text.contains("draw a card")); + } + + #[test] + fn captures_produced_mana_including_colorless() { + let oracle = r#"[ + {"oracle_id":"oid-dual","name":"Overgrown Tomb","cmc":0.0, + "color_identity":[],"keywords":[],"produced_mana":["B","G"]}, + {"oracle_id":"oid-wastes","name":"Wastes","cmc":0.0, + "color_identity":[],"keywords":[],"produced_mana":["C"]}, + {"oracle_id":"oid-vanilla","name":"Grizzly Bears","cmc":2.0, + "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 fetch = |id: &str| -> (i64, i64) { + conn.query_row( + "SELECT produced_mana, produces_colorless FROM oracle_cards WHERE oracle_id=?1", + params![id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + }; + + let (dual_mana, dual_colorless) = fetch("oid-dual"); + assert_eq!(dual_mana as u8, crate::model::B | crate::model::G); + assert_eq!(dual_colorless, 0); + + let (wastes_mana, wastes_colorless) = fetch("oid-wastes"); + assert_eq!(wastes_mana, 0); // "C" isn't a WUBRG bit + assert_eq!(wastes_colorless, 1); + + let (vanilla_mana, vanilla_colorless) = fetch("oid-vanilla"); + assert_eq!(vanilla_mana, 0); + assert_eq!(vanilla_colorless, 0); // no produced_mana field at all + } + #[test] fn captures_back_face_image_for_double_faced_cards() { // Transform/MDFC cards: two `card_faces`, each with its own image. diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 2eb337f..d81c772 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -19,7 +19,9 @@ use std::path::Path; use rusqlite::Connection; pub use error::{CoreError, Result}; -pub use model::{CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFilter, LibraryStats}; +pub use model::{ + CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats, +}; pub use search::NameIndex; /// A handle to an open catalog: the library db with the reference db attached. @@ -66,7 +68,7 @@ pub(crate) mod test_support { } /// (oracle_id, name, mana_value, colors, color_identity, type_line, - /// types, subtypes, keywords) + /// types, subtypes, keywords, oracle_text) pub struct SeedCard { pub oracle_id: &'static str, pub name: &'static str, @@ -77,6 +79,7 @@ pub(crate) mod test_support { pub types: &'static [&'static str], pub subtypes: &'static [&'static str], pub keywords: &'static [&'static str], + pub oracle_text: &'static str, } pub fn seed_cards() -> Vec { @@ -91,6 +94,7 @@ pub(crate) mod test_support { types: &["Creature"], subtypes: &["Elf", "Druid"], keywords: &[], + oracle_text: "{T}: Add {G}.", }, SeedCard { oracle_id: "deathrite-shaman", @@ -102,6 +106,7 @@ pub(crate) mod test_support { types: &["Creature"], subtypes: &["Elf", "Shaman"], keywords: &[], + oracle_text: "", }, SeedCard { oracle_id: "serra-angel", @@ -113,6 +118,7 @@ pub(crate) mod test_support { types: &["Creature"], subtypes: &["Angel"], keywords: &["Flying", "Vigilance"], + oracle_text: "Flying, vigilance", }, SeedCard { oracle_id: "lightning-bolt", @@ -124,6 +130,7 @@ pub(crate) mod test_support { types: &["Instant"], subtypes: &[], keywords: &[], + oracle_text: "Lightning Bolt deals 3 damage to any target.", }, SeedCard { oracle_id: "sol-ring", @@ -135,6 +142,7 @@ pub(crate) mod test_support { types: &["Artifact"], subtypes: &[], keywords: &[], + oracle_text: "{T}: Add {C}{C}.", }, ] } @@ -153,8 +161,8 @@ pub(crate) mod test_support { rconn .execute( "INSERT INTO oracle_cards - (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line) - VALUES (?1,?2,?3,?4,?5,?6,?7)", + (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", rusqlite::params![ c.oracle_id, c.name, @@ -162,7 +170,8 @@ pub(crate) mod test_support { c.mv, colors, identity, - c.type_line + c.type_line, + c.oracle_text, ], ) .unwrap(); diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index c2b1528..788c7a7 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -6,12 +6,13 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet, LibraryCard, LibraryStats, WUBRG}; +use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; use crate::Catalog; /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, -/// colors, color_identity, type_line, image_uri, back_image_uri)` into its -/// library parts. Shared by [`Catalog::list_library`] and the criteria search. +/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana, +/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`] +/// and the criteria search. pub(crate) fn row_to_library_parts( r: &rusqlite::Row, ) -> rusqlite::Result<(String, i64, String, CardRef)> { @@ -29,6 +30,8 @@ pub(crate) fn row_to_library_parts( type_line: r.get(7)?, image_uri: r.get::<_, Option>(8)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), + produced_mana: ColorSet(r.get::<_, i64>(10)? as u8), + produces_colorless: r.get::<_, i64>(11)? != 0, }, )) } @@ -70,7 +73,8 @@ impl Catalog { let mut stmt = self.conn.prepare(&format!( "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 FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0 @@ -144,6 +148,29 @@ impl Catalog { }) } + /// Distinct type/subtype/keyword values among owned cards, for filter + /// autocomplete. + pub fn library_facets(&self) -> Result { + Ok(LibraryFacets { + types: self.distinct_owned_facet("card_types", "type")?, + subtypes: self.distinct_owned_facet("card_subtypes", "subtype")?, + keywords: self.distinct_owned_facet("card_keywords", "keyword")?, + }) + } + + /// `table`/`column` are always one of the three hardcoded call sites above + /// (never user input), so interpolating them into the query is safe. + fn distinct_owned_facet(&self, table: &str, column: &str) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT DISTINCT x.{column} + FROM {REF_DB}.{table} x + WHERE x.oracle_id IN (SELECT oracle_id FROM library_cards WHERE quantity_owned > 0) + ORDER BY x.{column} COLLATE NOCASE" + ))?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + Ok(rows.collect::, _>>()?) + } + /// Set the free-text storage location for a card (creates the library row if /// absent). Empty string clears it. pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> { @@ -207,6 +234,22 @@ mod tests { assert_eq!(sol.card.image_uri, "http://img/sol-ring"); } + #[test] + fn library_facets_only_include_owned_cards() { + let fx = test_support::make(); + let cat = &fx.catalog; + // Own llanowar-elves (Creature — Elf Druid) and lightning-bolt + // (Instant); leave serra-angel (Creature — Angel, Flying/Vigilance) + // unowned. + cat.add_owned("llanowar-elves", 1).unwrap(); + cat.add_owned("lightning-bolt", 1).unwrap(); + + let facets = cat.library_facets().unwrap(); + assert_eq!(facets.types, vec!["Creature", "Instant"]); + assert_eq!(facets.subtypes, vec!["Druid", "Elf"]); + assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel + } + #[test] fn library_stats_aggregates_counts_and_colors() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 984830c..8879f7b 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -91,6 +91,14 @@ pub struct CardRef { /// URL of the *back face* image for the same printing (transform / modal /// double-faced / reversible cards), or empty for single-faced cards. pub back_image_uri: String, + /// WUBRG bitmask of colors this card can *produce* (lands, mana rocks, + /// dorks, etc.) — independent of `colors`/`color_identity`, which + /// describe what color the card *is*, not what it taps for. + pub produced_mana: ColorSet, + /// Whether this card can produce colorless mana (Scryfall's + /// `produced_mana` including "C"), tracked separately since colorless + /// isn't a WUBRG bit. + pub produces_colorless: bool, } /// A library row: a card the user owns, with quantity and its deck memberships. @@ -155,6 +163,16 @@ pub struct LibraryStats { pub colorless_count: i64, } +/// Distinct type/subtype/keyword values found among *owned* cards, sorted. +/// Powers autocomplete for the library filter panel — e.g. no point +/// suggesting "Phyrexian" as a subtype if you don't own any. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LibraryFacets { + pub types: Vec, + pub subtypes: Vec, + pub keywords: Vec, +} + /// Criteria for searching the owned library. All fields are optional; an empty /// filter matches every owned card. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -184,4 +202,6 @@ pub struct LibraryFilter { /// Case-insensitive substring match against the card's stored location /// (e.g. "Box 1"). Cards with no location set never match a non-empty value. pub location: Option, + /// Case-insensitive substring match against the card's oracle rules text. + pub oracle_text: Option, } diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index 3c0762e..23c69ab 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -34,7 +34,7 @@ impl NameIndex { pub fn build(conn: &Connection) -> Result { let mut stmt = conn.prepare(&format!( "SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, - oc.type_line, {image}, {back} + oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless FROM {REF_DB}.oracle_cards oc", image = crate::db::image_subquery("image_uri"), back = crate::db::image_subquery("back_image_uri") @@ -51,6 +51,8 @@ impl NameIndex { type_line: r.get(5)?, image_uri: r.get::<_, Option>(6)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(7)?.unwrap_or_default(), + produced_mana: ColorSet(r.get::<_, i64>(8)? as u8), + produces_colorless: r.get::<_, i64>(9)? != 0, }, }) })? diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 9f535e0..1dd9b49 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -10,7 +10,9 @@ CREATE TABLE IF NOT EXISTS oracle_cards ( colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask type_line TEXT NOT NULL DEFAULT '', - oracle_text TEXT NOT NULL DEFAULT '' + oracle_text TEXT NOT NULL DEFAULT '', + produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks) + produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C} ); 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); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3abcaa1..0e2bafb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::sync::Mutex; -use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFilter, LibraryStats}; +use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats}; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; @@ -142,6 +142,12 @@ pub fn get_library_stats(db: Db) -> Res { s.catalog()?.library_stats().map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_library_facets(db: Db) -> Res { + let s = db.lock().unwrap(); + s.catalog()?.library_facets().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_decks(db: Db) -> Res> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0b8247..ba77e81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ pub fn run() { commands::set_location, commands::search_library, commands::get_library_stats, + commands::get_library_facets, commands::list_decks, commands::create_deck, commands::delete_deck, diff --git a/src/App.tsx b/src/App.tsx index 23d0c83..6a1b082 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,14 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api"; -import { HoverPreviewProvider, IconButton, Icons, usePersistedState } from "./components"; +import { + ColorDistributionBar, + HoverPreviewProvider, + IconButton, + Icons, + StatTile, + usePersistedState, +} from "./components"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { DeckView } from "./views/DeckView"; @@ -284,49 +291,15 @@ export default function App() {
Loading…
) : ( <> -
-
- - {libStats.unique_cards.toLocaleString()} - - unique -
-
- - {libStats.total_copies.toLocaleString()} - - copies -
-
- - {libStats.deck_count.toLocaleString()} - - decks -
+
+ + +
- {libStats.total_copies > 0 && ( -
- {( - [ - ["W", libStats.color_counts[0]], - ["U", libStats.color_counts[1]], - ["B", libStats.color_counts[2]], - ["R", libStats.color_counts[3]], - ["G", libStats.color_counts[4]], - ["C", libStats.colorless_count], - ] as [string, number][] - ).map(([code, count]) => - count > 0 ? ( - - ) : null, - )} -
- )} + )}
diff --git a/src/api.ts b/src/api.ts index f29d118..6f85f46 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,6 +12,8 @@ export interface CardRef { type_line: string; image_uri: string; // Scryfall "normal" image URL, or "" if unavailable back_image_uri: string; // back face for transform/MDFC/reversible cards, else "" + produced_mana: number; // WUBRG bitmask of mana this card can tap for + produces_colorless: boolean; // can produce {C} } export interface LibraryCard extends CardRef { @@ -47,6 +49,7 @@ export interface LibraryFilter { keywords?: string[]; in_deck?: number | null; location?: string | null; + oracle_text?: string | null; } export interface Status { @@ -65,6 +68,12 @@ export interface LibraryStats { colorless_count: number; } +export interface LibraryFacets { + types: string[]; + subtypes: string[]; + keywords: string[]; +} + export interface IngestStageInfo { id: string; label: string; @@ -115,6 +124,7 @@ export const api = { searchLibrary: (filter: LibraryFilter) => invoke("search_library", { filter }), getLibraryStats: () => invoke("get_library_stats"), + getLibraryFacets: () => invoke("get_library_facets"), listDecks: () => invoke("list_decks"), createDeck: (name: string, commanderOracleId: string | null) => diff --git a/src/components.tsx b/src/components.tsx index dda6517..7b47bd3 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1,5 +1,6 @@ import { createContext, + forwardRef, ReactNode, useCallback, useContext, @@ -341,27 +342,27 @@ export function CardThumb({ card }: { card: CardRef }) { * A search input plus a results dropdown with keyboard navigation: * ↑/↓ move the highlight, Enter picks, mouse hover/click also work. * `value`/`onChange` are controlled by the caller (which runs the search). + * Forwards a ref to the underlying `` so callers can focus it + * imperatively (e.g. redirecting a stray keystroke into the search box). */ -export function CardSearchBox({ - placeholder, - value, - onChange, - results, - onPick, - autoFocus, - big, -}: { - placeholder: string; - value: string; - onChange: (v: string) => void; - results: CardRef[]; - onPick: (card: CardRef) => void; - autoFocus?: boolean; - /** Larger, more prominent input (used for the primary Add Cards field). */ - big?: boolean; -}) { +export const CardSearchBox = forwardRef< + HTMLInputElement, + { + placeholder: string; + value: string; + onChange: (v: string) => void; + results: CardRef[]; + onPick: (card: CardRef) => void; + autoFocus?: boolean; + /** Larger, more prominent input (used for the primary Add Cards field). */ + big?: boolean; + } +>(function CardSearchBox( + { placeholder, value, onChange, results, onPick, autoFocus, big }, + forwardedRef, +) { const [sel, setSel] = useState(0); - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => setSel(0), [results]); // Keep focus in the box after a pick so cards can be added in a rapid streak. @@ -386,7 +387,11 @@ export function CardSearchBox({ return ( <> { + inputRef.current = el; + if (typeof forwardedRef === "function") forwardedRef(el); + else if (forwardedRef) (forwardedRef as React.MutableRefObject).current = el; + }} autoFocus={autoFocus} style={{ width: "100%", @@ -416,6 +421,110 @@ export function CardSearchBox({ )} ); +}); + +/** + * A chip-based multi-value text filter with autocomplete: selected values + * render as removable chips, and typing filters `suggestions` into a + * dropdown (↑/↓ to move, Enter to pick — or Enter with no match adds the + * typed text verbatim, so it's not limited to only-suggested values). + * Backspace on an empty input quick-removes the last chip. + */ +export function TagInput({ + values, + onChange, + suggestions, + placeholder, +}: { + values: string[]; + onChange: (values: string[]) => void; + suggestions: string[]; + placeholder?: string; +}) { + const [text, setText] = useState(""); + const [sel, setSel] = useState(0); + + const query = text.trim().toLowerCase(); + const filtered = query + ? suggestions + .filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase())) + .filter((s) => s.toLowerCase().includes(query)) + .slice(0, 8) + : []; + + useEffect(() => setSel(0), [text]); + + function addValue(v: string) { + const trimmed = v.trim(); + if (!trimmed) return; + if (!values.some((x) => x.toLowerCase() === trimmed.toLowerCase())) { + onChange([...values, trimmed]); + } + setText(""); + } + + function removeValue(v: string) { + onChange(values.filter((x) => x !== v)); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown" && filtered.length > 0) { + e.preventDefault(); + setSel((s) => Math.min(s + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp" && filtered.length > 0) { + e.preventDefault(); + setSel((s) => Math.max(s - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (filtered[sel]) addValue(filtered[sel]); + else if (text.trim()) addValue(text); + } else if (e.key === "Backspace" && text === "" && values.length > 0) { + onChange(values.slice(0, -1)); + } else if (e.key === "Escape") { + setText(""); + } + } + + return ( +
+
+ {values.map((v) => ( + + {v} + + + ))} + setText(e.target.value)} + onKeyDown={onKeyDown} + placeholder={values.length === 0 ? placeholder : ""} + /> +
+ {filtered.length > 0 && ( +
    + {filtered.map((s, i) => ( +
  • setSel(i)} + onClick={() => addValue(s)} + > + {s} +
  • + ))} +
+ )} +
+ ); } // --- Hover + click preview ------------------------------------------------- @@ -666,6 +775,98 @@ export function GroupBySelect({ ); } +// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------ + +/** A single numeric stat in a small panel tile, e.g. "42 / unique". */ +export function StatTile({ value, label }: { value: string | number; label: string }) { + return ( +
+ {value} + {label} +
+ ); +} + +/** A segmented WUBRG(+colorless) bar sized proportionally to `colorCounts`. */ +export function ColorDistributionBar({ + colorCounts, + colorlessCount, +}: { + colorCounts: [number, number, number, number, number]; // W, U, B, R, G + colorlessCount: number; +}) { + const segs: [string, number][] = [ + ["W", colorCounts[0]], + ["U", colorCounts[1]], + ["B", colorCounts[2]], + ["R", colorCounts[3]], + ["G", colorCounts[4]], + ["C", colorlessCount], + ]; + if (segs.every(([, count]) => count === 0)) return null; + return ( +
+ {segs.map(([code, count]) => + count > 0 ? ( + + ) : null, + )} +
+ ); +} + +/** A mana-value histogram (bar height proportional to the largest bucket), + * each bar segmented by color like {@link ColorDistributionBar}. */ +export function ManaCurve({ + buckets, +}: { + buckets: { + label: string; + count: number; + colorCounts: [number, number, number, number, number]; // W, U, B, R, G + colorlessCount: number; + }[]; +}) { + const max = Math.max(1, ...buckets.map((b) => b.count)); + return ( +
+ {buckets.map((b) => { + const segs: [string, number][] = [ + ["W", b.colorCounts[0]], + ["U", b.colorCounts[1]], + ["B", b.colorCounts[2]], + ["R", b.colorCounts[3]], + ["G", b.colorCounts[4]], + ["C", b.colorlessCount], + ]; + return ( +
+ {b.count || ""} +
+ {segs.map(([code, count]) => + count > 0 ? ( + + ) : null, + )} +
+ {b.label} +
+ ); + })} +
+ ); +} + // --- Persistence ---------------------------------------------------------- /** useState that mirrors its value to localStorage (JSON), so it survives diff --git a/src/styles.css b/src/styles.css index c9d401b..34c7f7c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -349,12 +349,14 @@ input[type="checkbox"] { color: var(--faint); padding-bottom: 10px; } -.lib-stats-numbers { +/* Generic numeric-stat tile row + segmented color bar — used by both the + sidebar "Your library" panel and the per-deck analysis panel. */ +.stat-tiles-row { display: flex; gap: 6px; margin-bottom: 10px; } -.lib-stat { +.stat-tile { flex: 1; min-width: 0; display: flex; @@ -367,25 +369,25 @@ input[type="checkbox"] { border-radius: var(--radius); transition: border-color 0.15s, transform 0.15s; } -.lib-stat:hover { +.stat-tile:hover { border-color: var(--accent-glow); transform: translateY(-1px); } -.lib-stat-value { +.stat-tile-value { font-size: 17px; font-weight: 800; color: var(--text-strong); font-variant-numeric: tabular-nums; text-shadow: 0 0 14px var(--accent-glow); } -.lib-stat-label { +.stat-tile-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--faint); } -.lib-color-bar { +.color-bar { display: flex; height: 8px; border-radius: 999px; @@ -393,16 +395,63 @@ input[type="checkbox"] { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); margin-bottom: 9px; } -.lib-color-seg { +.color-bar-seg { min-width: 3px; transition: flex-grow 0.4s ease; } -.lib-color-seg.W { background: var(--mtg-w); } -.lib-color-seg.U { background: var(--mtg-u); } -.lib-color-seg.B { background: var(--mtg-b); } -.lib-color-seg.R { background: var(--mtg-r); } -.lib-color-seg.G { background: var(--mtg-g); } -.lib-color-seg.C { background: var(--mtg-c); } +.color-bar-seg.W { background: var(--mtg-w); } +.color-bar-seg.U { background: var(--mtg-u); } +.color-bar-seg.B { background: var(--mtg-b); } +.color-bar-seg.R { background: var(--mtg-r); } +.color-bar-seg.G { background: var(--mtg-g); } +.color-bar-seg.C { background: var(--mtg-c); } + +/* Mana-value curve histogram (per-deck analysis panel). */ +.mana-curve { + display: flex; + align-items: flex-end; + gap: 6px; + flex: 1; + min-height: 84px; + margin-top: 4px; +} +.mana-curve-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; + gap: 3px; +} +.mana-curve-count { + font-size: 10px; + color: var(--faint); + font-variant-numeric: tabular-nums; +} +.mana-curve-bar { + width: 100%; + min-height: 2px; + border-radius: 3px 3px 0 0; + overflow: hidden; + display: flex; + flex-direction: column; + transition: height 0.25s ease; +} +.mana-curve-seg { + width: 100%; + min-height: 1px; +} +.mana-curve-seg.W { background: var(--mtg-w); } +.mana-curve-seg.U { background: var(--mtg-u); } +.mana-curve-seg.B { background: var(--mtg-b); } +.mana-curve-seg.R { background: var(--mtg-r); } +.mana-curve-seg.G { background: var(--mtg-g); } +.mana-curve-seg.C { background: var(--mtg-c); } +.mana-curve-label { + font-size: 10px; + color: var(--faint); +} .lib-stats-sub { display: flex; flex-direction: column; @@ -863,6 +912,26 @@ li:focus-within .row-actions { font-weight: 600; color: var(--text-strong); } + +/* Deck list: name+format on one row, actions on their own row below, so + * long names don't push the action icons off the edge of the sidebar. */ +.result-list.deck-list li { + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.deck-list-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.deck-list-row .card-name { + overflow-wrap: break-word; +} +.deck-list-actions-row { + justify-content: space-between; +} .card-type { color: var(--muted); font-size: 13px; @@ -1022,6 +1091,83 @@ tbody tr:hover { max-width: 160px; } +/* ============================================================ + TagInput: chip-based multi-value filter with autocomplete + (type/subtype/keyword filters in the Library tab) + ============================================================ */ + +.tag-input { + position: relative; +} +.tag-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + min-height: 34px; + padding: 5px 8px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.12s, box-shadow 0.12s; +} +.tag-chips:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} +.tag-chip { + display: inline-flex; + align-items: center; + gap: 2px; + background: var(--accent-subtle); + color: var(--accent-hover); + border-radius: 999px; + padding: 2px 4px 2px 10px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} +.tag-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; +} +.tag-chip-remove:hover { + background: rgba(0, 0, 0, 0.25); +} +.tag-chip-input { + flex: 1; + min-width: 70px; + border: none; + background: none; + padding: 3px 2px; + font-size: 14px; +} +.tag-chip-input:focus { + outline: none; + border: none; + box-shadow: none; +} +.tag-suggestions { + position: absolute; + z-index: 50; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 220px; + overflow-y: auto; +} + /* ============================================================ Card images: thumbnails, hover preview, tiles ============================================================ */ @@ -1301,3 +1447,103 @@ tbody tr:hover { min-width: 280px; flex: 1; } + +/* Prominent commander card display, above the fold on a Commander/Brawl deck. */ +.commander-hero { + display: flex; + align-items: center; + gap: 16px; + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + padding: 14px 16px; + margin-bottom: 16px; +} +.commander-hero-img { + width: 84px; + height: 117px; + flex-shrink: 0; + object-fit: cover; + border-radius: 8px; + cursor: zoom-in; +} +.commander-hero-noimg { + width: 84px; + height: 117px; + flex-shrink: 0; + border: 1px dashed var(--border); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 8px; + font-size: 11px; + font-weight: 600; + color: var(--muted); +} +.commander-hero-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.commander-hero-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--accent-hover); +} +.commander-hero-name { + font-size: 19px; + font-weight: 700; + color: var(--text-strong); +} +.commander-hero-type { + font-size: 12px; + color: var(--muted); +} + +/* Per-deck analysis panel. */ +.deck-stats { + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + padding: 14px 16px; + margin-bottom: 16px; +} +.deck-stats-head { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--faint); + margin-bottom: 10px; +} +.deck-stats-body { + display: flex; + flex-wrap: wrap; + gap: 24px; +} +.deck-stats-numbers { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 220px; +} +.deck-stats-curve { + flex: 1; + min-width: 220px; + display: flex; + flex-direction: column; +} +.deck-stats-curve-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--faint); + margin-bottom: 2px; +} diff --git a/src/views/CardEntry.tsx b/src/views/CardEntry.tsx index 5a7d253..8701c2e 100644 --- a/src/views/CardEntry.tsx +++ b/src/views/CardEntry.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api, CardRef } from "../api"; import { CardSearchBox, QtyStepper } from "../components"; @@ -11,11 +11,38 @@ export function CardEntry() { >([]); // Persisted so a "box" location sticks while entering a stack of cards. const [location, setLocation] = useState(() => localStorage.getItem("add-location") || ""); + const searchInputRef = useRef(null); useEffect(() => { localStorage.setItem("add-location", location); }, [location]); + // Typing anywhere on this tab — without having clicked into another field + // first — jumps focus to the search box and keeps the keystroke that + // triggered it, instead of it going nowhere. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const target = e.target; + const alreadyTyping = + target instanceof HTMLElement && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable); + if (alreadyTyping) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key.length !== 1) return; // printable characters only + // Let Space still activate a focused button rather than hijacking it. + if (target instanceof HTMLElement && target.tagName === "BUTTON" && e.key === " ") return; + + e.preventDefault(); + setQuery((q) => q + e.key); + searchInputRef.current?.focus(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + // Debounced fuzzy search. useEffect(() => { const q = query.trim(); @@ -63,6 +90,7 @@ export function CardEntry() {
!e.is_commander); + const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0); + const lands = cards.filter((e) => e.type_line.includes("Land")); + const nonlands = cards.filter((e) => !e.type_line.includes("Land")); + const landCount = lands.reduce((sum, e) => sum + e.quantity, 0); + const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0); + const avgManaValue = nonlandCount + ? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount + : 0; + + const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"]; + const curveBuckets = new Map( + bucketLabels.map((l) => [ + l, + { count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 }, + ]), + ); + for (const e of nonlands) { + const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value)); + const bucket = curveBuckets.get(label)!; + bucket.count += e.quantity; + if (e.color_identity === 0) { + bucket.colorlessCount += e.quantity; + } else { + COLORS.forEach((c, i) => { + if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity; + }); + } + } + const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! })); + + const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0]; + let colorlessCount = 0; + for (const e of cards) { + if (e.color_identity === 0) { + colorlessCount += e.quantity; + } else { + COLORS.forEach((c, i) => { + if (e.color_identity & c.bit) colorCounts[i] += e.quantity; + }); + } + } + + const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0]; + let colorlessProduction = 0; + for (const e of cards) { + if (e.produces_colorless) colorlessProduction += e.quantity; + COLORS.forEach((c, i) => { + if (e.produced_mana & c.bit) manaProduction[i] += e.quantity; + }); + } + + // Consumption: colored cost requirements of the spells actually cast — + // lands are excluded since they have no colored casting cost. + const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0]; + let colorlessConsumption = 0; + for (const e of nonlands) { + if (e.colors === 0) { + colorlessConsumption += e.quantity; + } else { + COLORS.forEach((c, i) => { + if (e.colors & c.bit) manaConsumption[i] += e.quantity; + }); + } + } + + return { + totalCards, + uniqueCards: cards.length, + landCount, + avgManaValue, + curve, + colorCounts, + colorlessCount, + manaProduction, + colorlessProduction, + manaConsumption, + colorlessConsumption, + }; +} + +/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */ +function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) { + const { handlers } = useHoverPreview(); + const { open } = useClickPreview(); + return ( +
+ {card.image_uri ? ( + {card.name} open(card)} + {...handlers(card)} + /> + ) : ( +
{card.name}
+ )} +
+ Commander + {card.name} + {card.type_line} + +
+ +
+ ); +} + // How long a card decremented to 0 copies lingers (dimmed, still adjustable) // before actually dropping out of the deck — same grace-period pattern as // the Library tab, so a mistaken removal can be undone with +. @@ -289,10 +428,8 @@ export function DeckView() { const owned = contents.filter((e) => e.quantity_owned > 0); const unowned = contents.filter((e) => e.quantity_owned === 0); - const commanderName = - deck?.commander_oracle_id && - (contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ?? - deck.commander_oracle_id); + const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id); + const deckStats = useMemo(() => computeDeckStats(contents), [contents]); function deckTiles(entries: DeckEntry[]) { return ( @@ -409,45 +546,48 @@ export function DeckView() { +
-
    +
      {decks.map((d) => (
    • setSelected(d.id)} > - {d.name} - {d.format && {d.format}} - - - { - e.stopPropagation(); - setDialogError(null); - setDialog({ kind: "rename", deck: d }); - }} - /> - { - e.stopPropagation(); - setDialogError(null); - setDialog({ kind: "duplicate", deck: d }); - }} - /> - { - e.stopPropagation(); - setDialog({ kind: "delete", deck: d }); - }} - /> - +
      + {d.name} +
      +
      + {d.format ? {d.format} : } + + { + e.stopPropagation(); + setDialogError(null); + setDialog({ kind: "rename", deck: d }); + }} + /> + { + e.stopPropagation(); + setDialogError(null); + setDialog({ kind: "duplicate", deck: d }); + }} + /> + { + e.stopPropagation(); + setDialog({ kind: "delete", deck: d }); + }} + /> + +
    • ))} {decks.length === 0 && ( @@ -500,6 +640,45 @@ export function DeckView() { )} + {COMMANDER_FORMATS.includes(deck.format) && commanderEntry && ( + + )} + + {contents.length > 0 && ( +
      +
      Deck analysis
      +
      +
      +
      + + + + +
      +
      Color identity
      + +
      Mana production
      + +
      Mana consumption
      + +
      +
      +
      Mana curve (nonland)
      + +
      +
      +
      + )} + {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
      {rows.length === 0 ? ( @@ -496,10 +527,3 @@ export function LibraryBrowser() { ); } - -function splitCsv(s: string): string[] { - return s - .split(",") - .map((x) => x.trim()) - .filter(Boolean); -}