Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every screen size the Decks tab opens to a deck list page (DeckList), and picking a deck opens its builder full-width with an "All decks" back button. The list shows each deck as an art tile (or a compact row, toggleable) with its colors, size against its format, owned %, cost and legality, and can be searched, filtered by format and sorted (recently opened, name, format, size, cost, owned %). Rename/duplicate/export/delete live in each deck's menu and beside the builder's title; New deck now asks for a format too, defaulting to the last one used. A deck's cover art is the commander, else its priciest nonland, unless a card is chosen with "Use as cover" in the card preview. The cover is stored in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a document carrying one is version 3, so the sync server must be updated before clients publish covers. The list's stats come from one all_deck_contents query, summarised with the builder's own analysis code, now in deckAnalysis.ts. Closes #9 Closes #11 Closes #12
This commit is contained in:
@@ -19,6 +19,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
||||
include_str!("sql/library/0004_deck_maybeboard.sql"),
|
||||
include_str!("sql/library/0005_deck_uuid.sql"),
|
||||
include_str!("sql/library/0006_printings.sql"),
|
||||
include_str!("sql/library/0007_deck_cover.sql"),
|
||||
];
|
||||
|
||||
/// The attached alias under which the reference db is available at runtime.
|
||||
|
||||
@@ -876,6 +876,7 @@ mod tests {
|
||||
format: String::new(),
|
||||
commander_oracle_id: None,
|
||||
notes: String::new(),
|
||||
cover_oracle_id: None,
|
||||
};
|
||||
let text = format_deck_text(&deck, &[], &HashMap::new());
|
||||
assert_eq!(text, "// mtgc deck export\n");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Deck operations and deck membership lookups.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
@@ -27,7 +29,7 @@ impl Catalog {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
|
||||
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks WHERE id = ?1",
|
||||
params![id],
|
||||
Self::map_deck,
|
||||
)
|
||||
@@ -36,7 +38,7 @@ impl Catalog {
|
||||
|
||||
pub fn list_decks(&self) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, format, commander_oracle_id, notes
|
||||
"SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id
|
||||
FROM decks ORDER BY name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt
|
||||
@@ -60,7 +62,7 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Duplicate a deck under a new name: same format/commander/notes, plus a
|
||||
/// Duplicate a deck under a new name: same format/commander/notes/cover, plus a
|
||||
/// copy of every deck_cards row (quantity, flags, chosen printing). Fails if
|
||||
/// `new_name` is already taken.
|
||||
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
||||
@@ -70,9 +72,9 @@ impl Catalog {
|
||||
// A duplicate is a genuinely new deck, so it gets its own identity
|
||||
// rather than inheriting the source's.
|
||||
self.conn.execute(
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, lower(hex(randomblob(16))))",
|
||||
params![new_name, src.format, src.commander_oracle_id, src.notes],
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes, cover_oracle_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, lower(hex(randomblob(16))))",
|
||||
params![new_name, src.format, src.commander_oracle_id, src.notes, src.cover_oracle_id],
|
||||
)?;
|
||||
let new_id = self.conn.last_insert_rowid();
|
||||
self.conn.execute(
|
||||
@@ -123,6 +125,26 @@ impl Catalog {
|
||||
/// from it, so a double-faced card never mixes two printings. An id that
|
||||
/// no longer resolves (or has no image) falls back to the default.
|
||||
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
|
||||
Ok(self
|
||||
.deck_entries(Some(deck_id))?
|
||||
.into_iter()
|
||||
.map(|(_, e)| e)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every deck's contents in one query, keyed by deck id — what the deck
|
||||
/// list summarises. Decks with no cards are absent.
|
||||
pub fn all_deck_contents(&self) -> Result<HashMap<i64, Vec<DeckEntry>>> {
|
||||
let mut by_deck: HashMap<i64, Vec<DeckEntry>> = HashMap::new();
|
||||
for (deck_id, entry) in self.deck_entries(None)? {
|
||||
by_deck.entry(deck_id).or_default().push(entry);
|
||||
}
|
||||
Ok(by_deck)
|
||||
}
|
||||
|
||||
/// The rows behind [`Self::deck_contents`], for one deck or (with `None`)
|
||||
/// all of them, each tagged with its deck id.
|
||||
fn deck_entries(&self, deck_id: Option<i64>) -> Result<Vec<(i64, DeckEntry)>> {
|
||||
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,
|
||||
@@ -131,23 +153,24 @@ impl Catalog {
|
||||
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
|
||||
oc.produced_mana, oc.produces_colorless,
|
||||
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
|
||||
oc.oracle_text
|
||||
oc.oracle_text, dc.deck_id
|
||||
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
|
||||
LEFT JOIN {REF_DB}.printings pp ON pp.scryfall_id = dc.preferred_scryfall_id
|
||||
WHERE dc.deck_id = ?1
|
||||
{filter}
|
||||
ORDER BY oc.name COLLATE NOCASE",
|
||||
filter = if deck_id.is_some() { "WHERE dc.deck_id = ?1" } else { "" },
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![deck_id], |r| {
|
||||
.query_map(rusqlite::params_from_iter(deck_id), |r| {
|
||||
let type_line: String = r.get(8)?;
|
||||
let partner = crate::partner::parse(&r.get::<_, String>(18)?, &type_line);
|
||||
Ok(DeckEntry {
|
||||
let entry = DeckEntry {
|
||||
quantity: r.get(0)?,
|
||||
is_commander: r.get::<_, i64>(1)? != 0,
|
||||
quantity_owned: r.get(2)?,
|
||||
@@ -169,7 +192,8 @@ impl Catalog {
|
||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||
preferred_scryfall_id: r.get(17)?,
|
||||
partner,
|
||||
})
|
||||
};
|
||||
Ok((r.get(19)?, entry))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
@@ -178,7 +202,7 @@ impl Catalog {
|
||||
/// The decks a given card is currently assigned to.
|
||||
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
|
||||
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id
|
||||
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
|
||||
WHERE dc.oracle_id = ?1
|
||||
ORDER BY d.name COLLATE NOCASE",
|
||||
@@ -196,6 +220,7 @@ impl Catalog {
|
||||
format: r.get(2)?,
|
||||
commander_oracle_id: r.get(3)?,
|
||||
notes: r.get(4)?,
|
||||
cover_oracle_id: r.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,6 +233,17 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Choose (or clear, with `None`) the card whose art headlines the deck in
|
||||
/// the deck list. Not checked against the deck's contents: a cover that
|
||||
/// leaves the deck is simply passed over by the UI.
|
||||
pub fn set_deck_cover(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE decks SET cover_oracle_id = ?1 WHERE id = ?2",
|
||||
params![oracle_id, deck_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
||||
/// the corresponding deck_cards row as the commander when present.
|
||||
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
||||
@@ -508,6 +544,56 @@ mod tests {
|
||||
assert!(cat.duplicate_deck(src.id, "Original").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deck_contents_groups_each_decks_cards_by_deck() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let a = cat.create_deck("A", None).unwrap();
|
||||
let b = cat.create_deck("B", None).unwrap();
|
||||
let empty = cat.create_deck("Empty", None).unwrap();
|
||||
cat.set_deck_card(a.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card(a.id, "lightning-bolt", 4, false, true).unwrap();
|
||||
cat.set_deck_card(b.id, "sol-ring", 2, false, false).unwrap();
|
||||
cat.set_deck_card_printing(b.id, "sol-ring", Some("print-sol-ring")).unwrap();
|
||||
|
||||
let all = cat.all_deck_contents().unwrap();
|
||||
assert!(!all.contains_key(&empty.id));
|
||||
let names = |id: i64| -> Vec<(String, i64)> {
|
||||
all[&id].iter().map(|e| (e.card.oracle_id.clone(), e.quantity)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
names(a.id),
|
||||
vec![("lightning-bolt".to_string(), 4), ("sol-ring".to_string(), 1)]
|
||||
);
|
||||
assert_eq!(names(b.id), vec![("sol-ring".to_string(), 2)]);
|
||||
// Each deck's slot carries its own flags and chosen printing.
|
||||
assert!(all[&a.id][0].is_maybeboard);
|
||||
assert_eq!(all[&b.id][0].preferred_scryfall_id.as_deref(), Some("print-sol-ring"));
|
||||
assert_eq!(all[&a.id][1].preferred_scryfall_id, None);
|
||||
// Same rows as the per-deck query.
|
||||
assert_eq!(all[&a.id].len(), cat.deck_contents(a.id).unwrap().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_cover_is_set_cleared_and_copied_by_duplicate() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Covered", None).unwrap();
|
||||
assert_eq!(d.cover_oracle_id, None);
|
||||
|
||||
cat.set_deck_cover(d.id, Some("serra-angel")).unwrap();
|
||||
let got = cat.get_deck(d.id).unwrap().unwrap();
|
||||
assert_eq!(got.cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
let listed = cat.list_decks().unwrap();
|
||||
assert_eq!(listed[0].cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
|
||||
let copy = cat.duplicate_deck(d.id, "Covered (copy)").unwrap();
|
||||
assert_eq!(copy.cover_oracle_id.as_deref(), Some("serra-angel"));
|
||||
|
||||
cat.set_deck_cover(d.id, None).unwrap();
|
||||
assert_eq!(cat.get_deck(d.id).unwrap().unwrap().cover_oracle_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_deck_commanders_flags_each_and_keeps_the_first_as_primary() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -269,6 +269,9 @@ pub struct Deck {
|
||||
/// The commander card's oracle id (meaningful for Commander/Brawl formats).
|
||||
pub commander_oracle_id: Option<String>,
|
||||
pub notes: String,
|
||||
/// The card whose art headlines the deck in the deck list, if chosen;
|
||||
/// `None` leaves the pick to the UI.
|
||||
pub cover_oracle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Aggregate stats about the owned library, for a dashboard-style summary.
|
||||
|
||||
4
crates/mtgc-core/src/sql/library/0007_deck_cover.sql
Normal file
4
crates/mtgc-core/src/sql/library/0007_deck_cover.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- v7 — a deck's chosen cover card: whose art headlines it in the deck list.
|
||||
-- NULL means pick one automatically (the commander, else a standout card). An
|
||||
-- oracle id that's no longer in the deck falls back the same way.
|
||||
ALTER TABLE decks ADD COLUMN cover_oracle_id TEXT;
|
||||
@@ -56,7 +56,8 @@ pub mod remote;
|
||||
/// dropped printing data would publish its loss as a deletion on the next
|
||||
/// sync, so documents that carry any are marked 2 and older builds refuse
|
||||
/// them — while libraries that don't use printings stay readable everywhere.
|
||||
pub const DOC_VERSION: u32 = 2;
|
||||
/// Version 3 does the same for deck covers.
|
||||
pub const DOC_VERSION: u32 = 3;
|
||||
|
||||
/// The format of a document with no printing data in it.
|
||||
const BASE_VERSION: u32 = 1;
|
||||
@@ -108,6 +109,9 @@ pub struct DeckDoc {
|
||||
pub commander: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub notes: String,
|
||||
/// The oracle id of the card chosen to headline the deck in the deck list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cover: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards: BTreeMap<String, DeckCardEntry>,
|
||||
}
|
||||
@@ -165,9 +169,12 @@ impl LibraryDoc {
|
||||
self.cards.is_empty() && self.decks.is_empty()
|
||||
}
|
||||
|
||||
/// The lowest format that can hold this document: 2 once it carries any
|
||||
/// printing data, else 1. See [`DOC_VERSION`].
|
||||
/// The lowest format that can hold this document: 3 once any deck has a
|
||||
/// cover, 2 once it carries any printing data, else 1. See [`DOC_VERSION`].
|
||||
pub fn required_version(&self) -> u32 {
|
||||
if self.decks.values().any(|d| d.cover.is_some()) {
|
||||
return 3;
|
||||
}
|
||||
let has_copies = self.cards.values().any(|c| !c.printings.is_empty());
|
||||
let has_deck_printings = self
|
||||
.decks
|
||||
@@ -415,6 +422,9 @@ fn merge_deck(
|
||||
notes: merge_field(&b.notes, &o.notes, &t.notes, || {
|
||||
report.conflicts.push(format!("notes of deck \"{name}\""))
|
||||
}),
|
||||
cover: merge_field(&b.cover, &o.cover, &t.cover, || {
|
||||
report.conflicts.push(format!("cover of deck \"{name}\""))
|
||||
}),
|
||||
cards: BTreeMap::new(),
|
||||
};
|
||||
|
||||
@@ -483,7 +493,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?;
|
||||
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut by_id: BTreeMap<i64, String> = BTreeMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
@@ -497,6 +507,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
format: row.get(3)?,
|
||||
commander: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
cover: row.get(6)?,
|
||||
cards: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
@@ -585,14 +596,15 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
||||
|
||||
for (uuid, deck) in &doc.decks {
|
||||
tx.execute(
|
||||
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
format = excluded.format,
|
||||
commander_oracle_id = excluded.commander_oracle_id,
|
||||
notes = excluded.notes",
|
||||
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes],
|
||||
notes = excluded.notes,
|
||||
cover_oracle_id = excluded.cover_oracle_id",
|
||||
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes, deck.cover],
|
||||
)?;
|
||||
let deck_id: i64 =
|
||||
tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?;
|
||||
@@ -911,6 +923,43 @@ mod tests {
|
||||
assert_eq!(back, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_3_is_used_only_once_a_deck_has_a_cover() {
|
||||
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
||||
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into());
|
||||
assert_eq!(d.required_version(), 2);
|
||||
|
||||
d.decks.get_mut("u1").unwrap().cover = Some("a".into());
|
||||
assert_eq!(d.required_version(), 3);
|
||||
|
||||
let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default());
|
||||
assert_eq!(merged.version, 3);
|
||||
let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap();
|
||||
assert_eq!(back, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_chosen_on_one_side_survives_an_edit_on_the_other() {
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let mut ours = base.clone();
|
||||
let mut theirs = base.clone();
|
||||
ours.decks.get_mut("u1").unwrap().cover = Some("a".into());
|
||||
theirs.decks.get_mut("u1").unwrap().name = "Renamed".into();
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
let deck = &merged.decks["u1"];
|
||||
assert_eq!(deck.cover.as_deref(), Some("a"));
|
||||
assert_eq!(deck.name, "Renamed");
|
||||
assert!(report.conflicts.is_empty());
|
||||
|
||||
// Both choosing different covers is a conflict that takes theirs.
|
||||
let mut theirs = base.clone();
|
||||
theirs.decks.get_mut("u1").unwrap().cover = Some("b".into());
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
assert_eq!(merged.decks["u1"].cover.as_deref(), Some("b"));
|
||||
assert_eq!(report.conflicts, vec!["cover of deck \"Fub\"".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_1_document_carries_no_printing_fields() {
|
||||
// Older builds must see exactly what they always saw.
|
||||
@@ -938,10 +987,12 @@ mod tests {
|
||||
c.set_deck_format(deck.id, "Commander").unwrap();
|
||||
c.set_deck_card(deck.id, "card-a", 2, false, false).unwrap();
|
||||
c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap();
|
||||
c.set_deck_cover(deck.id, Some("card-a")).unwrap();
|
||||
c.set_owned("card-a", 4).unwrap();
|
||||
c.set_location("card-a", "Box 1").unwrap();
|
||||
|
||||
let before = read_doc(&c.conn).unwrap();
|
||||
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
|
||||
// Round-trip through JSON too, so the serde attributes are exercised.
|
||||
let text = before.to_json().unwrap();
|
||||
let parsed = LibraryDoc::from_json(&text).unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! JS passes camelCase argument keys; Tauri maps them to these snake_case params.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -232,6 +233,13 @@ pub fn deck_contents(db: Db, deck_id: i64) -> Res<Vec<DeckEntry>> {
|
||||
s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Every deck's contents keyed by deck id, for the deck list's summaries.
|
||||
#[tauri::command]
|
||||
pub fn all_deck_contents(db: Db) -> Res<HashMap<i64, Vec<DeckEntry>>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.all_deck_contents().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn suggest_cards(
|
||||
db: Db,
|
||||
@@ -321,6 +329,14 @@ pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_deck_cover(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_deck_cover(deck_id, oracle_id.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
|
||||
@@ -107,11 +107,13 @@ pub fn run() {
|
||||
commands::rename_deck,
|
||||
commands::duplicate_deck,
|
||||
commands::set_deck_format,
|
||||
commands::set_deck_cover,
|
||||
commands::set_deck_commander,
|
||||
commands::set_deck_commanders,
|
||||
commands::search_partners,
|
||||
commands::set_deck_card,
|
||||
commands::deck_contents,
|
||||
commands::all_deck_contents,
|
||||
commands::suggest_cards,
|
||||
commands::card_decks,
|
||||
commands::list_printings,
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface PreviewContext {
|
||||
onChanged: () => void;
|
||||
/** The deck the previewed card is a slot of. */
|
||||
deckId?: number;
|
||||
/** That deck's chosen cover card, so the preview can offer to change it. */
|
||||
coverOracleId?: string | null;
|
||||
}
|
||||
|
||||
export interface Deck {
|
||||
@@ -103,6 +105,8 @@ export interface Deck {
|
||||
format: string;
|
||||
commander_oracle_id: string | null;
|
||||
notes: string;
|
||||
/** The card chosen to headline the deck in the deck list; null = automatic. */
|
||||
cover_oracle_id: string | null;
|
||||
}
|
||||
|
||||
export interface DeckImportResult {
|
||||
@@ -493,6 +497,9 @@ export const api = {
|
||||
invoke<Deck>("duplicate_deck", { deckId, newName }),
|
||||
setDeckFormat: (deckId: number, format: string) =>
|
||||
invoke<void>("set_deck_format", { deckId, format }),
|
||||
/** Choose the card headlining the deck in the deck list; null = automatic. */
|
||||
setDeckCover: (deckId: number, oracleId: string | null) =>
|
||||
invoke<void>("set_deck_cover", { deckId, oracleId }),
|
||||
setDeckCommander: (deckId: number, oracleId: string | null) =>
|
||||
invoke<void>("set_deck_commander", { deckId, oracleId }),
|
||||
/** One commander, or a partner pair; the first is the deck's primary. */
|
||||
@@ -512,6 +519,8 @@ export const api = {
|
||||
shareCardImage: (url: string, name: string) =>
|
||||
invoke<"copied" | "shared">("share_card_image", { url, name }),
|
||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||
/** Every deck's contents by deck id; decks with no cards are absent. */
|
||||
allDeckContents: () => invoke<Record<number, DeckEntry[]>>("all_deck_contents"),
|
||||
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
|
||||
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
|
||||
@@ -107,6 +107,32 @@ const ICON_SHARE = icon(
|
||||
</>,
|
||||
);
|
||||
const ICON_CHECK = icon(<polyline points="20 6 9 17 4 12" />);
|
||||
const ICON_ARROW_LEFT = icon(
|
||||
<>
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</>,
|
||||
);
|
||||
const ICON_PLUS = icon(
|
||||
<>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</>,
|
||||
);
|
||||
const ICON_MORE = icon(
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</>,
|
||||
);
|
||||
const ICON_IMAGE = icon(
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</>,
|
||||
);
|
||||
// A box (as on the "Not owned" filter) with a plus: add to the library.
|
||||
const ICON_LIBRARY_ADD = icon(
|
||||
<>
|
||||
@@ -167,8 +193,19 @@ export const Icons = {
|
||||
libraryAdd: ICON_LIBRARY_ADD,
|
||||
share: ICON_SHARE,
|
||||
check: ICON_CHECK,
|
||||
arrowLeft: ICON_ARROW_LEFT,
|
||||
plus: ICON_PLUS,
|
||||
more: ICON_MORE,
|
||||
image: ICON_IMAGE,
|
||||
};
|
||||
|
||||
/** Scryfall serves every crop size at the same path under a different
|
||||
* segment, so the stored "normal" card image gives us the art alone. */
|
||||
export function artCrop(imageUri: string): string | null {
|
||||
if (!imageUri) return null;
|
||||
return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/");
|
||||
}
|
||||
|
||||
// --- Dialogs ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1042,6 +1079,8 @@ function PrintingsSection({
|
||||
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
|
||||
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Tracked here: `ctx` is a snapshot from when the preview opened.
|
||||
const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -1092,6 +1131,12 @@ function PrintingsSection({
|
||||
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
|
||||
});
|
||||
|
||||
const setCover = (cover: boolean) =>
|
||||
run(async () => {
|
||||
await api.setDeckCover(ctx.deckId!, cover ? card.oracle_id : null);
|
||||
setIsCover(cover);
|
||||
});
|
||||
|
||||
function onPick(p: PrintingRef, finish: string) {
|
||||
const mode = picking;
|
||||
setPicking(null);
|
||||
@@ -1120,6 +1165,24 @@ function PrintingsSection({
|
||||
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="printing-row">
|
||||
<span className="printing-row-label">
|
||||
{isCover ? "This card's art is the deck's cover" : "Deck cover"}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<span className="printing-actions">
|
||||
{isCover ? (
|
||||
<button onClick={() => setCover(false)} title="Let the deck list pick the cover again">
|
||||
Automatic
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setCover(true)} title="Headline the deck with this card's art in the deck list">
|
||||
{Icons.image}
|
||||
Use as cover
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="preview-info-section">
|
||||
@@ -1821,11 +1884,13 @@ export type ViewMode = "list" | "tiles";
|
||||
* multi-column table forces horizontal scrolling on a phone, so narrow
|
||||
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
|
||||
* This only picks the *default* — once a mode is explicitly chosen it's
|
||||
* remembered and used everywhere, same as before. */
|
||||
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
|
||||
* remembered and used everywhere, same as before. A view whose list mode
|
||||
* reflows fine on a phone can pass its own `fallback` instead. */
|
||||
export function useViewMode(key: string, fallback?: ViewMode): [ViewMode, (m: ViewMode) => void] {
|
||||
const [mode, setMode] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem(key) as ViewMode | null;
|
||||
if (saved) return saved;
|
||||
if (fallback) return fallback;
|
||||
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list";
|
||||
});
|
||||
const set = useCallback(
|
||||
|
||||
378
src/deckAnalysis.ts
Normal file
378
src/deckAnalysis.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
// Pure deck analysis shared by the deck builder and the deck list: composition
|
||||
// stats, format legality, commander pairing, and the deck list's per-deck
|
||||
// summary.
|
||||
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api";
|
||||
|
||||
/** Copies of a deck card you'd need to acquire to cover the deck. */
|
||||
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
|
||||
|
||||
export interface DeckStats {
|
||||
totalCards: number;
|
||||
uniqueCards: number;
|
||||
landCount: number;
|
||||
avgManaValue: number;
|
||||
/** USD for every copy in the deck, commander included; cards without a
|
||||
* known price count as $0. */
|
||||
totalCost: number;
|
||||
curve: {
|
||||
label: string;
|
||||
count: number;
|
||||
colorCounts: [number, number, number, number, number];
|
||||
colorlessCount: number;
|
||||
}[];
|
||||
colorCounts: [number, number, number, number, number];
|
||||
colorlessCount: number;
|
||||
manaProduction: [number, number, number, number, number];
|
||||
colorlessProduction: number;
|
||||
manaConsumption: [number, number, number, number, number];
|
||||
colorlessConsumption: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck composition stats, excluding the commander (shown separately in its
|
||||
* own hero display) and the maybeboard (cards under consideration, not
|
||||
* actually part of the deck) — matches the usual "99 + commander" framing
|
||||
* even for non-Commander decks, where it's just "everything but those".
|
||||
*/
|
||||
export function computeDeckStats(contents: DeckEntry[]): DeckStats {
|
||||
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
|
||||
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const lands = cards.filter((e) => e.type_line.includes("Land"));
|
||||
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
|
||||
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const avgManaValue = nonlandCount
|
||||
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
|
||||
: 0;
|
||||
|
||||
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
|
||||
const curveBuckets = new Map(
|
||||
bucketLabels.map((l) => [
|
||||
l,
|
||||
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
|
||||
]),
|
||||
);
|
||||
for (const e of nonlands) {
|
||||
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
|
||||
const bucket = curveBuckets.get(label)!;
|
||||
bucket.count += e.quantity;
|
||||
if (e.color_identity === 0) {
|
||||
bucket.colorlessCount += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
|
||||
|
||||
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessCount = 0;
|
||||
for (const e of cards) {
|
||||
if (e.color_identity === 0) {
|
||||
colorlessCount += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessProduction = 0;
|
||||
for (const e of cards) {
|
||||
if (e.produces_colorless) colorlessProduction += e.quantity;
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
// Consumption: colored cost requirements of the spells actually cast —
|
||||
// lands are excluded since they have no colored casting cost.
|
||||
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessConsumption = 0;
|
||||
for (const e of nonlands) {
|
||||
if (e.colors === 0) {
|
||||
colorlessConsumption += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike the rest, cost counts the commander: it's a card you have to buy.
|
||||
const totalCost = contents
|
||||
.filter((e) => !e.is_maybeboard)
|
||||
.reduce((sum, e) => sum + e.price_usd * e.quantity, 0);
|
||||
|
||||
return {
|
||||
totalCards,
|
||||
uniqueCards: cards.length,
|
||||
landCount,
|
||||
avgManaValue,
|
||||
totalCost,
|
||||
curve,
|
||||
colorCounts,
|
||||
colorlessCount,
|
||||
manaProduction,
|
||||
colorlessProduction,
|
||||
manaConsumption,
|
||||
colorlessConsumption,
|
||||
};
|
||||
}
|
||||
|
||||
/** "$84.50", or whole dollars once it's big enough that cents are noise. */
|
||||
export function formatCost(usd: number): string {
|
||||
return `$${usd.toLocaleString(undefined, {
|
||||
minimumFractionDigits: usd >= 100 ? 0 : 2,
|
||||
maximumFractionDigits: usd >= 100 ? 0 : 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
|
||||
* crates/mtgc-core/src/partner.rs — keep the two in step. */
|
||||
export function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
|
||||
const p = a.partner;
|
||||
const q = b.partner;
|
||||
if (!p || !q) return false;
|
||||
switch (p.kind) {
|
||||
case "partner":
|
||||
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
|
||||
case "variant":
|
||||
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
|
||||
case "with":
|
||||
return q.kind === "with" && p.name === b.name && q.name === a.name;
|
||||
case "chooses_background":
|
||||
return q.kind === "background";
|
||||
case "background":
|
||||
return q.kind === "chooses_background";
|
||||
case "doctors_companion":
|
||||
return q.kind === "doctor";
|
||||
case "doctor":
|
||||
return q.kind === "doctors_companion";
|
||||
}
|
||||
}
|
||||
|
||||
/** What the second-commander search box asks for, given the first's ability. */
|
||||
export function partnerPlaceholder(partner: Partner): string {
|
||||
switch (partner.kind) {
|
||||
case "chooses_background":
|
||||
return "Search to add a Background…";
|
||||
case "background":
|
||||
return "Search to add a commander that chooses a Background…";
|
||||
case "doctors_companion":
|
||||
return "Search to add a Doctor…";
|
||||
case "doctor":
|
||||
return "Search to add a Doctor's companion…";
|
||||
case "with":
|
||||
return `Search to add ${partner.name}…`;
|
||||
case "variant":
|
||||
return `Search to add a partner (${partner.name})…`;
|
||||
case "partner":
|
||||
return "Search to add a partner…";
|
||||
}
|
||||
}
|
||||
|
||||
export const FORMATS = [
|
||||
"",
|
||||
"Commander",
|
||||
"Brawl",
|
||||
"Standard",
|
||||
"Pioneer",
|
||||
"Modern",
|
||||
"Legacy",
|
||||
"Vintage",
|
||||
"Pauper",
|
||||
"Other",
|
||||
];
|
||||
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
||||
|
||||
export interface DeckLegality {
|
||||
status: "legal" | "illegal" | "unchecked";
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export const COMMANDER_DECK_SIZE = 100;
|
||||
export const CONSTRUCTED_MIN_DECK_SIZE = 60;
|
||||
export const CONSTRUCTED_MAX_COPIES = 4;
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
export 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).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Two commanders are allowed when their partner abilities pair them up.
|
||||
const commanders = cards.filter((e) => e.is_commander);
|
||||
if (commanders.length === 0) {
|
||||
reasons.push(`No commander is set.`);
|
||||
} else if (commanders.length > 2) {
|
||||
reasons.push(`More than two cards are marked as commanders.`);
|
||||
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
|
||||
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
|
||||
}
|
||||
|
||||
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 (commanders.length > 0) {
|
||||
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
|
||||
const whose = commanders.length > 1 ? "commanders'" : "commander's";
|
||||
for (const e of cards) {
|
||||
if (e.is_commander) continue;
|
||||
const outsideIdentity = e.color_identity & ~identity;
|
||||
if (outsideIdentity !== 0) {
|
||||
reasons.push(`${e.name}'s color identity is outside the ${whose} 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 };
|
||||
}
|
||||
|
||||
|
||||
/** What the deck list shows for one deck. */
|
||||
export interface DeckSummary {
|
||||
/** The card whose art headlines the deck, or null for an empty deck. */
|
||||
cover: DeckEntry | null;
|
||||
/** The primary commander first, then its partner. */
|
||||
commanders: DeckEntry[];
|
||||
/** WUBRG mask: the commanders' identity, else the whole mainboard's. */
|
||||
colors: number;
|
||||
/** Mainboard copies, commander included. */
|
||||
size: number;
|
||||
/** The size the format calls for (a minimum for 60-card formats), or null
|
||||
* when there's no format to measure against. */
|
||||
target: number | null;
|
||||
/** Fraction of the mainboard's copies you own, 0–1 (1 for an empty deck). */
|
||||
owned: number;
|
||||
cost: number;
|
||||
legality: DeckLegality;
|
||||
maybeboardCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card whose art headlines a deck: the chosen cover while it's still on
|
||||
* the mainboard, then the primary commander, then the priciest nonland (the
|
||||
* card most likely to be the deck's centrepiece), then any card at all.
|
||||
*/
|
||||
export function pickCover(deck: Deck, contents: DeckEntry[]): DeckEntry | null {
|
||||
const main = contents.filter((e) => !e.is_maybeboard);
|
||||
const byId = (id: string | null) => (id ? main.find((e) => e.oracle_id === id) : undefined);
|
||||
const priciest = (cards: DeckEntry[]) =>
|
||||
cards.reduce<DeckEntry | null>((best, e) => (!best || e.price_usd > best.price_usd ? e : best), null);
|
||||
return (
|
||||
byId(deck.cover_oracle_id) ??
|
||||
byId(deck.commander_oracle_id) ??
|
||||
main.find((e) => e.is_commander) ??
|
||||
priciest(main.filter((e) => !e.type_line.includes("Land"))) ??
|
||||
priciest(main) ??
|
||||
priciest(contents)
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
|
||||
const main = contents.filter((e) => !e.is_maybeboard);
|
||||
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck.commander_oracle_id);
|
||||
const commanders = main.filter((e) => e.is_commander).sort((a, b) => isPrimary(b) - isPrimary(a));
|
||||
const colorSource = commanders.length ? commanders : main;
|
||||
const size = main.reduce((n, e) => n + e.quantity, 0);
|
||||
const ownedCopies = main.reduce((n, e) => n + Math.min(e.quantity, e.quantity_owned), 0);
|
||||
const target = COMMANDER_FORMATS.includes(deck.format)
|
||||
? COMMANDER_DECK_SIZE
|
||||
: deck.format && deck.format !== "Other"
|
||||
? CONSTRUCTED_MIN_DECK_SIZE
|
||||
: null;
|
||||
return {
|
||||
cover: pickCover(deck, contents),
|
||||
commanders,
|
||||
colors: colorSource.reduce((mask, e) => mask | e.color_identity, 0),
|
||||
size,
|
||||
target,
|
||||
owned: size ? ownedCopies / size : 1,
|
||||
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
|
||||
legality: computeDeckLegality(deck, contents),
|
||||
maybeboardCount: contents.length - main.length,
|
||||
};
|
||||
}
|
||||
513
src/styles.css
513
src/styles.css
@@ -1224,20 +1224,6 @@ h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Row-level action icons (e.g. rename/duplicate/delete a deck): hidden until
|
||||
* the row is hovered/focused, so the collapsed list stays uncluttered. */
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
li:hover .row-actions,
|
||||
li:focus-within .row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dialogs: confirm (delete) / prompt (rename, duplicate-as)
|
||||
============================================================ */
|
||||
@@ -1312,7 +1298,8 @@ li:focus-within .row-actions {
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dialog-field input {
|
||||
.dialog-field input,
|
||||
.dialog-field select {
|
||||
width: 100%;
|
||||
}
|
||||
.dialog-error {
|
||||
@@ -1359,26 +1346,6 @@ li:focus-within .row-actions {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Deck list: name+format on one row, actions on their own row below, so
|
||||
* long names don't push the action icons off the edge of the sidebar. */
|
||||
.result-list.deck-list li {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
.deck-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-list-row .card-name {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.deck-list-actions-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
|
||||
* synergy reason chips per row, with stacked actions on the right. Denser
|
||||
* than the default .result-list row (smaller padding/gaps, chips instead of
|
||||
@@ -2613,25 +2580,412 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck metadata (format + commander)
|
||||
Deck list (the Decks tab's front page)
|
||||
============================================================ */
|
||||
|
||||
/* Two-pane deck screen: deck list + selected-deck detail. Stacks below the
|
||||
tablet breakpoint (see the responsive section) so the list sits above the
|
||||
detail on narrow/portrait viewports. */
|
||||
.deck-layout {
|
||||
.deck-gallery-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.deck-sidebar {
|
||||
width: 240px;
|
||||
.deck-gallery-toolbar h2 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-gallery-search {
|
||||
width: 260px;
|
||||
}
|
||||
.deck-gallery-controls,
|
||||
.deck-gallery-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.deck-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-5);
|
||||
}
|
||||
.deck-gallery-empty h3 {
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.deck-gallery-empty .hint {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.deck-gallery-empty-icon svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-quiet);
|
||||
}
|
||||
|
||||
/* Tiles: an art banner with the deck's name on it, stats below. */
|
||||
.deck-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.deck-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--panel-highlight);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.deck-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-glow);
|
||||
box-shadow: var(--panel-highlight), var(--shadow-md);
|
||||
}
|
||||
.deck-tile:focus-visible,
|
||||
.deck-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The hover lift makes each tile its own stacking context, so the one whose
|
||||
menu is open has to sit above its neighbours or they'd cover the menu. */
|
||||
.deck-tile.menu-open,
|
||||
.deck-row.menu-open {
|
||||
z-index: 5;
|
||||
}
|
||||
.deck-tile-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
.deck-tile-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.deck-tile-art img,
|
||||
.deck-row-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.deck-tile-art img {
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.deck-tile:hover .deck-tile-art img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.deck-tile-art,
|
||||
.deck-row-art {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: repeating-linear-gradient(-45deg, var(--bg-input) 0 10px, var(--bg-hover) 10px 20px);
|
||||
}
|
||||
.deck-art-empty {
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--faint);
|
||||
}
|
||||
/* Darkens the top (for the badge + menu) and, more, the bottom (for the
|
||||
name), so both stay legible over any art. */
|
||||
.deck-tile-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(12, 10, 8, 0.45) 0%,
|
||||
rgba(12, 10, 8, 0) 28%,
|
||||
rgba(12, 10, 8, 0) 45%,
|
||||
rgba(12, 10, 8, 0.92) 100%
|
||||
);
|
||||
}
|
||||
.deck-tile-heading {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-2);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-tile-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.deck-tile-heading .pips {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.deck-tile-cover .deck-legality {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
}
|
||||
.deck-tile-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-3);
|
||||
}
|
||||
.deck-tile-sub {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-tile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-caption);
|
||||
min-height: 22px;
|
||||
}
|
||||
.deck-tile-meta .deck-chip,
|
||||
.deck-row-stats .deck-chip {
|
||||
margin-right: 0;
|
||||
}
|
||||
.deck-tile-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
.deck-tile-foot span:last-child,
|
||||
.deck-row-cost {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.deck-size {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-size-target {
|
||||
color: var(--faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
.deck-size.done {
|
||||
color: var(--success);
|
||||
}
|
||||
.deck-size.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.deck-owned-bar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.deck-owned-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.deck-legality {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-tile .deck-legality {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(12, 10, 8, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.deck-legality.legal {
|
||||
color: var(--success);
|
||||
}
|
||||
.deck-legality.illegal {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* The ⋯ menu: over the tile's top-right corner, or at a row's end. */
|
||||
.deck-menu {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-detail-pane {
|
||||
.deck-tile > .deck-menu {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
}
|
||||
.deck-menu-list {
|
||||
left: auto;
|
||||
right: 0;
|
||||
min-width: 170px;
|
||||
}
|
||||
.deck-menu-list > button.danger-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
/* Where there's a hover, the tile's menu waits for it; on touch it's always
|
||||
showing, since there's nothing to reveal it. */
|
||||
@media (hover: hover) {
|
||||
.deck-tile > .deck-menu {
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.deck-tile:hover > .deck-menu,
|
||||
.deck-tile:focus-within > .deck-menu,
|
||||
.deck-tile.menu-open > .deck-menu {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rows: the compact alternative, more decks per screen. */
|
||||
.deck-gallery-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.deck-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-2);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--panel-highlight);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.deck-row:hover {
|
||||
border-color: var(--accent-glow);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.deck-row-art {
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.deck-row-art .deck-art-empty {
|
||||
font-size: 22px;
|
||||
}
|
||||
.deck-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.deck-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-row-name .card-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-row-name .pips {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.deck-row-sub {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.deck-row-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.deck-row-owned {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-row-owned .deck-owned-bar {
|
||||
width: 56px;
|
||||
}
|
||||
.deck-row-owned > span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
/* Fixed widths, so each stat lines up in a column down the list. */
|
||||
.deck-row-stats .deck-size {
|
||||
min-width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
.deck-row-owned > span:last-child {
|
||||
min-width: 4ch;
|
||||
}
|
||||
.deck-row-cost {
|
||||
min-width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
.deck-row .deck-legality {
|
||||
min-width: 1em;
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
/* The builder's way back to the deck list. */
|
||||
.deck-builder-back {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.deck-builder-back:hover {
|
||||
color: var(--text-strong);
|
||||
background: none;
|
||||
}
|
||||
.deck-builder-title {
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-builder-title h2 {
|
||||
margin-right: var(--space-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck metadata (format + commander)
|
||||
============================================================ */
|
||||
|
||||
/* Surface from `.panel`; layout only here. */
|
||||
.deck-meta {
|
||||
@@ -2809,27 +3163,9 @@ tbody tr:hover {
|
||||
Responsive groundwork
|
||||
Desktop (the primary target) is unchanged above; these breakpoints keep
|
||||
the layout usable as the window narrows and lay the foundation for a
|
||||
future mobile port. Breakpoints: 1000 (tablet) and 720 (compact).
|
||||
future mobile port. Breakpoint: 720 (compact).
|
||||
============================================================ */
|
||||
|
||||
/* Tablet: the deck two-pane stacks (list above detail). */
|
||||
@media (max-width: 1000px) {
|
||||
.deck-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.deck-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Back button into the deck list — only meaningful once the list and the
|
||||
deck-building pane become separate screens below, so it's hidden outside
|
||||
that breakpoint (and on the tablet stacked layout, where both panes are
|
||||
still visible together). */
|
||||
.deck-back-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact: tighten page gutters and collapse the nav rail to an icon strip
|
||||
the left rail's job moves to .bottom-nav below instead, the customary
|
||||
placement on Android). */
|
||||
@@ -2871,6 +3207,41 @@ tbody tr:hover {
|
||||
.deck-stats-curve {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Deck list toolbar: title and create buttons on top, then search, then
|
||||
the filter/sort/view controls sharing a line. */
|
||||
.deck-gallery-toolbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
.deck-gallery-toolbar h2 {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-gallery-create {
|
||||
order: 1;
|
||||
}
|
||||
.deck-gallery-search {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.deck-gallery-controls {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.deck-gallery-controls select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Rows put their stats on a second line, under the name. */
|
||||
.deck-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-1);
|
||||
}
|
||||
.deck-row-stats {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding-left: calc(72px + var(--space-3));
|
||||
}
|
||||
/* `.deck-meta` puts the Format selector and the commander search box side
|
||||
by side as flex-wrap row items — with `.commander-box`'s min-width now
|
||||
0, it no longer wraps onto its own line, it just gets squeezed into
|
||||
@@ -2880,20 +3251,6 @@ tbody tr:hover {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* Deck list and deck-building pane become separate screens (only one
|
||||
visible at a time) instead of stacking above one another — with many
|
||||
decks, scrolling past the whole list to reach the builder doesn't
|
||||
scale. `.deck-layout`'s show-list/show-detail class is toggled by
|
||||
DeckView in response to picking a deck / the back button. */
|
||||
.deck-back-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
.deck-layout.show-list .deck-detail-pane {
|
||||
display: none;
|
||||
}
|
||||
.deck-layout.show-detail .deck-sidebar {
|
||||
display: none;
|
||||
}
|
||||
/* The Suggested Cards dialog: `.suggestion-actions`' two nowrap buttons
|
||||
are flex-shrink:0, so they claimed their full fixed width no matter how
|
||||
little room was left, crushing `.suggestion-main` down to almost
|
||||
|
||||
426
src/views/DeckList.tsx
Normal file
426
src/views/DeckList.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, Deck, DeckEntry } from "../api";
|
||||
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
|
||||
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
|
||||
|
||||
type SortMode = "recent" | "name" | "format" | "size" | "cost" | "owned";
|
||||
|
||||
const SORTS: { id: SortMode; label: string }[] = [
|
||||
{ id: "recent", label: "Recently opened" },
|
||||
{ id: "name", label: "Name" },
|
||||
{ id: "format", label: "Format" },
|
||||
{ id: "size", label: "Size" },
|
||||
{ id: "cost", label: "Cost" },
|
||||
{ id: "owned", label: "Owned %" },
|
||||
];
|
||||
|
||||
interface DeckActions {
|
||||
onOpen: (id: number) => void;
|
||||
onRename: (deck: Deck) => void;
|
||||
onDuplicate: (deck: Deck) => void;
|
||||
onDelete: (deck: Deck) => void;
|
||||
onExport: (deck: Deck) => void;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
deck: Deck;
|
||||
summary: DeckSummary;
|
||||
}
|
||||
|
||||
/** "Muldrotha, the Gravetide" / "Tymna & Thrasios" / "Featuring Sol Ring" —
|
||||
* or, for a deck named after its commander, the commander's type line rather
|
||||
* than its name again. */
|
||||
function subtitle({ deck, summary }: Row): string {
|
||||
if (summary.commanders.length) {
|
||||
const names = summary.commanders.map((c) => c.name).join(" & ");
|
||||
if (fold(names) !== fold(deck.name.trim())) return names;
|
||||
return summary.commanders[0].type_line;
|
||||
}
|
||||
if (summary.cover) return `Featuring ${summary.cover.name}`;
|
||||
return "No cards yet";
|
||||
}
|
||||
|
||||
/** Floored, so a deck one card short never reads as "100% owned". */
|
||||
function ownedLabel(s: DeckSummary): string {
|
||||
if (s.size === 0) return "—";
|
||||
return s.owned === 1 ? "All owned" : `${Math.floor(s.owned * 100)}% owned`;
|
||||
}
|
||||
|
||||
/** The deck's size against its format: "87/100" until it's there, then just
|
||||
* the count — a 60-card minimum isn't a target to show as "62/60". */
|
||||
function DeckSize({ summary: s }: { summary: DeckSummary }) {
|
||||
const exact = s.target === 100;
|
||||
const done = s.target !== null && (exact ? s.size === s.target : s.size >= s.target);
|
||||
const over = exact && s.size > s.target!;
|
||||
const cls = over ? "warn" : done ? "done" : "";
|
||||
if (s.target === null || (done && !exact)) {
|
||||
return (
|
||||
<span className={`deck-size ${cls}`}>
|
||||
{s.size} card{s.size === 1 ? "" : "s"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`deck-size ${cls}`} title={`${s.size} of ${s.target} cards`}>
|
||||
{s.size}
|
||||
<span className="deck-size-target">/{s.target}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?: boolean }) {
|
||||
// An empty deck is "illegal" only in the sense that it isn't built yet.
|
||||
if (s.legality.status === "unchecked" || s.size === 0) return null;
|
||||
const legal = s.legality.status === "legal";
|
||||
const n = s.legality.reasons.length;
|
||||
const title = legal ? "Meets the format's deck-building rules" : s.legality.reasons.join("\n");
|
||||
return (
|
||||
<span className={`deck-legality ${legal ? "legal" : "illegal"}`} title={title}>
|
||||
{legal ? "✓" : "⚠"}
|
||||
{!compact && <span>{legal ? "Legal" : `${n} issue${n === 1 ? "" : "s"}`}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnedBar({ summary: s }: { summary: DeckSummary }) {
|
||||
return (
|
||||
<span className="deck-owned-bar" aria-hidden="true">
|
||||
<span style={{ width: `${s.size ? s.owned * 100 : 0}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The cover card's art, or the deck's initial when there's none to show. */
|
||||
function DeckArt({ row, className }: { row: Row; className: string }) {
|
||||
const src = row.summary.cover ? artCrop(row.summary.cover.image_uri) : null;
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [src]);
|
||||
return (
|
||||
<div className={className}>
|
||||
{src && !failed ? (
|
||||
<img src={src} alt="" loading="lazy" draggable={false} onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<span className="deck-art-empty">{row.deck.name.trim().charAt(0).toUpperCase() || "?"}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "⋯" menu of whole-deck actions. Its clicks never reach the tile/row
|
||||
* underneath, which opens the deck. */
|
||||
function DeckMenu({
|
||||
deck,
|
||||
overlay,
|
||||
actions,
|
||||
onOpenChange,
|
||||
}: {
|
||||
deck: Deck;
|
||||
overlay?: boolean;
|
||||
actions: DeckActions;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) toggle(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && toggle(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const item = (label: string, run: () => void, danger?: boolean) => (
|
||||
<button
|
||||
role="menuitem"
|
||||
className={danger ? "danger-text" : undefined}
|
||||
onClick={() => {
|
||||
toggle(false);
|
||||
run();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="deck-menu"
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton icon={Icons.more} label="Deck actions" overlay={overlay} onClick={() => toggle(!open)} />
|
||||
{open && (
|
||||
<div className="library-menu deck-menu-list" role="menu">
|
||||
{item("Open", () => actions.onOpen(deck.id))}
|
||||
{item("Rename…", () => actions.onRename(deck))}
|
||||
{item("Duplicate…", () => actions.onDuplicate(deck))}
|
||||
{item("Export…", () => actions.onExport(deck))}
|
||||
<hr />
|
||||
{item("Delete…", () => actions.onDelete(deck), true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Click or Enter/Space opens the deck. */
|
||||
function openProps(open: () => void) {
|
||||
return {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: open,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
|
||||
const { deck, summary: s } = row;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
return (
|
||||
<div className={`deck-tile ${menuOpen ? "menu-open" : ""}`} {...openProps(() => actions.onOpen(deck.id))}>
|
||||
<div className="deck-tile-cover">
|
||||
<DeckArt row={row} className="deck-tile-art" />
|
||||
<div className="deck-tile-scrim" />
|
||||
<LegalityBadge summary={s} />
|
||||
<div className="deck-tile-heading">
|
||||
<span className="deck-tile-name">{deck.name}</span>
|
||||
{s.size > 0 && <ColorPips mask={s.colors} />}
|
||||
</div>
|
||||
</div>
|
||||
{/* Outside the cover, whose overflow clipping would cut the menu off. */}
|
||||
<DeckMenu deck={deck} overlay actions={actions} onOpenChange={setMenuOpen} />
|
||||
<div className="deck-tile-body">
|
||||
<div className="deck-tile-sub">{subtitle(row)}</div>
|
||||
<div className="deck-tile-meta">
|
||||
{deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>}
|
||||
{s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>}
|
||||
<span className="spacer" />
|
||||
<DeckSize summary={s} />
|
||||
</div>
|
||||
<OwnedBar summary={s} />
|
||||
<div className="deck-tile-foot">
|
||||
<span>{ownedLabel(s)}</span>
|
||||
<span>{formatCost(s.cost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
|
||||
const { deck, summary: s } = row;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
return (
|
||||
<div className={`deck-row ${menuOpen ? "menu-open" : ""}`} {...openProps(() => actions.onOpen(deck.id))}>
|
||||
<DeckArt row={row} className="deck-row-art" />
|
||||
<div className="deck-row-main">
|
||||
<div className="deck-row-name">
|
||||
<span className="card-name">{deck.name}</span>
|
||||
{s.size > 0 && <ColorPips mask={s.colors} />}
|
||||
</div>
|
||||
<div className="deck-row-sub">{subtitle(row)}</div>
|
||||
</div>
|
||||
<div className="deck-row-stats">
|
||||
{deck.format && <span className="deck-chip">{deck.format}</span>}
|
||||
<DeckSize summary={s} />
|
||||
<span className="deck-row-owned" title={ownedLabel(s)}>
|
||||
<OwnedBar summary={s} />
|
||||
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
|
||||
</span>
|
||||
<span className="deck-row-cost">{formatCost(s.cost)}</span>
|
||||
<LegalityBadge summary={s} compact />
|
||||
</div>
|
||||
<DeckMenu deck={deck} actions={actions} onOpenChange={setMenuOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Decks tab's front page: every deck as an art-forward tile (or a compact
|
||||
* row), with its colors, size against its format, how much of it you own,
|
||||
* what it costs, and whether it's legal. Picking one opens its builder.
|
||||
*/
|
||||
export function DeckList({
|
||||
decks,
|
||||
refreshSignal,
|
||||
openedAt,
|
||||
onNew,
|
||||
onImport,
|
||||
...actions
|
||||
}: {
|
||||
decks: Deck[];
|
||||
refreshSignal?: number;
|
||||
/** When each deck was last opened (ms), for the "Recently opened" sort. */
|
||||
openedAt: Record<number, number>;
|
||||
onNew: () => void;
|
||||
onImport: () => void;
|
||||
} & DeckActions) {
|
||||
const [contents, setContents] = useState<Record<number, DeckEntry[]> | null>(null);
|
||||
const [view, setView] = useViewMode("deck-list-view", "tiles");
|
||||
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
|
||||
const [query, setQuery] = useState("");
|
||||
const [format, setFormat] = useState<string | null>(null);
|
||||
|
||||
// `decks` is refetched after anything that adds a deck, so it doubles as
|
||||
// the signal that contents may have changed.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api
|
||||
.allDeckContents()
|
||||
.then((c) => live && setContents(c))
|
||||
.catch(() => live && setContents({}));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [decks, refreshSignal]);
|
||||
|
||||
const rows: Row[] = useMemo(
|
||||
() => decks.map((deck) => ({ deck, summary: summarizeDeck(deck, contents?.[deck.id] ?? []) })),
|
||||
[decks, contents],
|
||||
);
|
||||
|
||||
const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = fold(query.trim());
|
||||
const matches = (r: Row) =>
|
||||
!q ||
|
||||
fold(r.deck.name).includes(q) ||
|
||||
fold(r.deck.format).includes(q) ||
|
||||
r.summary.commanders.some((c) => fold(c.name).includes(q));
|
||||
const byName = (a: Row, b: Row) => a.deck.name.localeCompare(b.deck.name, undefined, { sensitivity: "base" });
|
||||
const desc = (f: (r: Row) => number) => (a: Row, b: Row) => f(b) - f(a) || byName(a, b);
|
||||
const compare: Record<SortMode, (a: Row, b: Row) => number> = {
|
||||
recent: desc((r) => openedAt[r.deck.id] ?? 0),
|
||||
name: byName,
|
||||
// Decks with no format sink to the bottom rather than leading with "".
|
||||
format: (a, b) =>
|
||||
Number(!a.deck.format) - Number(!b.deck.format) ||
|
||||
a.deck.format.localeCompare(b.deck.format) ||
|
||||
byName(a, b),
|
||||
size: desc((r) => r.summary.size),
|
||||
cost: desc((r) => r.summary.cost),
|
||||
owned: desc((r) => (r.summary.size ? r.summary.owned : -1)),
|
||||
};
|
||||
return rows
|
||||
.filter((r) => (format === null || r.deck.format === format) && matches(r))
|
||||
.sort(compare[sort] ?? byName);
|
||||
}, [rows, query, format, sort, openedAt]);
|
||||
|
||||
const filtering = query.trim() !== "" || format !== null;
|
||||
|
||||
return (
|
||||
<div className="deck-gallery">
|
||||
<div className="deck-gallery-toolbar">
|
||||
<h2>
|
||||
Decks {decks.length > 0 && <span className="badge">{decks.length}</span>}
|
||||
</h2>
|
||||
<span className="spacer" />
|
||||
{decks.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="deck-gallery-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search decks or commanders"
|
||||
/>
|
||||
<div className="deck-gallery-controls">
|
||||
{formats.length > 1 && (
|
||||
<select
|
||||
value={format ?? "*"}
|
||||
onChange={(e) => setFormat(e.target.value === "*" ? null : e.target.value)}
|
||||
title="Filter by format"
|
||||
>
|
||||
<option value="*">All formats</option>
|
||||
{formats.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f || "No format"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks">
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="deck-gallery-create">
|
||||
<IconButton icon={Icons.upload} label="Import deck…" onClick={onImport} />
|
||||
<button className="primary" onClick={onNew}>
|
||||
{Icons.plus}
|
||||
New deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{decks.length === 0 ? (
|
||||
<div className="panel deck-gallery-empty">
|
||||
<span className="deck-gallery-empty-icon">{Icons.cards}</span>
|
||||
<h3>Build your first deck</h3>
|
||||
<p className="hint">Start from scratch, or import a decklist exported from another app.</p>
|
||||
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
|
||||
<button className="primary" onClick={onNew}>
|
||||
{Icons.plus}
|
||||
New deck
|
||||
</button>
|
||||
<button onClick={onImport}>
|
||||
{Icons.upload}
|
||||
Import decklist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div className="deck-gallery-empty">
|
||||
<p className="hint">No decks match.</p>
|
||||
{filtering && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setFormat(null);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "tiles" ? (
|
||||
<div className="deck-gallery-grid">
|
||||
{shown.map((r) => (
|
||||
<DeckTile key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="deck-gallery-rows">
|
||||
{shown.map((r) => (
|
||||
<DeckRow key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { CSSProperties, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { api, Deck } from "../api";
|
||||
import { ConfirmDialog, DialogFrame, usePersistedState } from "../components";
|
||||
import { artCrop, ConfirmDialog, DialogFrame, usePersistedState } from "../components";
|
||||
import { useBackButton } from "../backButton";
|
||||
|
||||
// --- Game state ------------------------------------------------------------
|
||||
@@ -108,13 +108,6 @@ function randomInt(n: number): number {
|
||||
return buf[0] % n;
|
||||
}
|
||||
|
||||
/** Scryfall serves every crop size at the same path under a different
|
||||
* segment, so the stored "normal" card image gives us the art alone. */
|
||||
function artCrop(imageUri: string): string | null {
|
||||
if (!imageUri) return null;
|
||||
return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/");
|
||||
}
|
||||
|
||||
const PLAYER_COLORS = ["#c8553d", "#3a8fd0", "#479b63", "#8a63c9", "#e0a34a", "#3fa9a5"];
|
||||
|
||||
const LIFE_PRESETS = [
|
||||
|
||||
Reference in New Issue
Block a user