From d286d3420c16b9216663efd35625e701c800cb8a Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Sun, 25 Oct 2026 15:04:41 -0500 Subject: [PATCH] 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 --- crates/mtgc-core/src/db.rs | 1 + crates/mtgc-core/src/decklist.rs | 1 + crates/mtgc-core/src/decks.rs | 110 +- crates/mtgc-core/src/model.rs | 3 + .../src/sql/library/0007_deck_cover.sql | 4 + crates/mtgc-core/src/sync.rs | 67 +- src-tauri/src/commands.rs | 16 + src-tauri/src/lib.rs | 2 + src/api.ts | 9 + src/components.tsx | 69 +- src/deckAnalysis.ts | 378 +++++ src/styles.css | 513 +++++- src/views/DeckList.tsx | 426 +++++ src/views/DeckView.tsx | 1475 +++++++---------- src/views/GameView.tsx | 9 +- 15 files changed, 2113 insertions(+), 970 deletions(-) create mode 100644 crates/mtgc-core/src/sql/library/0007_deck_cover.sql create mode 100644 src/deckAnalysis.ts create mode 100644 src/views/DeckList.tsx diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 04ba05c..295679a 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -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. diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index 9d1eaec..eedc515 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -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"); diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index f8867f0..8b52afe 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -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> { 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 { @@ -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> { + 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>> { + let mut by_deck: HashMap> = 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) -> Result> { 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::, _>>()?; 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> { 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(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index b2ab0e4..2753bf0 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -269,6 +269,9 @@ pub struct Deck { /// The commander card's oracle id (meaningful for Commander/Brawl formats). pub commander_oracle_id: Option, 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, } /// Aggregate stats about the owned library, for a dashboard-style summary. diff --git a/crates/mtgc-core/src/sql/library/0007_deck_cover.sql b/crates/mtgc-core/src/sql/library/0007_deck_cover.sql new file mode 100644 index 0000000..eacb203 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0007_deck_cover.sql @@ -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; diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs index 5c8db40..8a847e6 100644 --- a/crates/mtgc-core/src/sync.rs +++ b/crates/mtgc-core/src/sync.rs @@ -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, #[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, #[serde(default)] pub cards: BTreeMap, } @@ -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 { } } - 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 = BTreeMap::new(); while let Some(row) = rows.next()? { @@ -497,6 +507,7 @@ pub fn read_doc(conn: &Connection) -> Result { 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(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 99c82cd..70c491a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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> { 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>> { + 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) -> 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) -> Res<()> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9f69a7..0067fb4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/api.ts b/src/api.ts index 7ee5317..cfcd75e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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("duplicate_deck", { deckId, newName }), setDeckFormat: (deckId: number, format: string) => invoke("set_deck_format", { deckId, format }), + /** Choose the card headlining the deck in the deck list; null = automatic. */ + setDeckCover: (deckId: number, oracleId: string | null) => + invoke("set_deck_cover", { deckId, oracleId }), setDeckCommander: (deckId: number, oracleId: string | null) => invoke("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("deck_contents", { deckId }), + /** Every deck's contents by deck id; decks with no cards are absent. */ + allDeckContents: () => invoke>("all_deck_contents"), suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) => invoke("suggest_cards", { deckId, offset, options }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), diff --git a/src/components.tsx b/src/components.tsx index dc0e141..5f1ffa3 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -107,6 +107,32 @@ const ICON_SHARE = icon( , ); const ICON_CHECK = icon(); +const ICON_ARROW_LEFT = icon( + <> + + + , +); +const ICON_PLUS = icon( + <> + + + , +); +const ICON_MORE = icon( + <> + + + + , +); +const ICON_IMAGE = icon( + <> + + + + , +); // 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(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 && } +
+ + {isCover ? "This card's art is the deck's cover" : "Deck cover"} + + + + {isCover ? ( + + ) : ( + + )} + +
)}
@@ -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(() => { 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( diff --git a/src/deckAnalysis.ts b/src/deckAnalysis.ts new file mode 100644 index 0000000..aed4b31 --- /dev/null +++ b/src/deckAnalysis.ts @@ -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((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, + }; +} diff --git a/src/styles.css b/src/styles.css index a2ef2a0..80115b4 100644 --- a/src/styles.css +++ b/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 diff --git a/src/views/DeckList.tsx b/src/views/DeckList.tsx new file mode 100644 index 0000000..d21d581 --- /dev/null +++ b/src/views/DeckList.tsx @@ -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 ( + + {s.size} card{s.size === 1 ? "" : "s"} + + ); + } + return ( + + {s.size} + /{s.target} + + ); +} + +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 ( + + {legal ? "✓" : "⚠"} + {!compact && {legal ? "Legal" : `${n} issue${n === 1 ? "" : "s"}`}} + + ); +} + +function OwnedBar({ summary: s }: { summary: DeckSummary }) { + return ( +