Add deck analysis panel with mana production/consumption bars and oracle text search
Threads a new produced_mana/produces_colorless field through the whole card read path (ingest, library, filters, decks, search) so the deck view can show what colors a deck's manabase actually produces, alongside its existing color identity. Also adds a mana consumption bar (colored casting costs of nonland spells) and stacks the mana curve histogram by color. Adds an oracle-text substring filter to the library search, mirroring the existing location filter. Rearranges the deck sidebar list into two rows (name, then format chip + actions) so long names and action buttons no longer collide, and removes the drop shadows from the commander hero panel/image. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,7 @@ impl Catalog {
|
|||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"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.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
|
||||||
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
|
||||||
@@ -136,6 +136,8 @@ impl Catalog {
|
|||||||
type_line: r.get(8)?,
|
type_line: r.get(8)?,
|
||||||
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||||
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
||||||
|
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
||||||
|
produces_colorless: r.get::<_, i64>(12)? != 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ impl Catalog {
|
|||||||
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
|
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
|
||||||
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
|
||||||
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",
|
||||||
@@ -85,6 +86,10 @@ 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");
|
||||||
|
|
||||||
@@ -265,4 +270,38 @@ mod tests {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
assert_eq!(all.len(), 5);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,10 @@ struct ScryOracle {
|
|||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
card_faces: Option<Vec<ScryFace>>,
|
card_faces: Option<Vec<ScryFace>>,
|
||||||
|
/// 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<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -438,6 +442,8 @@ struct ScryFace {
|
|||||||
type_line: Option<String>,
|
type_line: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
colors: Option<Vec<String>>,
|
colors: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
oracle_text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
|
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
|
||||||
@@ -446,8 +452,9 @@ 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,
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
produced_mana,produces_colorless)
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||||
)?;
|
)?;
|
||||||
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)")?;
|
||||||
@@ -502,6 +509,30 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
.unwrap_or_default()
|
.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::<Vec<_>>()
|
||||||
|
.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![
|
let changed = ins_card.execute(params![
|
||||||
oracle_id,
|
oracle_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -510,7 +541,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
colors.0 as i64,
|
colors.0 as i64,
|
||||||
identity.0 as i64,
|
identity.0 as i64,
|
||||||
type_line,
|
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.
|
// Only emit satellite rows the first time we see this oracle_id.
|
||||||
if changed == 1 {
|
if changed == 1 {
|
||||||
@@ -797,6 +830,83 @@ mod tests {
|
|||||||
assert_eq!(g as u8, crate::model::G);
|
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]
|
#[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.
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ use std::path::Path;
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
pub use error::{CoreError, Result};
|
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;
|
pub use search::NameIndex;
|
||||||
|
|
||||||
/// A handle to an open catalog: the library db with the reference db attached.
|
/// 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,
|
/// (oracle_id, name, mana_value, colors, color_identity, type_line,
|
||||||
/// types, subtypes, keywords)
|
/// types, subtypes, keywords, oracle_text)
|
||||||
pub struct SeedCard {
|
pub struct SeedCard {
|
||||||
pub oracle_id: &'static str,
|
pub oracle_id: &'static str,
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
@@ -77,6 +79,7 @@ pub(crate) mod test_support {
|
|||||||
pub types: &'static [&'static str],
|
pub types: &'static [&'static str],
|
||||||
pub subtypes: &'static [&'static str],
|
pub subtypes: &'static [&'static str],
|
||||||
pub keywords: &'static [&'static str],
|
pub keywords: &'static [&'static str],
|
||||||
|
pub oracle_text: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seed_cards() -> Vec<SeedCard> {
|
pub fn seed_cards() -> Vec<SeedCard> {
|
||||||
@@ -91,6 +94,7 @@ pub(crate) mod test_support {
|
|||||||
types: &["Creature"],
|
types: &["Creature"],
|
||||||
subtypes: &["Elf", "Druid"],
|
subtypes: &["Elf", "Druid"],
|
||||||
keywords: &[],
|
keywords: &[],
|
||||||
|
oracle_text: "{T}: Add {G}.",
|
||||||
},
|
},
|
||||||
SeedCard {
|
SeedCard {
|
||||||
oracle_id: "deathrite-shaman",
|
oracle_id: "deathrite-shaman",
|
||||||
@@ -102,6 +106,7 @@ pub(crate) mod test_support {
|
|||||||
types: &["Creature"],
|
types: &["Creature"],
|
||||||
subtypes: &["Elf", "Shaman"],
|
subtypes: &["Elf", "Shaman"],
|
||||||
keywords: &[],
|
keywords: &[],
|
||||||
|
oracle_text: "",
|
||||||
},
|
},
|
||||||
SeedCard {
|
SeedCard {
|
||||||
oracle_id: "serra-angel",
|
oracle_id: "serra-angel",
|
||||||
@@ -113,6 +118,7 @@ pub(crate) mod test_support {
|
|||||||
types: &["Creature"],
|
types: &["Creature"],
|
||||||
subtypes: &["Angel"],
|
subtypes: &["Angel"],
|
||||||
keywords: &["Flying", "Vigilance"],
|
keywords: &["Flying", "Vigilance"],
|
||||||
|
oracle_text: "Flying, vigilance",
|
||||||
},
|
},
|
||||||
SeedCard {
|
SeedCard {
|
||||||
oracle_id: "lightning-bolt",
|
oracle_id: "lightning-bolt",
|
||||||
@@ -124,6 +130,7 @@ pub(crate) mod test_support {
|
|||||||
types: &["Instant"],
|
types: &["Instant"],
|
||||||
subtypes: &[],
|
subtypes: &[],
|
||||||
keywords: &[],
|
keywords: &[],
|
||||||
|
oracle_text: "Lightning Bolt deals 3 damage to any target.",
|
||||||
},
|
},
|
||||||
SeedCard {
|
SeedCard {
|
||||||
oracle_id: "sol-ring",
|
oracle_id: "sol-ring",
|
||||||
@@ -135,6 +142,7 @@ pub(crate) mod test_support {
|
|||||||
types: &["Artifact"],
|
types: &["Artifact"],
|
||||||
subtypes: &[],
|
subtypes: &[],
|
||||||
keywords: &[],
|
keywords: &[],
|
||||||
|
oracle_text: "{T}: Add {C}{C}.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -153,8 +161,8 @@ pub(crate) mod test_support {
|
|||||||
rconn
|
rconn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO oracle_cards
|
"INSERT INTO oracle_cards
|
||||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line)
|
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7)",
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
c.oracle_id,
|
c.oracle_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -162,7 +170,8 @@ pub(crate) mod test_support {
|
|||||||
c.mv,
|
c.mv,
|
||||||
colors,
|
colors,
|
||||||
identity,
|
identity,
|
||||||
c.type_line
|
c.type_line,
|
||||||
|
c.oracle_text,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ use rusqlite::{params, OptionalExtension};
|
|||||||
|
|
||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryStats, WUBRG};
|
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
|
||||||
use crate::Catalog;
|
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)` into its
|
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
||||||
/// library parts. Shared by [`Catalog::list_library`] and the criteria search.
|
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`]
|
||||||
|
/// and the criteria search.
|
||||||
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)> {
|
||||||
@@ -29,6 +30,8 @@ pub(crate) fn row_to_library_parts(
|
|||||||
type_line: r.get(7)?,
|
type_line: r.get(7)?,
|
||||||
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
||||||
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(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!(
|
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
|
||||||
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
|
||||||
@@ -144,6 +148,29 @@ impl Catalog {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinct type/subtype/keyword values among owned cards, for filter
|
||||||
|
/// autocomplete.
|
||||||
|
pub fn library_facets(&self) -> Result<LibraryFacets> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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::<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<()> {
|
||||||
@@ -207,6 +234,22 @@ mod tests {
|
|||||||
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
|
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]
|
#[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();
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ pub struct CardRef {
|
|||||||
/// URL of the *back face* image for the same printing (transform / modal
|
/// URL of the *back face* image for the same printing (transform / modal
|
||||||
/// double-faced / reversible cards), or empty for single-faced cards.
|
/// double-faced / reversible cards), or empty for single-faced cards.
|
||||||
pub back_image_uri: String,
|
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.
|
/// 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,
|
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<String>,
|
||||||
|
pub subtypes: Vec<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Criteria for searching the owned library. All fields are optional; an empty
|
/// Criteria for searching the owned library. All fields are optional; an empty
|
||||||
/// filter matches every owned card.
|
/// filter matches every owned card.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -184,4 +202,6 @@ pub struct LibraryFilter {
|
|||||||
/// Case-insensitive substring match against the card's stored location
|
/// 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.
|
/// (e.g. "Box 1"). Cards with no location set never match a non-empty value.
|
||||||
pub location: Option<String>,
|
pub location: Option<String>,
|
||||||
|
/// Case-insensitive substring match against the card's oracle rules text.
|
||||||
|
pub oracle_text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl NameIndex {
|
|||||||
pub fn build(conn: &Connection) -> Result<Self> {
|
pub fn build(conn: &Connection) -> Result<Self> {
|
||||||
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.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
|
||||||
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")
|
||||||
@@ -51,6 +51,8 @@ impl NameIndex {
|
|||||||
type_line: r.get(5)?,
|
type_line: r.get(5)?,
|
||||||
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||||
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||||
|
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
|
||||||
|
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
|||||||
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
||||||
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
|
||||||
type_line TEXT NOT NULL DEFAULT '',
|
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_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);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
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 serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
@@ -142,6 +142,12 @@ pub fn get_library_stats(db: Db) -> Res<LibraryStats> {
|
|||||||
s.catalog()?.library_stats().map_err(|e| e.to_string())
|
s.catalog()?.library_stats().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_library_facets(db: Db) -> Res<LibraryFacets> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
s.catalog()?.library_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();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub fn run() {
|
|||||||
commands::set_location,
|
commands::set_location,
|
||||||
commands::search_library,
|
commands::search_library,
|
||||||
commands::get_library_stats,
|
commands::get_library_stats,
|
||||||
|
commands::get_library_facets,
|
||||||
commands::list_decks,
|
commands::list_decks,
|
||||||
commands::create_deck,
|
commands::create_deck,
|
||||||
commands::delete_deck,
|
commands::delete_deck,
|
||||||
|
|||||||
59
src/App.tsx
59
src/App.tsx
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
|
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 { CardEntry } from "./views/CardEntry";
|
||||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||||
import { DeckView } from "./views/DeckView";
|
import { DeckView } from "./views/DeckView";
|
||||||
@@ -284,49 +291,15 @@ export default function App() {
|
|||||||
<div className="lib-stats-loading">Loading…</div>
|
<div className="lib-stats-loading">Loading…</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="lib-stats-numbers">
|
<div className="stat-tiles-row">
|
||||||
<div className="lib-stat">
|
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
|
||||||
<span className="lib-stat-value">
|
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
|
||||||
{libStats.unique_cards.toLocaleString()}
|
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
|
||||||
</span>
|
|
||||||
<span className="lib-stat-label">unique</span>
|
|
||||||
</div>
|
|
||||||
<div className="lib-stat">
|
|
||||||
<span className="lib-stat-value">
|
|
||||||
{libStats.total_copies.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="lib-stat-label">copies</span>
|
|
||||||
</div>
|
|
||||||
<div className="lib-stat">
|
|
||||||
<span className="lib-stat-value">
|
|
||||||
{libStats.deck_count.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="lib-stat-label">decks</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{libStats.total_copies > 0 && (
|
<ColorDistributionBar
|
||||||
<div className="lib-color-bar">
|
colorCounts={libStats.color_counts}
|
||||||
{(
|
colorlessCount={libStats.colorless_count}
|
||||||
[
|
/>
|
||||||
["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 ? (
|
|
||||||
<span
|
|
||||||
key={code}
|
|
||||||
className={`lib-color-seg ${code}`}
|
|
||||||
style={{ flexGrow: count }}
|
|
||||||
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()} copies`}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="lib-stats-sub">
|
<div className="lib-stats-sub">
|
||||||
|
|||||||
10
src/api.ts
10
src/api.ts
@@ -12,6 +12,8 @@ export interface CardRef {
|
|||||||
type_line: string;
|
type_line: string;
|
||||||
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
|
||||||
|
produces_colorless: boolean; // can produce {C}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryCard extends CardRef {
|
export interface LibraryCard extends CardRef {
|
||||||
@@ -47,6 +49,7 @@ export interface LibraryFilter {
|
|||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
in_deck?: number | null;
|
in_deck?: number | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
|
oracle_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
@@ -65,6 +68,12 @@ export interface LibraryStats {
|
|||||||
colorless_count: number;
|
colorless_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryFacets {
|
||||||
|
types: string[];
|
||||||
|
subtypes: string[];
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IngestStageInfo {
|
export interface IngestStageInfo {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -115,6 +124,7 @@ export const api = {
|
|||||||
searchLibrary: (filter: LibraryFilter) =>
|
searchLibrary: (filter: LibraryFilter) =>
|
||||||
invoke<LibraryCard[]>("search_library", { filter }),
|
invoke<LibraryCard[]>("search_library", { filter }),
|
||||||
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
||||||
|
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
|
||||||
|
|
||||||
listDecks: () => invoke<Deck[]>("list_decks"),
|
listDecks: () => invoke<Deck[]>("list_decks"),
|
||||||
createDeck: (name: string, commanderOracleId: string | null) =>
|
createDeck: (name: string, commanderOracleId: string | null) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
forwardRef,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -341,27 +342,27 @@ export function CardThumb({ card }: { card: CardRef }) {
|
|||||||
* A search input plus a results dropdown with keyboard navigation:
|
* A search input plus a results dropdown with keyboard navigation:
|
||||||
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
|
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
|
||||||
* `value`/`onChange` are controlled by the caller (which runs the search).
|
* `value`/`onChange` are controlled by the caller (which runs the search).
|
||||||
|
* Forwards a ref to the underlying `<input>` so callers can focus it
|
||||||
|
* imperatively (e.g. redirecting a stray keystroke into the search box).
|
||||||
*/
|
*/
|
||||||
export function CardSearchBox({
|
export const CardSearchBox = forwardRef<
|
||||||
placeholder,
|
HTMLInputElement,
|
||||||
value,
|
{
|
||||||
onChange,
|
placeholder: string;
|
||||||
results,
|
value: string;
|
||||||
onPick,
|
onChange: (v: string) => void;
|
||||||
autoFocus,
|
results: CardRef[];
|
||||||
big,
|
onPick: (card: CardRef) => void;
|
||||||
}: {
|
autoFocus?: boolean;
|
||||||
placeholder: string;
|
/** Larger, more prominent input (used for the primary Add Cards field). */
|
||||||
value: string;
|
big?: boolean;
|
||||||
onChange: (v: string) => void;
|
}
|
||||||
results: CardRef[];
|
>(function CardSearchBox(
|
||||||
onPick: (card: CardRef) => void;
|
{ placeholder, value, onChange, results, onPick, autoFocus, big },
|
||||||
autoFocus?: boolean;
|
forwardedRef,
|
||||||
/** Larger, more prominent input (used for the primary Add Cards field). */
|
) {
|
||||||
big?: boolean;
|
|
||||||
}) {
|
|
||||||
const [sel, setSel] = useState(0);
|
const [sel, setSel] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
useEffect(() => setSel(0), [results]);
|
useEffect(() => setSel(0), [results]);
|
||||||
|
|
||||||
// Keep focus in the box after a pick so cards can be added in a rapid streak.
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={(el) => {
|
||||||
|
inputRef.current = el;
|
||||||
|
if (typeof forwardedRef === "function") forwardedRef(el);
|
||||||
|
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||||
|
}}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
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 (
|
||||||
|
<div className="tag-input">
|
||||||
|
<div className="tag-chips">
|
||||||
|
{values.map((v) => (
|
||||||
|
<span key={v} className="tag-chip">
|
||||||
|
{v}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip-remove"
|
||||||
|
onClick={() => removeValue(v)}
|
||||||
|
title={`Remove ${v}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="tag-chip-input"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={values.length === 0 ? placeholder : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filtered.length > 0 && (
|
||||||
|
<ul className="result-list tag-suggestions">
|
||||||
|
{filtered.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={s}
|
||||||
|
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
onClick={() => addValue(s)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hover + click preview -------------------------------------------------
|
// --- 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 (
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-tile-value">{value}</span>
|
||||||
|
<span className="stat-tile-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="color-bar">
|
||||||
|
{segs.map(([code, count]) =>
|
||||||
|
count > 0 ? (
|
||||||
|
<span
|
||||||
|
key={code}
|
||||||
|
className={`color-bar-seg ${code}`}
|
||||||
|
style={{ flexGrow: count }}
|
||||||
|
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="mana-curve">
|
||||||
|
{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 (
|
||||||
|
<div key={b.label} className="mana-curve-col">
|
||||||
|
<span className="mana-curve-count">{b.count || ""}</span>
|
||||||
|
<div className="mana-curve-bar" style={{ height: `${(b.count / max) * 100}%` }}>
|
||||||
|
{segs.map(([code, count]) =>
|
||||||
|
count > 0 ? (
|
||||||
|
<span
|
||||||
|
key={code}
|
||||||
|
className={`mana-curve-seg ${code}`}
|
||||||
|
style={{ flexGrow: count }}
|
||||||
|
title={`${code === "C" ? "Colorless" : code}: ${count.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mana-curve-label">{b.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Persistence ----------------------------------------------------------
|
// --- Persistence ----------------------------------------------------------
|
||||||
|
|
||||||
/** useState that mirrors its value to localStorage (JSON), so it survives
|
/** useState that mirrors its value to localStorage (JSON), so it survives
|
||||||
|
|||||||
272
src/styles.css
272
src/styles.css
@@ -349,12 +349,14 @@ input[type="checkbox"] {
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
padding-bottom: 10px;
|
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;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.lib-stat {
|
.stat-tile {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -367,25 +369,25 @@ input[type="checkbox"] {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: border-color 0.15s, transform 0.15s;
|
transition: border-color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
.lib-stat:hover {
|
.stat-tile:hover {
|
||||||
border-color: var(--accent-glow);
|
border-color: var(--accent-glow);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.lib-stat-value {
|
.stat-tile-value {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-shadow: 0 0 14px var(--accent-glow);
|
text-shadow: 0 0 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.lib-stat-label {
|
.stat-tile-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.lib-color-bar {
|
.color-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -393,16 +395,63 @@ input[type="checkbox"] {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
margin-bottom: 9px;
|
margin-bottom: 9px;
|
||||||
}
|
}
|
||||||
.lib-color-seg {
|
.color-bar-seg {
|
||||||
min-width: 3px;
|
min-width: 3px;
|
||||||
transition: flex-grow 0.4s ease;
|
transition: flex-grow 0.4s ease;
|
||||||
}
|
}
|
||||||
.lib-color-seg.W { background: var(--mtg-w); }
|
.color-bar-seg.W { background: var(--mtg-w); }
|
||||||
.lib-color-seg.U { background: var(--mtg-u); }
|
.color-bar-seg.U { background: var(--mtg-u); }
|
||||||
.lib-color-seg.B { background: var(--mtg-b); }
|
.color-bar-seg.B { background: var(--mtg-b); }
|
||||||
.lib-color-seg.R { background: var(--mtg-r); }
|
.color-bar-seg.R { background: var(--mtg-r); }
|
||||||
.lib-color-seg.G { background: var(--mtg-g); }
|
.color-bar-seg.G { background: var(--mtg-g); }
|
||||||
.lib-color-seg.C { background: var(--mtg-c); }
|
.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 {
|
.lib-stats-sub {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -863,6 +912,26 @@ li:focus-within .row-actions {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-strong);
|
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 {
|
.card-type {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1022,6 +1091,83 @@ tbody tr:hover {
|
|||||||
max-width: 160px;
|
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
|
Card images: thumbnails, hover preview, tiles
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -1301,3 +1447,103 @@ tbody tr:hover {
|
|||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, CardRef } from "../api";
|
import { api, CardRef } from "../api";
|
||||||
import { CardSearchBox, QtyStepper } from "../components";
|
import { CardSearchBox, QtyStepper } from "../components";
|
||||||
|
|
||||||
@@ -11,11 +11,38 @@ export function CardEntry() {
|
|||||||
>([]);
|
>([]);
|
||||||
// Persisted so a "box" location sticks while entering a stack of cards.
|
// Persisted so a "box" location sticks while entering a stack of cards.
|
||||||
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
|
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("add-location", location);
|
localStorage.setItem("add-location", 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.
|
// Debounced fuzzy search.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
@@ -63,6 +90,7 @@ export function CardEntry() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardSearchBox
|
<CardSearchBox
|
||||||
|
ref={searchInputRef}
|
||||||
big
|
big
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"
|
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
import { api, CardRef, Deck, DeckEntry } from "../api";
|
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
|
||||||
import {
|
import {
|
||||||
CardSearchBox,
|
CardSearchBox,
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
|
ColorDistributionBar,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
GroupMode,
|
GroupMode,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
ManaCurve,
|
||||||
PromptDialog,
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
|
StatTile,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
|
useClickPreview,
|
||||||
|
useHoverPreview,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
@@ -29,6 +34,140 @@ function friendlyDeckError(err: unknown): string {
|
|||||||
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeckStats {
|
||||||
|
totalCards: number;
|
||||||
|
uniqueCards: number;
|
||||||
|
landCount: number;
|
||||||
|
avgManaValue: number;
|
||||||
|
curve: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
colorCounts: [number, number, number, number, number];
|
||||||
|
colorlessCount: number;
|
||||||
|
}[];
|
||||||
|
colorCounts: [number, number, number, number, number];
|
||||||
|
colorlessCount: number;
|
||||||
|
manaProduction: [number, number, number, number, number];
|
||||||
|
colorlessProduction: number;
|
||||||
|
manaConsumption: [number, number, number, number, number];
|
||||||
|
colorlessConsumption: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck composition stats, excluding the commander (shown separately in its
|
||||||
|
* own hero display) — matches the usual "99 + commander" framing even for
|
||||||
|
* non-Commander decks, where it's just "everything but the flagged card".
|
||||||
|
*/
|
||||||
|
function computeDeckStats(contents: DeckEntry[]): DeckStats {
|
||||||
|
const cards = contents.filter((e) => !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 (
|
||||||
|
<div className="commander-hero">
|
||||||
|
{card.image_uri ? (
|
||||||
|
<img
|
||||||
|
className="commander-hero-img"
|
||||||
|
src={card.image_uri}
|
||||||
|
alt={card.name}
|
||||||
|
onClick={() => open(card)}
|
||||||
|
{...handlers(card)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="commander-hero-noimg">{card.name}</div>
|
||||||
|
)}
|
||||||
|
<div className="commander-hero-info">
|
||||||
|
<span className="commander-hero-label">Commander</span>
|
||||||
|
<span className="commander-hero-name">{card.name}</span>
|
||||||
|
<span className="commander-hero-type">{card.type_line}</span>
|
||||||
|
<ColorPips mask={card.color_identity} />
|
||||||
|
</div>
|
||||||
|
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
||||||
// before actually dropping out of the deck — same grace-period pattern as
|
// before actually dropping out of the deck — same grace-period pattern as
|
||||||
// the Library tab, so a mistaken removal can be undone with +.
|
// 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 owned = contents.filter((e) => e.quantity_owned > 0);
|
||||||
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
||||||
const commanderName =
|
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
|
||||||
deck?.commander_oracle_id &&
|
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||||
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
|
|
||||||
deck.commander_oracle_id);
|
|
||||||
|
|
||||||
function deckTiles(entries: DeckEntry[]) {
|
function deckTiles(entries: DeckEntry[]) {
|
||||||
return (
|
return (
|
||||||
@@ -409,45 +546,48 @@ export function DeckView() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="result-list">
|
<ul className="result-list deck-list">
|
||||||
{decks.map((d) => (
|
{decks.map((d) => (
|
||||||
<li
|
<li
|
||||||
key={d.id}
|
key={d.id}
|
||||||
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
||||||
onClick={() => setSelected(d.id)}
|
onClick={() => setSelected(d.id)}
|
||||||
>
|
>
|
||||||
<span className="card-name">{d.name}</span>
|
<div className="deck-list-row">
|
||||||
{d.format && <span className="deck-chip">{d.format}</span>}
|
<span className="card-name">{d.name}</span>
|
||||||
<span className="spacer" />
|
</div>
|
||||||
<span className="row-actions">
|
<div className="deck-list-row deck-list-actions-row">
|
||||||
<IconButton
|
{d.format ? <span className="deck-chip">{d.format}</span> : <span />}
|
||||||
icon={Icons.pencil}
|
<span className="row-actions">
|
||||||
label="Rename deck"
|
<IconButton
|
||||||
onClick={(e) => {
|
icon={Icons.pencil}
|
||||||
e.stopPropagation();
|
label="Rename deck"
|
||||||
setDialogError(null);
|
onClick={(e) => {
|
||||||
setDialog({ kind: "rename", deck: d });
|
e.stopPropagation();
|
||||||
}}
|
setDialogError(null);
|
||||||
/>
|
setDialog({ kind: "rename", deck: d });
|
||||||
<IconButton
|
}}
|
||||||
icon={Icons.copy}
|
/>
|
||||||
label="Duplicate deck"
|
<IconButton
|
||||||
onClick={(e) => {
|
icon={Icons.copy}
|
||||||
e.stopPropagation();
|
label="Duplicate deck"
|
||||||
setDialogError(null);
|
onClick={(e) => {
|
||||||
setDialog({ kind: "duplicate", deck: d });
|
e.stopPropagation();
|
||||||
}}
|
setDialogError(null);
|
||||||
/>
|
setDialog({ kind: "duplicate", deck: d });
|
||||||
<IconButton
|
}}
|
||||||
icon={Icons.trash}
|
/>
|
||||||
label="Delete deck"
|
<IconButton
|
||||||
danger
|
icon={Icons.trash}
|
||||||
onClick={(e) => {
|
label="Delete deck"
|
||||||
e.stopPropagation();
|
danger
|
||||||
setDialog({ kind: "delete", deck: d });
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
/>
|
setDialog({ kind: "delete", deck: d });
|
||||||
</span>
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{decks.length === 0 && (
|
{decks.length === 0 && (
|
||||||
@@ -500,6 +640,45 @@ export function DeckView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||||
|
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contents.length > 0 && (
|
||||||
|
<div className="deck-stats">
|
||||||
|
<div className="deck-stats-head">Deck analysis</div>
|
||||||
|
<div className="deck-stats-body">
|
||||||
|
<div className="deck-stats-numbers">
|
||||||
|
<div className="stat-tiles-row">
|
||||||
|
<StatTile value={deckStats.totalCards} label="cards" />
|
||||||
|
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||||
|
<StatTile value={deckStats.landCount} label="lands" />
|
||||||
|
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||||
|
</div>
|
||||||
|
<div className="deck-stats-curve-label">Color identity</div>
|
||||||
|
<ColorDistributionBar
|
||||||
|
colorCounts={deckStats.colorCounts}
|
||||||
|
colorlessCount={deckStats.colorlessCount}
|
||||||
|
/>
|
||||||
|
<div className="deck-stats-curve-label">Mana production</div>
|
||||||
|
<ColorDistributionBar
|
||||||
|
colorCounts={deckStats.manaProduction}
|
||||||
|
colorlessCount={deckStats.colorlessProduction}
|
||||||
|
/>
|
||||||
|
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||||
|
<ColorDistributionBar
|
||||||
|
colorCounts={deckStats.manaConsumption}
|
||||||
|
colorlessCount={deckStats.colorlessConsumption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="deck-stats-curve">
|
||||||
|
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||||
|
<ManaCurve buckets={deckStats.curve} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||||
<div className="deck-meta">
|
<div className="deck-meta">
|
||||||
<label className="row" style={{ gap: 6 }}>
|
<label className="row" style={{ gap: 6 }}>
|
||||||
@@ -515,15 +694,13 @@ export function DeckView() {
|
|||||||
|
|
||||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||||
<div className="commander-box">
|
<div className="commander-box">
|
||||||
<div className="row" style={{ gap: 8 }}>
|
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||||
<span className="muted">Commander:</span>
|
|
||||||
<strong>{commanderName || "none"}</strong>
|
|
||||||
{commanderName && (
|
|
||||||
<IconButton icon={Icons.close} label="Clear commander" onClick={clearCommander} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CardSearchBox
|
<CardSearchBox
|
||||||
placeholder="Search to set the commander…"
|
placeholder={
|
||||||
|
commanderEntry
|
||||||
|
? "Search to change the commander…"
|
||||||
|
: "Search to set the commander…"
|
||||||
|
}
|
||||||
value={cmdQuery}
|
value={cmdQuery}
|
||||||
onChange={setCmdQuery}
|
onChange={setCmdQuery}
|
||||||
results={cmdResults}
|
results={cmdResults}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
|
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||||||
import {
|
import {
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
LocationField,
|
LocationField,
|
||||||
PromptDialog,
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
|
TagInput,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
// user a window to undo an accidental removal by clicking + again.
|
// user a window to undo an accidental removal by clicking + again.
|
||||||
const REMOVAL_GRACE_MS = 5000;
|
const REMOVAL_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
||||||
|
|
||||||
/** Browse & filter the owned library by name and card criteria. */
|
/** Browse & filter the owned library by name and card criteria. */
|
||||||
export function LibraryBrowser() {
|
export function LibraryBrowser() {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -28,11 +31,13 @@ export function LibraryBrowser() {
|
|||||||
const [colorsInclude, setColorsInclude] = useState(0);
|
const [colorsInclude, setColorsInclude] = useState(0);
|
||||||
const [cmcMin, setCmcMin] = useState("");
|
const [cmcMin, setCmcMin] = useState("");
|
||||||
const [cmcMax, setCmcMax] = useState("");
|
const [cmcMax, setCmcMax] = useState("");
|
||||||
const [type, setType] = useState("");
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
const [subtype, setSubtype] = useState("");
|
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keywords, setKeywords] = useState<string[]>([]);
|
||||||
const [inDeck, setInDeck] = useState<number | "">("");
|
const [inDeck, setInDeck] = useState<number | "">("");
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
|
const [oracleText, setOracleText] = useState("");
|
||||||
|
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||||||
|
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||||||
@@ -53,6 +58,12 @@ export function LibraryBrowser() {
|
|||||||
api.listDecks().then(setDecks);
|
api.listDecks().then(setDecks);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Refetch whenever refreshKey bumps too, so newly-owned cards' types
|
||||||
|
// become suggestible without restarting the app.
|
||||||
|
useEffect(() => {
|
||||||
|
api.getLibraryFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
// Cancel any in-flight removal timers on unmount so they don't fire against
|
// Cancel any in-flight removal timers on unmount so they don't fire against
|
||||||
// an unmounted component.
|
// an unmounted component.
|
||||||
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
||||||
@@ -81,11 +92,12 @@ export function LibraryBrowser() {
|
|||||||
colors_include: colorsInclude || null,
|
colors_include: colorsInclude || null,
|
||||||
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||||||
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||||||
types: splitCsv(type),
|
types,
|
||||||
subtypes: splitCsv(subtype),
|
subtypes,
|
||||||
keywords: splitCsv(keyword),
|
keywords,
|
||||||
in_deck: inDeck === "" ? null : Number(inDeck),
|
in_deck: inDeck === "" ? null : Number(inDeck),
|
||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
|
oracle_text: oracleText.trim() || null,
|
||||||
};
|
};
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||||||
@@ -97,11 +109,12 @@ export function LibraryBrowser() {
|
|||||||
colorsInclude,
|
colorsInclude,
|
||||||
cmcMin,
|
cmcMin,
|
||||||
cmcMax,
|
cmcMax,
|
||||||
type,
|
types,
|
||||||
subtype,
|
subtypes,
|
||||||
keyword,
|
keywords,
|
||||||
inDeck,
|
inDeck,
|
||||||
location,
|
location,
|
||||||
|
oracleText,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -446,15 +459,25 @@ export function LibraryBrowser() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Type(s)</label>
|
<label>Type(s)</label>
|
||||||
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="Creature, Legendary" />
|
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Subtype(s)</label>
|
<label>Subtype(s)</label>
|
||||||
<input value={subtype} onChange={(e) => setSubtype(e.target.value)} placeholder="Elf" />
|
<TagInput
|
||||||
|
values={subtypes}
|
||||||
|
onChange={setSubtypes}
|
||||||
|
suggestions={facets.subtypes}
|
||||||
|
placeholder="Elf…"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Keyword(s)</label>
|
<label>Keyword(s)</label>
|
||||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="Flying" />
|
<TagInput
|
||||||
|
values={keywords}
|
||||||
|
onChange={setKeywords}
|
||||||
|
suggestions={facets.keywords}
|
||||||
|
placeholder="Flying…"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>In deck</label>
|
<label>In deck</label>
|
||||||
@@ -475,6 +498,14 @@ export function LibraryBrowser() {
|
|||||||
placeholder="e.g. Box 1"
|
placeholder="e.g. Box 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Oracle text</label>
|
||||||
|
<input
|
||||||
|
value={oracleText}
|
||||||
|
onChange={(e) => setOracleText(e.target.value)}
|
||||||
|
placeholder="e.g. landfall"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
@@ -496,10 +527,3 @@ export function LibraryBrowser() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitCsv(s: string): string[] {
|
|
||||||
return s
|
|
||||||
.split(",")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user