Add deck legality checking, deck/library import-export, and a command palette

Threads Scryfall's per-format legalities through the reference db and adds a
deck-page indicator (structural rules + banned/restricted lists) that lists
violation reasons inline. Decks and selected library cards can now be
exported to/imported from plain-text decklists, with best-effort name
resolution and a report of anything that didn't match. Also adds a shuffle-
and-draw sample hand modal, and an Alt+/ command palette for jumping to a
tab, deck, or card without leaving the keyboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:21:22 -05:00
parent cf9c930b02
commit 008ab33723
18 changed files with 1696 additions and 138 deletions

View File

@@ -0,0 +1,474 @@
//! Plain-text decklist import/export: parsing and formatting for decks
//! (with Commander/Deck/Maybeboard sections) and flat library card lists.
//!
//! Deck export format:
//! ```text
//! // mtgc deck export
//! // Format: Commander
//!
//! Commander
//! 1 Krenko, Mob Boss
//!
//! Deck
//! 1 Sol Ring
//! 4 Lightning Bolt
//!
//! Maybeboard
//! 1 Some Card
//! ```
//! `//` lines are comments (`// Format: X` is recognized specially to
//! restore the deck's format on import). Section headers are
//! case-insensitive; `Sideboard` is accepted as an input alias for
//! `Maybeboard` (compatibility with lists copied from other tools), but
//! export always emits `Maybeboard`. Cards before any header, or under
//! `Deck`, are mainboard. Quantities accept a bare integer (`4 Foo`) or an
//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped,
//! never a hard error — see [`parse_deck_text`].
//!
//! Library export format is the same comment/quantity-name line convention
//! with no sections — see [`parse_library_text`].
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{Deck, DeckEntry};
use crate::Catalog;
/// One `<quantity> <name>` line from a decklist, prior to name resolution.
#[derive(Debug, Clone, PartialEq)]
pub struct DecklistLine {
pub quantity: i64,
pub name: String,
}
/// The structured result of parsing deck-export text.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedDeckText {
/// From a `// Format: X` comment, if present.
pub format: Option<String>,
/// Commander section lines. Normally 0 or 1 — see
/// [`Catalog::import_deck`] for how extra lines are handled.
pub commander: Vec<DecklistLine>,
pub mainboard: Vec<DecklistLine>,
pub maybeboard: Vec<DecklistLine>,
}
#[derive(Clone, Copy, PartialEq)]
enum Section {
Mainboard,
Commander,
Maybeboard,
}
/// Parse deck-export text into structured lines. Lenient: blank lines are
/// skipped, `//` lines are comments, section headers are case-insensitive,
/// and lines that don't parse as `<qty> <name>` are silently skipped rather
/// than erroring the whole import.
pub fn parse_deck_text(text: &str) -> ParsedDeckText {
let mut parsed = ParsedDeckText::default();
let mut section = Section::Mainboard;
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("//") {
let rest = rest.trim();
if let Some(fmt) = rest
.strip_prefix("Format:")
.or_else(|| rest.strip_prefix("format:"))
{
parsed.format = Some(fmt.trim().to_string());
}
continue;
}
match line.to_lowercase().as_str() {
"commander" => {
section = Section::Commander;
continue;
}
"deck" => {
section = Section::Mainboard;
continue;
}
"maybeboard" | "sideboard" => {
section = Section::Maybeboard;
continue;
}
_ => {}
}
if let Some(card_line) = parse_card_line(line) {
match section {
Section::Mainboard => parsed.mainboard.push(card_line),
Section::Commander => parsed.commander.push(card_line),
Section::Maybeboard => parsed.maybeboard.push(card_line),
}
}
}
parsed
}
/// Parse a single `<qty> <name>` line. Accepts a bare integer quantity
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`).
/// Returns `None` (skip, don't error) if the line doesn't start with a
/// recognizable positive quantity or has no name after it.
fn parse_card_line(line: &str) -> Option<DecklistLine> {
let (head, rest) = line.split_once(char::is_whitespace)?;
let head_lower = head.to_lowercase();
let qty_str = head_lower
.strip_suffix('x')
.or_else(|| head_lower.strip_suffix('×'))
.unwrap_or(&head_lower);
let quantity: i64 = qty_str.parse().ok()?;
if quantity <= 0 {
return None;
}
let name = rest.trim();
if name.is_empty() {
return None;
}
Some(DecklistLine {
quantity,
name: name.to_string(),
})
}
/// Parse library-export text into `<qty> <name>` lines (no sections). Same
/// leniency rules as [`parse_deck_text`].
pub fn parse_library_text(text: &str) -> Vec<DecklistLine> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(parse_card_line)
.collect()
}
/// Render a deck (metadata + contents) as export text. Empty sections (no
/// commander, no maybeboard entries) are omitted entirely. The
/// `// Format: X` comment is emitted only when `deck.format` is non-empty.
pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
let mut out = String::from("// mtgc deck export\n");
if !deck.format.is_empty() {
out.push_str(&format!("// Format: {}\n", deck.format));
}
let commander: Vec<_> = contents.iter().filter(|e| e.is_commander).collect();
let maybeboard: Vec<_> = contents.iter().filter(|e| e.is_maybeboard).collect();
let mainboard: Vec<_> = contents
.iter()
.filter(|e| !e.is_commander && !e.is_maybeboard)
.collect();
if !commander.is_empty() {
out.push_str("\nCommander\n");
for e in &commander {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !mainboard.is_empty() {
out.push_str("\nDeck\n");
for e in &mainboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !maybeboard.is_empty() {
out.push_str("\nMaybeboard\n");
for e in &maybeboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
out
}
/// Render `(name, quantity)` pairs as library-export text.
pub fn format_library_text(entries: &[(String, i64)]) -> String {
let mut out = String::from("// mtgc library export\n");
for (name, qty) in entries {
out.push_str(&format!("{qty} {name}\n"));
}
out
}
impl Catalog {
/// Case-insensitive exact-name lookup against the reference db's
/// precomputed `name_lower` column. No fuzzy matching — an unresolved
/// name is reported back to the caller rather than guessed at, so a
/// typo never silently imports the wrong card.
pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
Ok(self
.conn
.query_row(
&format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"),
params![name.trim().to_lowercase()],
|r| r.get(0),
)
.optional()?)
}
/// Import a decklist as a brand-new deck named `name`. Always creates a
/// new deck — never merges into an existing one. Best-effort: card
/// names that don't resolve are skipped (not a hard failure) and
/// collected into the returned list, alongside the created `Deck`.
///
/// Only the first resolved Commander-section line becomes the actual
/// commander; any further Commander-section lines are folded into the
/// mainboard at their given quantity rather than dropped or erroring.
pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec<String>)> {
let parsed = parse_deck_text(text);
let deck = self.create_deck(name, None)?;
if let Some(fmt) = &parsed.format {
self.set_deck_format(deck.id, fmt)?;
}
let mut unresolved = Vec::new();
let mut commander_oracle_id: Option<String> = None;
for (i, line) in parsed.commander.iter().enumerate() {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let is_first = i == 0;
self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?;
if is_first {
commander_oracle_id = Some(oid);
}
}
None => unresolved.push(line.name.clone()),
}
}
if let Some(oid) = &commander_oracle_id {
self.set_deck_commander(deck.id, Some(oid))?;
}
for line in &parsed.mainboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?,
None => unresolved.push(line.name.clone()),
}
}
for line in &parsed.maybeboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?,
None => unresolved.push(line.name.clone()),
}
}
let deck = self
.get_deck(deck.id)?
.expect("just-imported deck must exist");
Ok((deck, unresolved))
}
/// Render a deck's current contents as export text.
pub fn export_deck_text(&self, deck_id: i64) -> Result<String> {
let deck = self
.get_deck(deck_id)?
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?;
let contents = self.deck_contents(deck_id)?;
Ok(format_deck_text(&deck, &contents))
}
/// Import a plain-text library list, adding each resolved card's
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
/// Returns `(name, new_total_quantity_owned)` per applied line, plus
/// unresolved names.
pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec<String>)> {
let lines = parse_library_text(text);
let mut added = Vec::new();
let mut unresolved = Vec::new();
for line in &lines {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let new_qty = self.add_owned(&oid, line.quantity)?;
added.push((line.name.clone(), new_qty));
}
None => unresolved.push(line.name.clone()),
}
}
Ok((added, unresolved))
}
/// Render the given owned cards (name + current quantity_owned) as
/// library-export text.
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
let mut entries = Vec::with_capacity(oracle_ids.len());
for oid in oracle_ids {
let row: Option<(String, i64)> = self
.conn
.query_row(
&format!(
"SELECT oc.name, lc.quantity_owned FROM {REF_DB}.oracle_cards oc
JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
WHERE oc.oracle_id = ?1"
),
params![oid],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some(entry) = row {
entries.push(entry);
}
}
entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
Ok(format_library_text(&entries))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support;
#[test]
fn parse_card_line_accepts_bare_and_x_prefixed_quantities() {
assert_eq!(
parse_card_line("4 Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(
parse_card_line("4x Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(
parse_card_line("4× Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
);
assert_eq!(parse_card_line("not a card line"), None);
assert_eq!(parse_card_line("0 Lightning Bolt"), None);
}
#[test]
fn parse_deck_text_tolerates_blanks_comments_and_sideboard_alias() {
let text = "// mtgc deck export\n// Format: Commander\n\nCommander\n1 Krenko, Mob Boss\n\nDeck\n1 Sol Ring\n\nSideboard\n1 Some Card\n";
let parsed = parse_deck_text(text);
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }]
);
assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Some Card".into() }]
);
}
#[test]
fn parse_deck_text_defaults_unheaded_lines_to_mainboard() {
let parsed = parse_deck_text("4 Lightning Bolt\n1 Sol Ring\n");
assert_eq!(parsed.mainboard.len(), 2);
assert!(parsed.commander.is_empty());
assert!(parsed.maybeboard.is_empty());
}
#[test]
fn format_deck_text_omits_empty_sections() {
let deck = Deck {
id: 1,
name: "Test".into(),
format: String::new(),
commander_oracle_id: None,
notes: String::new(),
};
let text = format_deck_text(&deck, &[]);
assert_eq!(text, "// mtgc deck export\n");
}
#[test]
fn import_deck_resolves_names_sets_format_and_commander_and_reports_unresolved() {
let fx = test_support::make();
let cat = &fx.catalog;
let text = "// Format: Commander\n\nCommander\n1 Serra Angel\n\nDeck\n4 Lightning Bolt\n1 Totally Fake Card\n\nMaybeboard\n1 Sol Ring\n";
let (deck, unresolved) = cat.import_deck("Imported", text).unwrap();
assert_eq!(deck.format, "Commander");
assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel"));
assert_eq!(unresolved, vec!["Totally Fake Card".to_string()]);
let contents = cat.deck_contents(deck.id).unwrap();
let bolt = contents.iter().find(|e| e.card.oracle_id == "lightning-bolt").unwrap();
assert_eq!(bolt.quantity, 4);
assert!(!bolt.is_commander);
assert!(!bolt.is_maybeboard);
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(sol.is_maybeboard);
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
assert!(angel.is_commander);
}
#[test]
fn import_deck_extra_commander_lines_fold_into_mainboard() {
let fx = test_support::make();
let cat = &fx.catalog;
let text = "Commander\n1 Serra Angel\n1 Sol Ring\n";
let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap();
assert!(unresolved.is_empty());
let contents = cat.deck_contents(deck.id).unwrap();
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
assert!(angel.is_commander);
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!sol.is_commander);
assert!(!sol.is_maybeboard);
}
#[test]
fn export_deck_text_round_trips_through_parser() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Round Trip", Some("serra-angel")).unwrap();
cat.set_deck_format(deck.id, "Commander").unwrap();
cat.set_deck_card(deck.id, "serra-angel", 1, true, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
cat.set_deck_card(deck.id, "sol-ring", 1, false, true).unwrap();
let text = cat.export_deck_text(deck.id).unwrap();
let parsed = parse_deck_text(&text);
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }]
);
assert_eq!(
parsed.mainboard,
vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }]
);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]
);
}
#[test]
fn import_library_list_adds_to_existing_ownership_and_reports_unresolved() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("lightning-bolt", 2).unwrap();
let text = "2 Lightning Bolt\n1 Sol Ring\n1 Not A Real Card\n";
let (added, unresolved) = cat.import_library_list(text).unwrap();
assert_eq!(unresolved, vec!["Not A Real Card".to_string()]);
let bolt_added = added.iter().find(|(n, _)| n == "Lightning Bolt").unwrap();
assert_eq!(bolt_added.1, 4); // 2 existing + 2 imported
let sol_added = added.iter().find(|(n, _)| n == "Sol Ring").unwrap();
assert_eq!(sol_added.1, 1);
}
#[test]
fn export_library_text_formats_only_requested_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("lightning-bolt", 3).unwrap();
cat.add_owned("sol-ring", 1).unwrap();
let text = cat.export_library_text(&["lightning-bolt".to_string()]).unwrap();
assert!(text.contains("3 Lightning Bolt"));
assert!(!text.contains("Sol Ring"));
}
}

View File

@@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
use crate::Catalog;
impl Catalog {
@@ -114,8 +114,8 @@ 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.produced_mana, oc.produces_colorless, {price},
dc.is_maybeboard
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}, dc.is_maybeboard
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
@@ -142,9 +142,10 @@ impl Catalog {
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,
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(13)?),
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
},
is_maybeboard: r.get::<_, i64>(14)? != 0,
is_maybeboard: r.get::<_, i64>(15)? != 0,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -236,6 +237,33 @@ mod tests {
assert!(!row.is_over_assigned());
}
#[test]
fn legalities_are_plumbed_through_deck_contents() {
use crate::model::LegalStatus;
let fx = test_support::make();
let cat = &fx.catalog;
// 2 (Legal) in the "commander" slot (bit offset 12, per LEGALITY_FORMATS).
cat.connection()
.execute(
"UPDATE ref.oracle_cards SET legalities = 4096 WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
let d = cat.create_deck("Legality Check", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.legalities.status_for("Commander"), LegalStatus::Legal);
// Fixture cards default to no legality data at all.
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
assert_eq!(elves.card.legalities.status_for("Commander"), LegalStatus::NotLegal);
}
#[test]
fn over_assignment_detected_across_decks() {
let fx = test_support::make();

View File

@@ -19,7 +19,7 @@ impl Catalog {
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.produced_mana, oc.produces_colorless, {price}
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",

View File

@@ -19,7 +19,7 @@ use serde::Deserialize;
use crate::db::open_reference_for_build;
use crate::error::{CoreError, Result};
use crate::model::ColorSet;
use crate::model::{ColorSet, Legalities};
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
@@ -434,6 +434,10 @@ struct ScryOracle {
/// top-level-only Scryfall field — not per-face, even on DFCs.
#[serde(default)]
produced_mana: Option<Vec<String>>,
/// Format-slug -> status-string (e.g. "legal"/"not_legal"/"banned"/
/// "restricted"). See [`crate::model::Legalities`].
#[serde(default)]
legalities: std::collections::HashMap<String, String>,
}
#[derive(Deserialize)]
@@ -453,8 +457,8 @@ 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,
produced_mana,produces_colorless)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
produced_mana,produces_colorless,legalities)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
@@ -533,6 +537,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
let produced_mana = ColorSet::from_letters(produced);
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
let legalities = Legalities::from_scryfall(&c.legalities);
let changed = ins_card.execute(params![
oracle_id,
c.name,
@@ -544,6 +550,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
oracle_text,
produced_mana.0 as i64,
produces_colorless as i64,
legalities.0,
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
@@ -922,6 +929,48 @@ mod tests {
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
}
#[test]
fn captures_legalities() {
let oracle = r#"[
{"oracle_id":"oid-banned","name":"Balance","cmc":1.0,"color_identity":["W"],"keywords":[],
"legalities":{"standard":"not_legal","modern":"banned","legacy":"banned","vintage":"restricted","commander":"banned"}},
{"oracle_id":"oid-legal","name":"Lightning Bolt","cmc":1.0,"color_identity":["R"],"keywords":[],
"legalities":{"modern":"legal","legacy":"legal","vintage":"legal","pauper":"legal"}},
{"oracle_id":"oid-nolegal","name":"Some Test Card","cmc":1.0,"color_identity":[],"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| -> Legalities {
Legalities(
conn.query_row(
"SELECT legalities FROM oracle_cards WHERE oracle_id=?1",
params![id],
|r| r.get(0),
)
.unwrap(),
)
};
let balance = fetch("oid-banned");
assert_eq!(balance.status_for("Vintage"), crate::model::LegalStatus::Restricted);
assert_eq!(balance.status_for("Modern"), crate::model::LegalStatus::Banned);
assert_eq!(balance.status_for("Standard"), crate::model::LegalStatus::NotLegal);
let bolt = fetch("oid-legal");
assert_eq!(bolt.status_for("Modern"), crate::model::LegalStatus::Legal);
assert_eq!(bolt.status_for("Standard"), crate::model::LegalStatus::NotLegal); // absent key
let vanilla = fetch("oid-nolegal");
for (_, label) in crate::model::LEGALITY_FORMATS {
assert_eq!(vanilla.status_for(label), crate::model::LegalStatus::NotLegal); // no legalities object at all
}
}
#[test]
fn captures_back_face_image_for_double_faced_cards() {
// Transform/MDFC cards: two `card_faces`, each with its own image.

View File

@@ -6,6 +6,7 @@
//! unit-testable and reusable when the app is ported to mobile.
pub mod db;
pub mod decklist;
pub mod decks;
pub mod error;
pub mod filters;
@@ -20,7 +21,8 @@ use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats,
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets,
LibraryFilter, LibraryStats,
};
pub use search::NameIndex;

View File

@@ -6,13 +6,13 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
use crate::model::{CardRef, ColorSet, Legalities, 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, produced_mana,
/// produces_colorless, price_usd)` into its library parts. Shared by
/// [`Catalog::list_library`] and the criteria search.
/// produces_colorless, legalities, price_usd)` 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)> {
@@ -32,7 +32,8 @@ pub(crate) fn row_to_library_parts(
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,
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(12)?),
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
},
))
}
@@ -75,7 +76,7 @@ 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.produced_mana, oc.produces_colorless, {price}
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0

View File

@@ -75,6 +75,73 @@ impl ColorSet {
}
}
/// The 8 formats this app tracks legality for, in a fixed order that defines
/// their bit position within [`Legalities`] (2 bits each, in order). This
/// order must never change once shipped — it's baked into every ingested
/// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the
/// display label used throughout the app (matches DeckView's
/// `FORMATS`/`COMMANDER_FORMATS`).
pub const LEGALITY_FORMATS: [(&str, &str); 8] = [
("standard", "Standard"),
("pioneer", "Pioneer"),
("modern", "Modern"),
("legacy", "Legacy"),
("vintage", "Vintage"),
("pauper", "Pauper"),
("commander", "Commander"),
("brawl", "Brawl"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegalStatus {
NotLegal,
Legal,
Banned,
Restricted,
}
/// Per-format legality, encoded as 2 bits/format x 8 formats packed into one
/// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors
/// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of
/// 1-bit flags, since each format has 4 possible statuses (not-legal / legal
/// / banned / restricted), not just a binary flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Legalities(pub i64);
impl Legalities {
/// Build from Scryfall's `legalities` object (format-slug -> status-string
/// map). Any format not present in `map`, or present with an unrecognized
/// status string, decodes as `NotLegal`.
pub fn from_scryfall(map: &std::collections::HashMap<String, String>) -> Self {
let mut bits = 0i64;
for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() {
let status: i64 = match map.get(*key).map(String::as_str) {
Some("legal") => 1,
Some("banned") => 2,
Some("restricted") => 3,
_ => 0, // "not_legal", or key missing entirely
};
bits |= status << (i * 2);
}
Legalities(bits)
}
/// Decode the status for a given format's display label (e.g.
/// "Commander", "Modern" — matches DeckView's `FORMATS` strings).
/// Unknown labels decode as `NotLegal`.
pub fn status_for(self, format_label: &str) -> LegalStatus {
let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else {
return LegalStatus::NotLegal;
};
match (self.0 >> (i * 2)) & 0b11 {
1 => LegalStatus::Legal,
2 => LegalStatus::Banned,
3 => LegalStatus::Restricted,
_ => LegalStatus::NotLegal,
}
}
}
/// A card as returned by search / library queries. Oracle-level (one per
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -102,6 +169,10 @@ pub struct CardRef {
/// USD market price (Scryfall's `prices.usd`) for the same printing that
/// supplied `image_uri`, or 0 if unknown.
pub price_usd: f64,
/// Per-format legality (banned/restricted/legal). See
/// [`LEGALITY_FORMATS`] for the bit-position order and
/// [`Legalities::status_for`] to decode a specific format.
pub legalities: Legalities,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
@@ -212,3 +283,38 @@ pub struct LibraryFilter {
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn legalities_round_trip_all_statuses() {
let mut map = HashMap::new();
map.insert("standard".to_string(), "legal".to_string());
map.insert("modern".to_string(), "banned".to_string());
map.insert("vintage".to_string(), "restricted".to_string());
// "pauper" deliberately absent
let legalities = Legalities::from_scryfall(&map);
assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal);
assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned);
assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted);
assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal);
}
#[test]
fn legalities_unknown_format_label_is_not_legal() {
let legalities = Legalities::from_scryfall(&HashMap::new());
assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal);
}
#[test]
fn legalities_default_is_all_not_legal() {
let legalities = Legalities::default();
assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal);
assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal);
}
}

View File

@@ -10,7 +10,7 @@ use rusqlite::Connection;
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet};
use crate::model::{CardRef, ColorSet, Legalities};
/// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra
/// DB round-trip. `AsRef<str>` yields the name that nucleo matches against.
@@ -34,7 +34,8 @@ impl NameIndex {
pub fn build(conn: &Connection) -> Result<Self> {
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.produced_mana, oc.produces_colorless, {price}
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri"),
@@ -54,7 +55,8 @@ impl NameIndex {
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,
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
legalities: Legalities(r.get::<_, i64>(10)?),
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
},
})
})?

View File

@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
type_line 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}
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
);
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);

View File

@@ -220,3 +220,48 @@ pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
let s = db.lock().unwrap();
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct DeckImportResult {
pub deck: Deck,
pub unresolved: Vec<String>,
}
#[derive(Serialize)]
pub struct LibraryImportResult {
pub added: Vec<(String, i64)>,
pub unresolved: Vec<String>,
}
#[tauri::command]
pub fn export_deck(db: Db, deck_id: i64, path: String) -> Res<()> {
let s = db.lock().unwrap();
let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_deck(db: Db, path: String, name: String) -> Res<DeckImportResult> {
let s = db.lock().unwrap();
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let (deck, unresolved) = s.catalog()?.import_deck(&name, &text).map_err(|e| e.to_string())?;
Ok(DeckImportResult { deck, unresolved })
}
#[tauri::command]
pub fn export_library_cards(db: Db, oracle_ids: Vec<String>, path: String) -> Res<()> {
let s = db.lock().unwrap();
let text = s
.catalog()?
.export_library_text(&oracle_ids)
.map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_library_cards(db: Db, path: String) -> Res<LibraryImportResult> {
let s = db.lock().unwrap();
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let (added, unresolved) = s.catalog()?.import_library_list(&text).map_err(|e| e.to_string())?;
Ok(LibraryImportResult { added, unresolved })
}

View File

@@ -42,6 +42,10 @@ pub fn run() {
commands::set_deck_card,
commands::deck_contents,
commands::card_decks,
commands::export_deck,
commands::import_deck,
commands::export_library_cards,
commands::import_library_cards,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -10,6 +10,7 @@ import {
StatTile,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { DeckView } from "./views/DeckView";
@@ -399,6 +400,12 @@ export default function App() {
</div>
</div>
</div>
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onNewLibrary={newLibrary}
/>
</HoverPreviewProvider>
);
}

170
src/CommandPalette.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from "react";
import { api, CardRef, Deck } from "./api";
import { DialogFrame, useClickPreview } from "./components";
type Tab = "add" | "library" | "decks";
interface PaletteItem {
key: string;
label: string;
hint?: string;
run: () => void;
}
/** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd"
* matches "Go to Decks". Good enough for the small static command list and
* the deck list; card search still defers to the server-side fuzzy matcher
* via `api.searchCards`. */
function subsequenceMatch(query: string, text: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++;
}
return qi === q.length;
}
/**
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
* selecting a card opens the existing click-to-zoom preview modal without
* changing tabs.
*/
export function CommandPalette({
onNavigate,
onUpdateCardData,
onOpenLibrary,
onNewLibrary,
}: {
onNavigate: (tab: Tab) => void;
onUpdateCardData: () => void;
onOpenLibrary: () => void;
onNewLibrary: () => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [decks, setDecks] = useState<Deck[]>([]);
const [cardResults, setCardResults] = useState<CardRef[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const { open: openCardPreview } = useClickPreview();
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.altKey && e.key === "/") {
e.preventDefault();
setOpen((o) => !o);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (!open) return;
setQuery("");
setCardResults([]);
api.listDecks().then(setDecks);
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const q = query.trim();
if (!q) return setCardResults([]);
const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80);
return () => clearTimeout(t);
}, [query, open]);
function close() {
setOpen(false);
}
const staticCommands: PaletteItem[] = [
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
];
const items: PaletteItem[] = [
...staticCommands.filter((c) => subsequenceMatch(query, c.label)),
...decks
.filter((d) => subsequenceMatch(query, d.name))
.map((d) => ({
key: `deck-${d.id}`,
label: d.name,
hint: "Deck",
run: () => {
// DeckView reads its initial selection from this same
// localStorage key on mount (usePersistedState("deck-selected"),
// src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on
// tab switch, so writing here before navigating is enough for it
// to pick up the selection on its next mount.
localStorage.setItem("deck-selected", JSON.stringify(d.id));
onNavigate("decks");
},
})),
...cardResults.map((c) => ({
key: `card-${c.oracle_id}`,
label: c.name,
hint: c.type_line,
run: () => openCardPreview(c),
})),
].slice(0, 20);
useEffect(() => setSel(0), [items.length, query]);
function pick(item: PaletteItem) {
item.run();
close();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && items[sel]) {
e.preventDefault();
pick(items[sel]);
}
}
if (!open) return null;
return (
<DialogFrame title="Command palette" onCancel={close} wide>
<input
ref={inputRef}
autoFocus
style={{ width: "100%", marginBottom: 8 }}
placeholder="Type a command, deck, or card name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{items.length > 0 && (
<ul className="result-list" style={{ marginBottom: 0 }}>
{items.map((item, i) => (
<li
key={item.key}
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(item)}
>
<span className="card-name">{item.label}</span>
{item.hint && <span className="card-type">{item.hint}</span>}
</li>
))}
</ul>
)}
</DialogFrame>
);
}

View File

@@ -15,6 +15,7 @@ export interface CardRef {
produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C}
price_usd: number; // USD market price, or 0 if unknown
legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS
}
export interface LibraryCard extends CardRef {
@@ -41,6 +42,16 @@ export interface Deck {
notes: string;
}
export interface DeckImportResult {
deck: Deck;
unresolved: string[];
}
export interface LibraryImportResult {
added: [string, number][];
unresolved: string[];
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
@@ -107,6 +118,32 @@ export function maskToLetters(mask: number): string {
.join("");
}
// --- Legality bitmask helpers ----------------------------------------------
// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per
// format, in this fixed order. Do not reorder.
export const LEGALITY_FORMATS: { key: string; label: string }[] = [
{ key: "standard", label: "Standard" },
{ key: "pioneer", label: "Pioneer" },
{ key: "modern", label: "Modern" },
{ key: "legacy", label: "Legacy" },
{ key: "vintage", label: "Vintage" },
{ key: "pauper", label: "Pauper" },
{ key: "commander", label: "Commander" },
{ key: "brawl", label: "Brawl" },
];
export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted";
const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"];
export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus {
const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel);
if (i < 0) return "not_legal";
const code = (legalities >> (i * 2)) & 0b11;
return LEGAL_STATUS_CODES[code];
}
// --- Commands -------------------------------------------------------------
export const api = {
@@ -150,4 +187,12 @@ export const api = {
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }),
exportLibraryCards: (oracleIds: string[], path: string) =>
invoke<void>("export_library_cards", { oracleIds, path }),
importLibraryCards: (path: string) =>
invoke<LibraryImportResult>("import_library_cards", { path }),
};

View File

@@ -63,6 +63,26 @@ const ICON_ARROW_LEFT_CIRCLE = icon(
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
const ICON_CARDS = icon(
<>
<rect x="4" y="2" width="12" height="17" rx="2" />
<rect x="8" y="5" width="12" height="17" rx="2" />
</>,
);
const ICON_DOWNLOAD = icon(
<>
<path d="M12 3v12" />
<polyline points="7 11 12 16 17 11" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_UPLOAD = icon(
<>
<path d="M12 21V9" />
<polyline points="7 13 12 8 17 13" />
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
/**
* A small round icon-only button, used consistently for lightweight actions
@@ -108,22 +128,29 @@ export const Icons = {
close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
cards: ICON_CARDS,
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
};
// --- Dialogs ------------------------------------------------------------
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and
* [`PromptDialog`]. Dismisses on Escape or backdrop click.
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
* command palette). Dismisses on Escape or backdrop click.
*/
function DialogFrame({
export function DialogFrame({
title,
children,
onCancel,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
wide?: boolean;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
@@ -133,7 +160,7 @@ function DialogFrame({
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
<h3 className="dialog-title">{title}</h3>
{children}
</div>
@@ -220,6 +247,83 @@ export function PromptDialog({
);
}
/** Read-only informational dialog: a message plus an optional bulleted list
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
export function InfoDialog({
title,
message,
items,
onClose,
}: {
title: string;
message?: ReactNode;
items?: string[];
onClose: () => void;
}) {
return (
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
{message && <p className="dialog-message">{message}</p>}
{items && items.length > 0 && (
<ul className="dialog-list">
{items.map((it, i) => (
<li key={i}>{it}</li>
))}
</ul>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
OK
</button>
</div>
</DialogFrame>
);
}
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
export function SampleHandModal({
hand,
libraryCount,
onDrawOne,
onNewHand,
onClose,
}: {
hand: CardRef[];
libraryCount: number;
onDrawOne: () => void;
onNewHand: () => void;
onClose: () => void;
}) {
return (
<DialogFrame title="Sample hand" onCancel={onClose} wide>
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
<span className="muted" style={{ fontSize: 12 }}>
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
</span>
<span className="spacer" />
<button onClick={onDrawOne} disabled={libraryCount === 0}>
Draw a card
</button>
<button onClick={onNewHand}>New hand</button>
</div>
{hand.length > 0 ? (
<div className="tile-grid">
{hand.map((c, i) => (
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
))}
</div>
) : (
<p className="hint">Your deck has no cards to draw yet.</p>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
@@ -719,10 +823,12 @@ function primaryType(typeLine: string): string {
return words[words.length - 1] || "Other";
}
function firstSubtype(typeLine: string): string {
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
* by (type, subtype) rather than colliding on a subtype shared across types. */
function subtypeLabel(typeLine: string): string {
const parts = typeLine.split("—");
if (parts.length < 2) return "No subtype";
return parts[1].trim().split(/\s+/)[0] || "No subtype";
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
return `${primaryType(typeLine)} — ${subtype}`;
}
function colorLabel(mask: number): string {
@@ -741,7 +847,7 @@ export function groupCards<T extends CardRef>(
mode === "type"
? primaryType(c.type_line)
: mode === "subtype"
? firstSubtype(c.type_line)
? subtypeLabel(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
@@ -962,6 +1068,7 @@ export function CardTile({
card,
corner,
cornerLeft,
cornerBottomLeft,
footer,
selected,
}: {
@@ -969,6 +1076,8 @@ export function CardTile({
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
/** Rendered bottom-left, e.g. a secondary per-card action button. */
cornerBottomLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
@@ -990,6 +1099,7 @@ export function CardTile({
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>

View File

@@ -621,14 +621,14 @@ h2 {
letter-spacing: -0.2px;
}
h3 {
margin: 18px 0 10px;
font-size: 13px;
font-weight: 700;
margin: 20px 0 12px;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
letter-spacing: 0.05em;
color: var(--text-strong);
border-bottom: 2px solid var(--border-soft);
padding-bottom: 10px;
}
h4 {
margin: 14px 0 7px;
@@ -636,6 +636,26 @@ h4 {
font-weight: 600;
color: var(--accent-hover);
}
/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a
* section) — sticky within .content's scroll area so the current group
* stays visible while scrolling through a long list, instead of scrolling
* out of view along with its cards. Applied via class (not the bare h3/h4
* tag) since h3 is also used for .dialog-title, which must not be sticky. */
.group-header {
position: sticky;
top: 0;
z-index: 5;
margin: 14px 0 8px;
padding: 8px 0;
background: var(--bg-app);
font-size: 15px;
font-weight: 700;
text-transform: none;
letter-spacing: normal;
color: var(--text-strong);
border-bottom: 2px solid var(--accent);
}
.muted {
color: var(--muted);
}
@@ -660,6 +680,86 @@ h4 {
font-weight: 700;
}
/* Deck format-legality panel: a prominent, colorful tile, matching
.commander-hero / .deck-stats in weight, above the fold on every deck. */
.legality-tile {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.legality-tile.legal {
background: rgba(75, 176, 115, 0.16);
border-color: rgba(75, 176, 115, 0.4);
}
.legality-tile.illegal {
background: rgba(216, 80, 63, 0.16);
border-color: rgba(216, 80, 63, 0.4);
}
.legality-tile.unchecked {
background: var(--bg-panel);
border-color: var(--border-soft);
}
.legality-tile-head {
display: flex;
align-items: center;
gap: 14px;
}
.legality-tile-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 999px;
font-size: 20px;
font-weight: 700;
}
.legality-tile.legal .legality-tile-icon {
background: var(--success);
color: #fff;
}
.legality-tile.illegal .legality-tile-icon {
background: var(--danger);
color: #fff;
}
.legality-tile.unchecked .legality-tile-icon {
background: var(--muted);
color: var(--bg-panel);
}
.legality-tile-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.legality-tile-status {
font-weight: 700;
font-size: 14px;
}
.legality-tile.legal .legality-tile-status {
color: var(--success);
}
.legality-tile.illegal .legality-tile-status {
color: var(--danger);
}
.legality-tile-detail {
font-size: 12px;
color: var(--muted);
}
.legality-tile-reasons {
margin: 0 0 0 54px;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text);
}
/* ============================================================
Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards
and both Library views (list + tile) for a consistent look.
@@ -844,6 +944,11 @@ li:focus-within .row-actions {
padding: 20px;
box-shadow: var(--shadow-lg);
}
.dialog-wide {
width: min(900px, 92vw);
max-height: 86vh;
overflow-y: auto;
}
.dialog-title {
margin: 0 0 10px;
font-size: 16px;
@@ -860,6 +965,18 @@ li:focus-within .row-actions {
font-size: 14px;
line-height: 1.5;
}
.dialog-list {
margin: 0 0 18px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
overflow-y: auto;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.dialog-field {
display: flex;
flex-direction: column;
@@ -1210,7 +1327,10 @@ tbody tr:hover {
.preview-modal-backdrop {
position: fixed;
inset: 0;
z-index: 300;
/* Above .dialog-backdrop (400) — a card can now be clicked to zoom from
inside a dialog (e.g. the sample hand modal), and the zoomed preview
must stay on top of the dialog it was opened from. */
z-index: 450;
display: flex;
align-items: center;
justify-content: center;
@@ -1370,6 +1490,17 @@ tbody tr:hover {
.tile:focus-within .tile-corner-bottom-right {
opacity: 1;
}
.tile-corner-bottom-left {
position: absolute;
bottom: 8px;
left: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-left,
.tile:focus-within .tile-corner-bottom-left {
opacity: 1;
}
.tile-selected .tile-img {
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
}
@@ -1450,6 +1581,14 @@ tbody tr:hover {
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
/* Separates the deck-level info panels (commander, format, analysis,
legality) from the card-management controls + lists below. */
.deck-section-divider {
border: none;
border-top: 1px solid var(--border-soft);
margin: 4px 0 18px;
}
.commander-box {
display: flex;
@@ -1558,3 +1697,4 @@ tbody tr:hover {
color: var(--faint);
margin-bottom: 2px;
}

View File

@@ -1,5 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -11,9 +12,11 @@ import {
GroupMode,
IconButton,
Icons,
InfoDialog,
ManaCurve,
PromptDialog,
QtyStepper,
SampleHandModal,
StatTile,
ViewToggle,
groupCards,
@@ -34,6 +37,8 @@ type DeckDialog =
| { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck }
| { kind: "duplicate"; deck: Deck }
| { kind: "import-name"; suggestedName: string; path: string }
| { kind: "import-result"; result: DeckImportResult }
| null;
function friendlyDeckError(err: unknown): string {
@@ -181,6 +186,8 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
// the Library tab, so a mistaken removal can be undone with +.
const REMOVAL_GRACE_MS = 5000;
const STARTING_HAND_SIZE = 7;
const FORMATS = [
"",
"Commander",
@@ -195,10 +202,131 @@ const FORMATS = [
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
const COMMANDER_DECK_SIZE = 100;
const CONSTRUCTED_MIN_DECK_SIZE = 60;
const CONSTRUCTED_MAX_COPIES = 4;
/**
* Deck editor. A deck's cards are split into two groups — "In your library"
* (owned) and "Not owned" — each with its own search box, and each groupable by
* card attribute. Deck-level format + commander metadata sit at the top.
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
}
const reasons: string[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
}
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 1) {
reasons.push(`More than one card is marked as commander.`);
}
const commander = commanders[0];
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
}
}
if (commander) {
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~commander.color_identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the commander's color identity.`);
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
} else {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
* groupable by card attribute; "Combine sections" merges these two into one
* list. The maybeboard is always shown as its own separate section. Deck-level
* format + commander metadata sit at the top.
*/
export function DeckView() {
const [decks, setDecks] = useState<Deck[]>([]);
@@ -207,6 +335,7 @@ export function DeckView() {
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
@@ -225,6 +354,10 @@ export function DeckView() {
const [bulkBusy, setBulkBusy] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const [handOpen, setHandOpen] = useState(false);
const [hand, setHand] = useState<DeckEntry[]>([]);
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
const deck = decks.find((d) => d.id === selected) ?? null;
const loadDecks = useCallback(async () => {
@@ -251,6 +384,9 @@ export function DeckView() {
useEffect(() => {
loadContents();
setSelectedCards(new Set());
setHandOpen(false);
setHand([]);
setLibraryStack([]);
}, [loadContents]);
// Cancel any in-flight removal timers on unmount so they don't fire against
@@ -310,6 +446,39 @@ export function DeckView() {
setSelected(d.id);
}
async function exportDeck(d: Deck) {
const path = await save({
title: "Export deck",
defaultPath: `${d.name}.txt`,
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportDeck(d.id, path);
}
async function pickImportFile() {
const path = await open({
title: "Import deck",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path !== "string") return;
const base = path.split(/[/\\]/).pop() ?? "Imported deck";
const suggestedName = base.replace(/\.txt$/i, "");
setDialog({ kind: "import-name", suggestedName, path });
}
async function confirmImportName(name: string) {
if (dialog?.kind !== "import-name") return;
try {
const result = await api.importDeck(dialog.path, name);
setDialog({ kind: "import-result", result });
setDialogError(null);
await loadDecks();
setSelected(result.deck.id);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function addToDeck(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
@@ -345,6 +514,35 @@ export function DeckView() {
await loadContents();
}
/** Every mainboard copy (commander/maybeboard excluded), one entry per
* physical copy so a card at quantity N is N times as likely to be drawn,
* shuffled in place (Fisher-Yates). */
function shuffledLibrary(): DeckEntry[] {
const pool: DeckEntry[] = [];
for (const e of contents) {
if (e.is_commander || e.is_maybeboard) continue;
for (let i = 0; i < e.quantity; i++) pool.push(e);
}
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool;
}
function drawNewHand() {
const shuffled = shuffledLibrary();
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
setHandOpen(true);
}
function drawOneMore() {
if (libraryStack.length === 0) return;
setHand((h) => [...h, libraryStack[0]]);
setLibraryStack((stack) => stack.slice(1));
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
const clamped = Math.max(0, qty);
@@ -478,6 +676,7 @@ export function DeckView() {
const maybeboard = contents.filter((e) => e.is_maybeboard);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
function deckTiles(entries: DeckEntry[]) {
return (
@@ -506,13 +705,10 @@ export function DeckView() {
pending={pendingRemoval.has(e.oracle_id)}
/>
}
footer={
<>
{e.is_commander && <span className="tile-badge">★ commander</span>}
<span className="spacer" />
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</>
cornerBottomLeft={
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
/>
))}
</div>
@@ -580,7 +776,7 @@ export function DeckView() {
return groupCards(entries, groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4>
<h4 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h4>
)}
@@ -592,7 +788,10 @@ export function DeckView() {
return (
<div style={{ display: "flex", gap: 20 }}>
<div style={{ width: 240, flexShrink: 0 }}>
<h2>Decks</h2>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>Decks</h2>
<IconButton icon={Icons.upload} label="Import deck…" onClick={pickImportFile} />
</div>
<div className="row" style={{ marginBottom: 10 }}>
<input
value={newName}
@@ -662,46 +861,57 @@ export function DeckView() {
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<div className="row" style={{ gap: 14 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
<input
ref={selectAllRef}
type="checkbox"
checked={contents.length > 0 && selectedCards.size === contents.length}
onChange={toggleSelectAllCards}
disabled={contents.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<h2>{deck.name}</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
<h2>{deck.name}</h2>
<div className="row" style={{ gap: 8 }}>
<button onClick={() => exportDeck(deck)}>
{Icons.download}
Export deck
</button>
<button
onClick={drawNewHand}
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
>
{Icons.cards}
Sample hand
</button>
</div>
)}
</div>
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<CommanderHero card={commanderEntry} onClear={clearCommander} />
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
</div>
)}
</div>
{contents.length > 0 && (
<div className="deck-stats">
<div className="deck-stats-head">Deck analysis</div>
@@ -737,64 +947,131 @@ export function DeckView() {
</div>
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
{deck.format !== "Other" && (
<div className={`legality-tile ${deckLegality.status}`}>
<div className="legality-tile-head">
<span className="legality-tile-icon">
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
</span>
<div className="legality-tile-info">
<span className="legality-tile-status">
{deckLegality.status === "legal"
? "Legal"
: deckLegality.status === "illegal"
? "Illegal"
: "Not checked"}
</span>
<span className="legality-tile-detail">
{deckLegality.status === "legal"
? `Meets all ${deck.format} deck-building rules.`
: deckLegality.status === "illegal"
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
: deck.format
? `Legality isn't checked for "${deck.format}".`
: "Set a format to check legality."}
</span>
</div>
</div>
)}
{deckLegality.status === "illegal" && (
<ul className="legality-tile-reasons">
{deckLegality.reasons.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
</div>
)}
<hr className="deck-section-divider" />
<div className="row" style={{ gap: 16 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
<input
ref={selectAllRef}
type="checkbox"
checked={contents.length > 0 && selectedCards.size === contents.length}
onChange={toggleSelectAllCards}
disabled={contents.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Combine owned/unowned into one section">
<input
type="checkbox"
checked={combineSections}
onChange={(e) => setCombineSections(e.target.checked)}
/>
<span className="muted" style={{ fontSize: 12 }}>
Combine sections
</span>
</label>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
<section style={{ marginTop: 18 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
</h3>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
onChange={setLibQuery}
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
</section>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add ones you don't own…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup(unowned)}
</section>
{combineSections ? (
<section style={{ marginTop: 10 }}>
<h3>
All cards <span className="badge">{owned.length + unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add to your deck…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup([...owned, ...unowned])}
</section>
) : (
<>
<section style={{ marginTop: 10 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
</h3>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
onChange={setLibQuery}
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add ones you don't own…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup(unowned)}
</section>
</>
)}
<section style={{ marginTop: 26 }}>
<h3>
@@ -848,6 +1125,38 @@ export function DeckView() {
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-name" && (
<PromptDialog
title="Import deck"
label="Deck name"
defaultValue={dialog.suggestedName}
confirmLabel="Import"
error={dialogError}
onConfirm={confirmImportName}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "import-result" && (
<InfoDialog
title="Deck imported"
message={
dialog.result.unresolved.length > 0
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Imported "${dialog.result.deck.name}" — every card matched.`
}
items={dialog.result.unresolved}
onClose={() => setDialog(null)}
/>
)}
{handOpen && (
<SampleHandModal
hand={hand}
libraryCount={libraryStack.length}
onDrawOne={drawOneMore}
onNewHand={drawNewHand}
onClose={() => setHandOpen(false)}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
import {
CardThumb,
CardTile,
@@ -7,6 +8,7 @@ import {
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
LocationField,
PromptDialog,
QtyStepper,
@@ -49,9 +51,11 @@ export function LibraryBrowser() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const [bulkDelta, setBulkDelta] = useState(1);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -172,7 +176,8 @@ export function LibraryBrowser() {
* owned count (e.g. select a handful of basic lands, "Add All" to put
* every copy you own into the deck at once) — overwrites the quantity for
* cards already in the deck rather than skipping them, so it doubles as a
* bulk "fix the land count" tool.
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
* deck's maybeboard instead of the mainboard.
*/
async function bulkAddToDeck(mode: "one" | "all") {
if (bulkDeckId === "") return;
@@ -183,7 +188,13 @@ export function LibraryBrowser() {
const targets = rows.filter((r) => selected.has(r.oracle_id));
await Promise.all(
targets.map((r) =>
api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false),
api.setDeckCard(
deck.id,
r.oracle_id,
mode === "all" ? r.quantity_owned : 1,
false,
bulkToMaybeboard,
),
),
);
setRefreshKey((k) => k + 1);
@@ -212,6 +223,28 @@ export function LibraryBrowser() {
}
}
async function exportSelected() {
const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id);
if (targets.length === 0) return;
const path = await save({
title: "Export library cards",
defaultPath: "library-export.txt",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportLibraryCards(targets, path);
}
async function pickImportFile() {
const path = await open({
title: "Import library cards",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path !== "string") return;
const result = await api.importLibraryCards(path);
setImportResult(result);
setRefreshKey((k) => k + 1);
}
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
@@ -342,6 +375,7 @@ export function LibraryBrowser() {
</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<button onClick={pickImportFile}>Import cards…</button>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
@@ -378,6 +412,17 @@ export function LibraryBrowser() {
>
Add All
</button>
<label className="row" style={{ gap: 4, cursor: "pointer" }} title="Send to the maybeboard instead of the mainboard">
<input
type="checkbox"
checked={bulkToMaybeboard}
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
disabled={bulkBusy}
/>
<span className="muted" style={{ fontSize: 12 }}>
Maybeboard
</span>
</label>
</span>
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
Set location…
@@ -405,6 +450,13 @@ export function LibraryBrowser() {
+
</button>
</span>
<button
onClick={exportSelected}
disabled={bulkBusy}
title="Export the selected cards to a decklist file"
>
Export selected
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
@@ -424,6 +476,19 @@ export function LibraryBrowser() {
/>
)}
{importResult && (
<InfoDialog
title="Cards imported"
message={
importResult.unresolved.length > 0
? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.`
}
items={importResult.unresolved}
onClose={() => setImportResult(null)}
/>
)}
<div className="filters">
<div className="field">
<label>Name contains</label>
@@ -516,7 +581,7 @@ export function LibraryBrowser() {
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3>
<h3 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}