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:
474
crates/mtgc-core/src/decklist.rs
Normal file
474
crates/mtgc-core/src/decklist.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
170
src/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/api.ts
45
src/api.ts
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
156
src/styles.css
156
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user