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:
2026-10-25 15:04:41 -05:00
parent fae1aa2b05
commit d286d3420c
15 changed files with 2113 additions and 970 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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();

View File

@@ -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.

View 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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -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
View 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,
};
}

View File

@@ -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
View 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

View File

@@ -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 = [