Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
|||||||
include_str!("sql/library/0006_printings.sql"),
|
include_str!("sql/library/0006_printings.sql"),
|
||||||
include_str!("sql/library/0007_deck_cover.sql"),
|
include_str!("sql/library/0007_deck_cover.sql"),
|
||||||
include_str!("sql/library/0008_deck_status.sql"),
|
include_str!("sql/library/0008_deck_status.sql"),
|
||||||
|
include_str!("sql/library/0009_deck_card_tags.sql"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The attached alias under which the reference db is available at runtime.
|
/// The attached alias under which the reference db is available at runtime.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Duplicate a deck under a new name: same format/commander/notes/cover, 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
|
/// copy of every deck_cards row (quantity, flags, chosen printing, tags). Fails if
|
||||||
/// `new_name` is already taken. The copy starts as an idea: it's a new list,
|
/// `new_name` is already taken. The copy starts as an idea: it's a new list,
|
||||||
/// not a second set of physical cards.
|
/// not a second set of physical cards.
|
||||||
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
||||||
@@ -81,8 +81,8 @@ impl Catalog {
|
|||||||
)?;
|
)?;
|
||||||
let new_id = self.conn.last_insert_rowid();
|
let new_id = self.conn.last_insert_rowid();
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id)
|
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, tags)
|
||||||
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id
|
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, tags
|
||||||
FROM deck_cards WHERE deck_id = ?2",
|
FROM deck_cards WHERE deck_id = ?2",
|
||||||
params![new_id, id],
|
params![new_id, id],
|
||||||
)?;
|
)?;
|
||||||
@@ -160,7 +160,7 @@ impl Catalog {
|
|||||||
oc.produced_mana, oc.produces_colorless,
|
oc.produced_mana, oc.produces_colorless,
|
||||||
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
|
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
|
||||||
oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, ''),
|
oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, ''),
|
||||||
{copy_limit}
|
{copy_limit}, dc.tags
|
||||||
FROM deck_cards dc
|
FROM deck_cards dc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
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 library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||||
@@ -209,6 +209,7 @@ impl Catalog {
|
|||||||
quantity_spare: 0,
|
quantity_spare: 0,
|
||||||
quantity_in_box: 0,
|
quantity_in_box: 0,
|
||||||
claimed_by: Vec::new(),
|
claimed_by: Vec::new(),
|
||||||
|
tags: parse_tags(&r.get::<_, String>(23)?),
|
||||||
};
|
};
|
||||||
Ok((r.get(19)?, entry))
|
Ok((r.get(19)?, entry))
|
||||||
})?
|
})?
|
||||||
@@ -230,6 +231,51 @@ impl Catalog {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add and remove tags on deck cards, in one transaction: for each slot,
|
||||||
|
/// `remove` goes first, then `add`. Tags are normalised (see
|
||||||
|
/// [`normalize_tag`]) and compared ignoring case, so adding "Ramp" to a
|
||||||
|
/// card tagged "ramp" changes nothing. Cards not in the deck are skipped.
|
||||||
|
pub fn set_deck_card_tags(
|
||||||
|
&self,
|
||||||
|
deck_id: i64,
|
||||||
|
oracle_ids: &[&str],
|
||||||
|
add: &[&str],
|
||||||
|
remove: &[&str],
|
||||||
|
) -> Result<()> {
|
||||||
|
let add: Vec<String> = add.iter().filter_map(|t| normalize_tag(t)).collect();
|
||||||
|
let remove: Vec<String> = remove
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| normalize_tag(t))
|
||||||
|
.map(|t| t.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
let tx = self.nested_tx()?;
|
||||||
|
for oid in oracle_ids {
|
||||||
|
let current: Option<String> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT tags FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
|
||||||
|
params![deck_id, oid],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some(current) = current else { continue };
|
||||||
|
let mut tags = parse_tags(¤t);
|
||||||
|
tags.retain(|t| !remove.contains(&t.to_lowercase()));
|
||||||
|
for t in &add {
|
||||||
|
let lower = t.to_lowercase();
|
||||||
|
if !tags.iter().any(|have| have.to_lowercase() == lower) {
|
||||||
|
tags.push(t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE deck_cards SET tags = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
|
||||||
|
params![tags_json(&tags), deck_id, oid],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every building/built deck's main-board claim on each card, keyed by
|
/// Every building/built deck's main-board claim on each card, keyed by
|
||||||
/// oracle id.
|
/// oracle id.
|
||||||
fn physical_holdings(&self) -> Result<HashMap<String, Vec<DeckHolding>>> {
|
fn physical_holdings(&self) -> Result<HashMap<String, Vec<DeckHolding>>> {
|
||||||
@@ -480,6 +526,28 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The longest a tag may be, in characters.
|
||||||
|
const MAX_TAG_LEN: usize = 40;
|
||||||
|
|
||||||
|
/// Tidy a tag as the user typed it: trimmed, inner whitespace collapsed to
|
||||||
|
/// single spaces, cut to [`MAX_TAG_LEN`] characters. `None` if nothing's left.
|
||||||
|
pub fn normalize_tag(tag: &str) -> Option<String> {
|
||||||
|
let tag: String = tag.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let tag: String = tag.chars().take(MAX_TAG_LEN).collect::<String>().trim_end().to_string();
|
||||||
|
(!tag.is_empty()).then_some(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `deck_cards.tags` value (a JSON array of strings) as a list. Anything
|
||||||
|
/// unreadable counts as no tags.
|
||||||
|
pub(crate) fn parse_tags(json: &str) -> Vec<String> {
|
||||||
|
serde_json::from_str(json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `deck_cards.tags` value for a list of tags.
|
||||||
|
pub(crate) fn tags_json<'a>(tags: impl IntoIterator<Item = &'a String>) -> String {
|
||||||
|
serde_json::to_string(&tags.into_iter().collect::<Vec<_>>()).expect("strings always serialise")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::test_support;
|
use crate::test_support;
|
||||||
@@ -978,4 +1046,52 @@ mod tests {
|
|||||||
assert_eq!(limit("llanowar-elves"), Some(0));
|
assert_eq!(limit("llanowar-elves"), Some(0));
|
||||||
assert_eq!(limit("sol-ring"), None);
|
assert_eq!(limit("sol-ring"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_are_added_removed_normalised_and_deduped() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let d = cat.create_deck("Tags", None).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "lightning-bolt", 1, false, false).unwrap();
|
||||||
|
let tags = |oid: &str| {
|
||||||
|
cat.deck_contents(d.id).unwrap().into_iter().find(|e| e.card.oracle_id == oid).unwrap().tags
|
||||||
|
};
|
||||||
|
|
||||||
|
cat.set_deck_card_tags(d.id, &["sol-ring", "lightning-bolt"], &[" To remove ", "Ramp", ""], &[])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tags("sol-ring"), vec!["To remove", "Ramp"]);
|
||||||
|
assert_eq!(tags("lightning-bolt"), vec!["To remove", "Ramp"]);
|
||||||
|
|
||||||
|
// Matching ignores case; the first casing written is kept.
|
||||||
|
cat.set_deck_card_tags(d.id, &["sol-ring"], &["ramp", "RAMP"], &["to REMOVE"]).unwrap();
|
||||||
|
assert_eq!(tags("sol-ring"), vec!["Ramp"]);
|
||||||
|
assert_eq!(tags("lightning-bolt"), vec!["To remove", "Ramp"]);
|
||||||
|
|
||||||
|
// Long tags are cut; cards not in the deck are skipped.
|
||||||
|
cat.set_deck_card_tags(d.id, &["sol-ring", "serra-angel"], &[&"x".repeat(60)], &[]).unwrap();
|
||||||
|
assert_eq!(tags("sol-ring")[1].chars().count(), 40);
|
||||||
|
assert!(cat.deck_contents(d.id).unwrap().iter().all(|e| e.card.oracle_id != "serra-angel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_stay_with_the_slot() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let d = cat.create_deck("Tags", None).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||||
|
cat.set_deck_card_tags(d.id, &["sol-ring"], &["swap?"], &[]).unwrap();
|
||||||
|
let tags = || cat.deck_contents(d.id).unwrap().first().map(|e| e.tags.clone());
|
||||||
|
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 3, false, false).unwrap();
|
||||||
|
assert_eq!(tags(), Some(vec!["swap?".to_string()]), "maybeboard moves and quantity keep tags");
|
||||||
|
|
||||||
|
let copy = cat.duplicate_deck(d.id, "Copy").unwrap();
|
||||||
|
assert_eq!(cat.deck_contents(copy.id).unwrap()[0].tags, vec!["swap?"]);
|
||||||
|
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 0, false, false).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||||
|
assert_eq!(tags(), Some(vec![]), "removing the card drops its tags");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ pub struct DeckEntry {
|
|||||||
/// [`crate::roles::card_roles`]. Empty when nothing matches.
|
/// [`crate::roles::card_roles`]. Empty when nothing matches.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub roles: Vec<String>,
|
pub roles: Vec<String>,
|
||||||
|
/// The user's own tags on this card in this deck ("to remove", …), each
|
||||||
|
/// normalised by [`crate::decks::normalize_tag`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A building/built deck's claim on a card's copies.
|
/// A building/built deck's claim on a card's copies.
|
||||||
|
|||||||
5
crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql
Normal file
5
crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- v9 — user-defined tags on a deck card ("to remove", "ramp", …), as a JSON
|
||||||
|
-- array of strings. On the slot rather than a join table so they go with it:
|
||||||
|
-- removing the card from the deck drops them, while maybeboard moves and
|
||||||
|
-- quantity changes leave them alone.
|
||||||
|
ALTER TABLE deck_cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';
|
||||||
@@ -57,9 +57,9 @@ pub mod remote;
|
|||||||
/// dropped printing data would publish its loss as a deletion on the next
|
/// 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
|
/// sync, so documents that carry any are marked 2 and older builds refuse
|
||||||
/// them — while libraries that don't use printings stay readable everywhere.
|
/// them — while libraries that don't use printings stay readable everywhere.
|
||||||
/// Version 3 does the same for deck covers, and 4 for deck statuses and
|
/// Version 3 does the same for deck covers, 4 for deck statuses and pulled
|
||||||
/// pulled copies.
|
/// copies, and 5 for deck card tags.
|
||||||
pub const DOC_VERSION: u32 = 4;
|
pub const DOC_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// The format of a document with no printing data in it.
|
/// The format of a document with no printing data in it.
|
||||||
const BASE_VERSION: u32 = 1;
|
const BASE_VERSION: u32 = 1;
|
||||||
@@ -90,6 +90,9 @@ pub struct DeckCardEntry {
|
|||||||
/// Copies physically in the deck (see migration 0008).
|
/// Copies physically in the deck (see migration 0008).
|
||||||
#[serde(default, skip_serializing_if = "is_zero")]
|
#[serde(default, skip_serializing_if = "is_zero")]
|
||||||
pub pulled: i64,
|
pub pulled: i64,
|
||||||
|
/// The user's tags on this card in this deck (see migration 0009).
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
pub tags: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
|
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
|
||||||
@@ -185,10 +188,13 @@ impl LibraryDoc {
|
|||||||
self.cards.is_empty() && self.decks.is_empty()
|
self.cards.is_empty() && self.decks.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lowest format that can hold this document: 4 once any deck has a
|
/// The lowest format that can hold this document: 5 once any deck card
|
||||||
/// status or pulled copies, 3 once any deck has a cover, 2 once it carries
|
/// has tags, 4 once any deck has a status or pulled copies, 3 once any deck has a cover, 2 once it carries
|
||||||
/// any printing data, else 1. See [`DOC_VERSION`].
|
/// any printing data, else 1. See [`DOC_VERSION`].
|
||||||
pub fn required_version(&self) -> u32 {
|
pub fn required_version(&self) -> u32 {
|
||||||
|
if self.decks.values().flat_map(|d| d.cards.values()).any(|c| !c.tags.is_empty()) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.decks
|
.decks
|
||||||
.values()
|
.values()
|
||||||
@@ -543,7 +549,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
|||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id,
|
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id,
|
||||||
quantity_pulled
|
quantity_pulled, tags
|
||||||
FROM deck_cards",
|
FROM deck_cards",
|
||||||
)?;
|
)?;
|
||||||
let mut rows = stmt.query([])?;
|
let mut rows = stmt.query([])?;
|
||||||
@@ -561,6 +567,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
|||||||
maybeboard: row.get::<_, i64>(4)? != 0,
|
maybeboard: row.get::<_, i64>(4)? != 0,
|
||||||
printing: row.get(5)?,
|
printing: row.get(5)?,
|
||||||
pulled: row.get(6)?,
|
pulled: row.get(6)?,
|
||||||
|
tags: crate::decks::parse_tags(&row.get::<_, String>(7)?).into_iter().collect(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -651,14 +658,15 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
|||||||
for (oracle_id, card) in &deck.cards {
|
for (oracle_id, card) in &deck.cards {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard,
|
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard,
|
||||||
preferred_scryfall_id, quantity_pulled)
|
preferred_scryfall_id, quantity_pulled, tags)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
||||||
quantity = excluded.quantity,
|
quantity = excluded.quantity,
|
||||||
is_commander = excluded.is_commander,
|
is_commander = excluded.is_commander,
|
||||||
is_maybeboard = excluded.is_maybeboard,
|
is_maybeboard = excluded.is_maybeboard,
|
||||||
preferred_scryfall_id = excluded.preferred_scryfall_id,
|
preferred_scryfall_id = excluded.preferred_scryfall_id,
|
||||||
quantity_pulled = excluded.quantity_pulled",
|
quantity_pulled = excluded.quantity_pulled,
|
||||||
|
tags = excluded.tags",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
deck_id,
|
deck_id,
|
||||||
oracle_id,
|
oracle_id,
|
||||||
@@ -666,7 +674,8 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
|||||||
card.commander as i64,
|
card.commander as i64,
|
||||||
card.maybeboard as i64,
|
card.maybeboard as i64,
|
||||||
card.printing,
|
card.printing,
|
||||||
card.pulled
|
card.pulled,
|
||||||
|
crate::decks::tags_json(&card.tags)
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1030,19 @@ mod tests {
|
|||||||
assert_eq!(back, merged);
|
assert_eq!(back, merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_5_is_used_only_once_a_deck_card_has_tags() {
|
||||||
|
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
||||||
|
d.decks.get_mut("u1").unwrap().status = DeckStatus::Built;
|
||||||
|
assert_eq!(d.required_version(), 4);
|
||||||
|
assert!(!d.to_json().unwrap().contains("tags"));
|
||||||
|
|
||||||
|
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().tags.insert("ramp".into());
|
||||||
|
assert_eq!(d.required_version(), 5);
|
||||||
|
let back = LibraryDoc::from_json(&d.to_json().unwrap()).unwrap();
|
||||||
|
assert_eq!(back, d);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deck_status_merges_like_other_deck_fields() {
|
fn deck_status_merges_like_other_deck_fields() {
|
||||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||||
@@ -1072,8 +1094,11 @@ mod tests {
|
|||||||
c.set_location("card-a", "Box 1").unwrap();
|
c.set_location("card-a", "Box 1").unwrap();
|
||||||
c.set_deck_status(deck.id, DeckStatus::Building, false).unwrap();
|
c.set_deck_status(deck.id, DeckStatus::Building, false).unwrap();
|
||||||
c.set_deck_card_pulled(deck.id, "card-a", 1).unwrap();
|
c.set_deck_card_pulled(deck.id, "card-a", 1).unwrap();
|
||||||
|
c.set_deck_card_tags(deck.id, &["card-a"], &["ramp", "to remove"], &[]).unwrap();
|
||||||
|
|
||||||
let before = read_doc(&c.conn).unwrap();
|
let before = read_doc(&c.conn).unwrap();
|
||||||
|
assert_eq!(before.version, 5);
|
||||||
|
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].tags.len(), 2);
|
||||||
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
|
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
|
||||||
assert_eq!(before.decks.values().next().unwrap().status, DeckStatus::Building);
|
assert_eq!(before.decks.values().next().unwrap().status, DeckStatus::Building);
|
||||||
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1);
|
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1);
|
||||||
|
|||||||
@@ -463,6 +463,20 @@ async fn only_valid_libraries_are_stored() {
|
|||||||
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_library_with_deck_card_tags_is_stored() {
|
||||||
|
let s = Server::local();
|
||||||
|
let t = s.sign_in("alice", "correct horse battery").await;
|
||||||
|
let doc = json!({
|
||||||
|
"version": 5,
|
||||||
|
"cards": {},
|
||||||
|
"decks": { "u1": { "name": "Fub", "cards": { "a": { "quantity": 1, "tags": ["ramp"] } } } }
|
||||||
|
});
|
||||||
|
assert_eq!(s.put_library(&t, Some("0"), doc).await.status(), StatusCode::OK);
|
||||||
|
let got = body_json(s.get_library(&t).await).await;
|
||||||
|
assert_eq!(got["doc"]["decks"]["u1"]["cards"]["a"]["tags"], json!(["ramp"]));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn each_user_has_their_own_library() {
|
async fn each_user_has_their_own_library() {
|
||||||
let s = Server::local();
|
let s = Server::local();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Design: deck card tags
|
# Design: deck card tags
|
||||||
|
|
||||||
Status: **proposed — not implemented.**
|
Status: **implemented.**
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -132,8 +132,10 @@ v2–v4 followed the same rule.
|
|||||||
- `toggleSelectAllCards` works on the shown cards (main board and
|
- `toggleSelectAllCards` works on the shown cards (main board and
|
||||||
maybeboard, after filtering).
|
maybeboard, after filtering).
|
||||||
- **[`components.tsx`](../src/components.tsx):**
|
- **[`components.tsx`](../src/components.tsx):**
|
||||||
- `PreviewContext` gains `tags?`, `tagSuggestions?` and `onTagsChange?`.
|
- `PreviewContext` gains `tagSuggestions?`, and `PreviewCard` gains
|
||||||
- The preview's deck section renders a `TagInput` when they're provided.
|
`tags?` (the context is per deck, the tags per card).
|
||||||
|
- The preview's deck section renders a `TagInput` when `tagSuggestions`
|
||||||
|
is provided, saving through `api.setDeckCardTags` like its other edits.
|
||||||
- Reuse `TagInput` as it is.
|
- Reuse `TagInput` as it is.
|
||||||
- **[`styles.css`](../src/styles.css):** tag chips (clickable look, and an
|
- **[`styles.css`](../src/styles.css):** tag chips (clickable look, and an
|
||||||
active state while filtering), tile tag badges.
|
active state while filtering), tile tag badges.
|
||||||
|
|||||||
@@ -351,6 +351,24 @@ pub fn set_deck_card_pulled(db: Db, deck_id: i64, oracle_id: String, pulled: i64
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add and remove tags on one or more of a deck's cards.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_deck_card_tags(
|
||||||
|
db: Db,
|
||||||
|
deck_id: i64,
|
||||||
|
oracle_ids: Vec<String>,
|
||||||
|
add: Vec<String>,
|
||||||
|
remove: Vec<String>,
|
||||||
|
) -> Res<()> {
|
||||||
|
fn refs(v: &[String]) -> Vec<&str> {
|
||||||
|
v.iter().map(String::as_str).collect()
|
||||||
|
}
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
s.catalog()?
|
||||||
|
.set_deck_card_tags(deck_id, &refs(&oracle_ids), &refs(&add), &refs(&remove))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res<bool> {
|
pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res<bool> {
|
||||||
let s = db.lock().unwrap();
|
let s = db.lock().unwrap();
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub fn run() {
|
|||||||
commands::set_deck_cover,
|
commands::set_deck_cover,
|
||||||
commands::set_deck_status,
|
commands::set_deck_status,
|
||||||
commands::set_deck_card_pulled,
|
commands::set_deck_card_pulled,
|
||||||
|
commands::set_deck_card_tags,
|
||||||
commands::take_deck_card_copy,
|
commands::take_deck_card_copy,
|
||||||
commands::set_deck_commander,
|
commands::set_deck_commander,
|
||||||
commands::set_deck_commanders,
|
commands::set_deck_commanders,
|
||||||
|
|||||||
11
src/api.ts
11
src/api.ts
@@ -68,7 +68,8 @@ export interface DeckEntry extends CardRef {
|
|||||||
copy_limit: number | null;
|
copy_limit: number | null;
|
||||||
/** What the card does in a deck ("Removal", "Mill", …), read from its
|
/** What the card does in a deck ("Removal", "Mill", …), read from its
|
||||||
* rules text; a card can have several, or none. */
|
* rules text; a card can have several, or none. */
|
||||||
roles: string[];
|
roles: string[]; /** The user's own tags on this card in this deck ("to remove", …). */
|
||||||
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeckHolding {
|
export interface DeckHolding {
|
||||||
@@ -131,6 +132,8 @@ export type PreviewCard = CardRef & {
|
|||||||
quantity_in_decks?: number;
|
quantity_in_decks?: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
preferred_scryfall_id?: string | null;
|
preferred_scryfall_id?: string | null;
|
||||||
|
/** The card's tags in the deck the preview was opened from. */
|
||||||
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +148,9 @@ export interface PreviewContext {
|
|||||||
deckId?: number;
|
deckId?: number;
|
||||||
/** That deck's chosen cover card, so the preview can offer to change it. */
|
/** That deck's chosen cover card, so the preview can offer to change it. */
|
||||||
coverOracleId?: string | null;
|
coverOracleId?: string | null;
|
||||||
|
/** The tags already used in that deck, offered while tagging the card.
|
||||||
|
* The preview only offers tagging when this is given. */
|
||||||
|
tagSuggestions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Deck {
|
export interface Deck {
|
||||||
@@ -556,6 +562,9 @@ export const api = {
|
|||||||
/** Record copies physically in a deck; returns the (clamped) count stored. */
|
/** Record copies physically in a deck; returns the (clamped) count stored. */
|
||||||
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
|
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
|
||||||
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
|
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
|
||||||
|
/** Add and remove tags on deck cards; removals apply first, matching ignores case. */
|
||||||
|
setDeckCardTags: (deckId: number, oracleIds: string[], add: string[], remove: string[]) =>
|
||||||
|
invoke<void>("set_deck_card_tags", { deckId, oracleIds, add, remove }),
|
||||||
/** Move one pulled copy between decks; false if there was nothing to move. */
|
/** Move one pulled copy between decks; false if there was nothing to move. */
|
||||||
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
|
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
|
||||||
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
|
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
|
||||||
|
|||||||
@@ -1117,6 +1117,7 @@ function PrintingsSection({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Tracked here: `ctx` is a snapshot from when the preview opened.
|
// Tracked here: `ctx` is a snapshot from when the preview opened.
|
||||||
const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id);
|
const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id);
|
||||||
|
const [tags, setTags] = useState(card.tags ?? []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -1173,6 +1174,15 @@ function PrintingsSection({
|
|||||||
setIsCover(cover);
|
setIsCover(cover);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TagInput hands back the whole new list; send the difference.
|
||||||
|
const changeTags = (next: string[]) =>
|
||||||
|
run(async () => {
|
||||||
|
const add = next.filter((t) => !tags.includes(t));
|
||||||
|
const remove = tags.filter((t) => !next.includes(t));
|
||||||
|
setTags(next);
|
||||||
|
await api.setDeckCardTags(ctx.deckId!, [card.oracle_id], add, remove);
|
||||||
|
});
|
||||||
|
|
||||||
function onPick(p: PrintingRef, finish: string) {
|
function onPick(p: PrintingRef, finish: string) {
|
||||||
const mode = picking;
|
const mode = picking;
|
||||||
setPicking(null);
|
setPicking(null);
|
||||||
@@ -1219,6 +1229,17 @@ function PrintingsSection({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{ctx.tagSuggestions && (
|
||||||
|
<div className="preview-tags">
|
||||||
|
<span className="printing-row-label">Tags</span>
|
||||||
|
<TagInput
|
||||||
|
values={tags}
|
||||||
|
onChange={changeTags}
|
||||||
|
suggestions={ctx.tagSuggestions}
|
||||||
|
placeholder="Add tags…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="preview-info-section">
|
<div className="preview-info-section">
|
||||||
|
|||||||
@@ -2094,6 +2094,65 @@ button.pull-action {
|
|||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.dialog-field .tag-chip-input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deck card tags: chips in the deck list that filter to their tag. */
|
||||||
|
.tag-filter-chips {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tag-filter-chips .deck-chip {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.deck-chip.tag-filter-chip.active {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.tile-badge.tile-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* The Tags field atop the deck builder's advanced filter popup. */
|
||||||
|
.tag-filter-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.tag-filter-input.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Tags already on the cards being tagged, each with its own actions. */
|
||||||
|
.tag-cards-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tag-cards-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.preview-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Card images: thumbnails, hover preview, tiles
|
Card images: thumbnails, hover preview, tiles
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
SortDir,
|
SortDir,
|
||||||
SortMode,
|
SortMode,
|
||||||
StatTile,
|
StatTile,
|
||||||
|
TagInput,
|
||||||
TileColumnsControl,
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
@@ -80,7 +81,7 @@ function boardToggleProps(e: DeckEntry) {
|
|||||||
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "price" | "where" | "actions";
|
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "price" | "where" | "tags" | "actions";
|
||||||
|
|
||||||
// The List view's optional columns; the checkbox and name always show.
|
// The List view's optional columns; the checkbox and name always show.
|
||||||
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
||||||
@@ -91,6 +92,7 @@ const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
|||||||
{ id: "owned", label: "Owned" },
|
{ id: "owned", label: "Owned" },
|
||||||
{ id: "price", label: "Price" },
|
{ id: "price", label: "Price" },
|
||||||
{ id: "where", label: "Where" },
|
{ id: "where", label: "Where" },
|
||||||
|
{ id: "tags", label: "Tags" },
|
||||||
{ id: "actions", label: "Actions" },
|
{ id: "actions", label: "Actions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -325,16 +327,153 @@ function SectionNav({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrowing the deck's cards by their tags. Matched in the browser, unlike
|
||||||
|
* the rest of the advanced filter. */
|
||||||
|
interface TagFilter {
|
||||||
|
tags: string[];
|
||||||
|
/** Whether a card needs any of `tags`, or all of them. */
|
||||||
|
mode: "any" | "all";
|
||||||
|
/** Only cards with no tags at all; `tags` is then ignored. */
|
||||||
|
untagged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_TAG_FILTER: TagFilter = { tags: [], mode: "any", untagged: false };
|
||||||
|
|
||||||
|
const tagFilterActive = (f: TagFilter) => f.untagged || f.tags.length > 0;
|
||||||
|
|
||||||
|
/** Tag comparison ignores case, like the backend's. */
|
||||||
|
const hasTag = (e: DeckEntry, tag: string) => e.tags.some((t) => t.toLowerCase() === tag.toLowerCase());
|
||||||
|
|
||||||
|
function matchesTagFilter(e: DeckEntry, f: TagFilter): boolean {
|
||||||
|
if (f.untagged) return e.tags.length === 0;
|
||||||
|
if (f.tags.length === 0) return true;
|
||||||
|
return f.mode === "all" ? f.tags.every((t) => hasTag(e, t)) : f.tags.some((t) => hasTag(e, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's tags as chips; clicking one filters to it (or, if the filter
|
||||||
|
* already has it, takes it back out). */
|
||||||
|
function TagChips({
|
||||||
|
tags,
|
||||||
|
filter,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
tags: string[];
|
||||||
|
filter: TagFilter;
|
||||||
|
onToggle: (tag: string) => void;
|
||||||
|
}) {
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="tag-filter-chips">
|
||||||
|
{tags.map((t) => {
|
||||||
|
const active = !filter.untagged && filter.tags.some((f) => f.toLowerCase() === t.toLowerCase());
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={`deck-chip deck-chip-link tag-filter-chip ${active ? "active" : ""}`}
|
||||||
|
onClick={() => onToggle(t)}
|
||||||
|
title={active ? `Stop filtering by "${t}"` : `Show only cards tagged "${t}"`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add and remove tags across the selected cards. Each change saves at once. */
|
||||||
|
function TagCardsDialog({
|
||||||
|
entries,
|
||||||
|
suggestions,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
entries: DeckEntry[];
|
||||||
|
suggestions: string[];
|
||||||
|
onChange: (add: string[], remove: string[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const counts = new Map<string, { tag: string; n: number }>();
|
||||||
|
for (const e of entries) {
|
||||||
|
for (const t of e.tags) {
|
||||||
|
const c = counts.get(t.toLowerCase());
|
||||||
|
if (c) c.n++;
|
||||||
|
else counts.set(t.toLowerCase(), { tag: t, n: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const used = [...counts.values()].sort((a, b) => a.tag.localeCompare(b.tag));
|
||||||
|
|
||||||
|
async function apply(add: string[], remove: string[]) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onChange(add, remove);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogFrame title={`Tag ${entries.length} ${entries.length === 1 ? "card" : "cards"}`} onCancel={onClose}>
|
||||||
|
{used.length > 0 && (
|
||||||
|
<ul className="tag-cards-list">
|
||||||
|
{used.map(({ tag, n }) => (
|
||||||
|
<li key={tag}>
|
||||||
|
<span className="deck-chip">{tag}</span>
|
||||||
|
<span className="muted">
|
||||||
|
{n} of {entries.length}
|
||||||
|
</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={() => apply([tag], [])} disabled={busy || n === entries.length}>
|
||||||
|
Add to all
|
||||||
|
</button>
|
||||||
|
<button onClick={() => apply([], [tag])} disabled={busy}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<label className="dialog-field">
|
||||||
|
<span className="muted">Add to every selected card</span>
|
||||||
|
<TagInput
|
||||||
|
values={[]}
|
||||||
|
onChange={(next) => next.length > 0 && apply(next, [])}
|
||||||
|
suggestions={suggestions}
|
||||||
|
placeholder="New tag…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="dialog-error">{error}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The advanced filter as a popup, so it doesn't take over the builder.
|
/** The advanced filter as a popup, so it doesn't take over the builder.
|
||||||
* Changes apply as they're made; `matched` is null until a filter is on. */
|
* Changes apply as they're made; `matched` is null until a filter is on. */
|
||||||
function AdvancedFilterDialog({
|
function AdvancedFilterDialog({
|
||||||
state,
|
state,
|
||||||
|
tagFilter,
|
||||||
|
onTagFilterChange,
|
||||||
|
deckTags,
|
||||||
matched,
|
matched,
|
||||||
total,
|
total,
|
||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
state: CardFilterState;
|
state: CardFilterState;
|
||||||
|
tagFilter: TagFilter;
|
||||||
|
onTagFilterChange: (f: TagFilter) => void;
|
||||||
|
/** Every tag used in the deck, the only ones worth filtering by. */
|
||||||
|
deckTags: string[];
|
||||||
matched: number | null;
|
matched: number | null;
|
||||||
total: number;
|
total: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -344,15 +483,62 @@ function AdvancedFilterDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getReferenceFacets().then(setFacets).catch(() => {});
|
api.getReferenceFacets().then(setFacets).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
// TagInput accepts anything typed; keep only tags the deck actually uses.
|
||||||
|
const known = (tags: string[]) =>
|
||||||
|
tags.flatMap((t) => deckTags.find((d) => d.toLowerCase() === t.trim().toLowerCase()) ?? []);
|
||||||
return (
|
return (
|
||||||
<DialogFrame title="Filter deck cards" onCancel={onClose} wide>
|
<DialogFrame title="Filter deck cards" onCancel={onClose} wide>
|
||||||
|
<div className="tag-filter-field">
|
||||||
|
<span className="muted">Tags</span>
|
||||||
|
{deckTags.length === 0 ? (
|
||||||
|
<span className="hint">No cards in this deck are tagged yet.</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={tagFilter.untagged ? "tag-filter-input disabled" : "tag-filter-input"}>
|
||||||
|
<TagInput
|
||||||
|
values={tagFilter.tags}
|
||||||
|
onChange={(tags) => onTagFilterChange({ ...tagFilter, tags: known(tags) })}
|
||||||
|
suggestions={deckTags}
|
||||||
|
placeholder="Filter by tag…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 12, flexWrap: "wrap" }}>
|
||||||
|
<label className="row" style={{ gap: 6 }}>
|
||||||
|
<span className="muted">Match</span>
|
||||||
|
<select
|
||||||
|
value={tagFilter.mode}
|
||||||
|
onChange={(e) => onTagFilterChange({ ...tagFilter, mode: e.target.value as TagFilter["mode"] })}
|
||||||
|
disabled={tagFilter.untagged}
|
||||||
|
>
|
||||||
|
<option value="any">any of these</option>
|
||||||
|
<option value="all">all of these</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="row" style={{ gap: 6, cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tagFilter.untagged}
|
||||||
|
onChange={(e) => onTagFilterChange({ ...tagFilter, untagged: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Untagged only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<CardFilterPanel state={state} facets={facets} decks={[]} hideName hideInDeck alwaysExpanded />
|
<CardFilterPanel state={state} facets={facets} decks={[]} hideName hideInDeck alwaysExpanded />
|
||||||
{error && <p className="sync-bad">{error}</p>}
|
{error && <p className="sync-bad">{error}</p>}
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<span className="muted" style={{ marginRight: "auto" }}>
|
<span className="muted" style={{ marginRight: "auto" }}>
|
||||||
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`}
|
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={state.reset} disabled={!state.active}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
state.reset();
|
||||||
|
onTagFilterChange(NO_TAG_FILTER);
|
||||||
|
}}
|
||||||
|
disabled={!state.active && !tagFilterActive(tagFilter)}
|
||||||
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
</button>
|
</button>
|
||||||
<button className="primary" onClick={onClose}>
|
<button className="primary" onClick={onClose}>
|
||||||
@@ -412,6 +598,8 @@ function DeckBuilder({
|
|||||||
const [advOpen, setAdvOpen] = useState(false);
|
const [advOpen, setAdvOpen] = useState(false);
|
||||||
const [advMatches, setAdvMatches] = useState<Set<string> | null>(null);
|
const [advMatches, setAdvMatches] = useState<Set<string> | null>(null);
|
||||||
const [advError, setAdvError] = useState<string | null>(null);
|
const [advError, setAdvError] = useState<string | null>(null);
|
||||||
|
const [tagFilter, setTagFilter] = useState<TagFilter>(NO_TAG_FILTER);
|
||||||
|
const [tagDialogOpen, setTagDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [libQuery, setLibQuery] = useState("");
|
const [libQuery, setLibQuery] = useState("");
|
||||||
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
||||||
@@ -516,12 +704,6 @@ function DeckBuilder({
|
|||||||
});
|
});
|
||||||
}, [contents]);
|
}, [contents]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectAllRef.current) {
|
|
||||||
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
|
|
||||||
}
|
|
||||||
}, [selectedCards, contents]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = libQuery.trim();
|
const q = libQuery.trim();
|
||||||
if (!q) return setLibResults([]);
|
if (!q) return setLibResults([]);
|
||||||
@@ -704,11 +886,11 @@ function DeckBuilder({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select every card currently shown (so a filtered view selects just
|
||||||
|
* what passes the filter), or clear the selection if they all are. */
|
||||||
function toggleSelectAllCards() {
|
function toggleSelectAllCards() {
|
||||||
setPartialRemoval(new Map());
|
setPartialRemoval(new Map());
|
||||||
setSelectedCards((s) =>
|
setSelectedCards(allShownSelected ? new Set() : new Set(shownCards.map((e) => e.oracle_id)));
|
||||||
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
||||||
@@ -764,6 +946,23 @@ function DeckBuilder({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeTags(oracleIds: string[], add: string[], remove: string[]) {
|
||||||
|
await api.setDeckCardTags(deckId, oracleIds, add, remove);
|
||||||
|
await loadContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clicking a tag chip adds it to the tag filter, or takes it back out. */
|
||||||
|
function toggleTagFilter(tag: string) {
|
||||||
|
setTagFilter((f) => {
|
||||||
|
const has = !f.untagged && f.tags.some((t) => t.toLowerCase() === tag.toLowerCase());
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
untagged: false,
|
||||||
|
tags: has ? f.tags.filter((t) => t.toLowerCase() !== tag.toLowerCase()) : [...(f.untagged ? [] : f.tags), tag],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function changeFormat(format: string) {
|
async function changeFormat(format: string) {
|
||||||
await api.setDeckFormat(deckId, format);
|
await api.setDeckFormat(deckId, format);
|
||||||
await onDecksChanged();
|
await onDecksChanged();
|
||||||
@@ -863,6 +1062,7 @@ function DeckBuilder({
|
|||||||
// the library/reference db to find NEW cards to add).
|
// the library/reference db to find NEW cards to add).
|
||||||
function matchesFilter(e: DeckEntry): boolean {
|
function matchesFilter(e: DeckEntry): boolean {
|
||||||
if (advMatches && !advMatches.has(e.oracle_id)) return false;
|
if (advMatches && !advMatches.has(e.oracle_id)) return false;
|
||||||
|
if (!matchesTagFilter(e, tagFilter)) return false;
|
||||||
const q = fold(filterQuery.trim());
|
const q = fold(filterQuery.trim());
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
||||||
@@ -880,6 +1080,27 @@ function DeckBuilder({
|
|||||||
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
|
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
|
||||||
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
|
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
|
||||||
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
||||||
|
const shownCards = [...mainboard, ...maybeboard];
|
||||||
|
const allShownSelected = shownCards.length > 0 && shownCards.every((e) => selectedCards.has(e.oracle_id));
|
||||||
|
// Every tag used in the deck, in the casing first met, for autocomplete
|
||||||
|
// and the tag filter.
|
||||||
|
const deckTags = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const e of contents) for (const t of e.tags) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t);
|
||||||
|
return [...seen.values()].sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [contents]);
|
||||||
|
const tagsOn = tagFilterActive(tagFilter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectAllRef.current) {
|
||||||
|
selectAllRef.current.indeterminate = selectedCards.size > 0 && !allShownSelected;
|
||||||
|
}
|
||||||
|
}, [selectedCards, allShownSelected]);
|
||||||
|
// The popup's "N of M match": both the backend-matched criteria and tags.
|
||||||
|
const advMatchedCount =
|
||||||
|
advMatches || tagsOn
|
||||||
|
? contents.filter((e) => (!advMatches || advMatches.has(e.oracle_id)) && matchesTagFilter(e, tagFilter)).length
|
||||||
|
: null;
|
||||||
// The primary commander first, then its partner, if any.
|
// The primary commander first, then its partner, if any.
|
||||||
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
||||||
const commanders = contents
|
const commanders = contents
|
||||||
@@ -957,6 +1178,7 @@ function DeckBuilder({
|
|||||||
const preview: PreviewContext = {
|
const preview: PreviewContext = {
|
||||||
deckId,
|
deckId,
|
||||||
coverOracleId: deck.cover_oracle_id,
|
coverOracleId: deck.cover_oracle_id,
|
||||||
|
tagSuggestions: deckTags,
|
||||||
onChanged: () => {
|
onChanged: () => {
|
||||||
loadContents();
|
loadContents();
|
||||||
onDecksChanged();
|
onDecksChanged();
|
||||||
@@ -1067,12 +1289,17 @@ function DeckBuilder({
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
e.is_commander || partialRemoval.has(e.oracle_id) || whereabouts(e) ? (
|
e.is_commander || partialRemoval.has(e.oracle_id) || e.tags.length > 0 || whereabouts(e) ? (
|
||||||
<span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
|
<span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
|
||||||
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
||||||
{partialRemoval.has(e.oracle_id) && (
|
{partialRemoval.has(e.oracle_id) && (
|
||||||
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
|
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
|
||||||
)}
|
)}
|
||||||
|
{e.tags.map((t) => (
|
||||||
|
<span key={t} className="tile-badge tile-tag">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{whereabouts(e, true)}
|
{whereabouts(e, true)}
|
||||||
</span>
|
</span>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -1097,6 +1324,7 @@ function DeckBuilder({
|
|||||||
{columns.shown("owned") && <th>Owned</th>}
|
{columns.shown("owned") && <th>Owned</th>}
|
||||||
{columns.shown("price") && <th>Price</th>}
|
{columns.shown("price") && <th>Price</th>}
|
||||||
{columns.shown("where") && <th>Where</th>}
|
{columns.shown("where") && <th>Where</th>}
|
||||||
|
{columns.shown("tags") && <th>Tags</th>}
|
||||||
{columns.shown("actions") && <th></th>}
|
{columns.shown("actions") && <th></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1156,6 +1384,11 @@ function DeckBuilder({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
||||||
|
{columns.shown("tags") && (
|
||||||
|
<td data-label="Tags">
|
||||||
|
<TagChips tags={e.tags} filter={tagFilter} onToggle={toggleTagFilter} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
{columns.shown("actions") && (
|
{columns.shown("actions") && (
|
||||||
<td>
|
<td>
|
||||||
<span className="row" style={{ gap: 4 }}>
|
<span className="row" style={{ gap: 4 }}>
|
||||||
@@ -1399,13 +1632,13 @@ function DeckBuilder({
|
|||||||
<hr className="deck-section-divider" />
|
<hr className="deck-section-divider" />
|
||||||
|
|
||||||
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
||||||
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards shown">
|
||||||
<input
|
<input
|
||||||
ref={selectAllRef}
|
ref={selectAllRef}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={contents.length > 0 && selectedCards.size === contents.length}
|
checked={allShownSelected}
|
||||||
onChange={toggleSelectAllCards}
|
onChange={toggleSelectAllCards}
|
||||||
disabled={contents.length === 0}
|
disabled={shownCards.length === 0}
|
||||||
/>
|
/>
|
||||||
<span className="muted" style={{ fontSize: 12 }}>
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
Select all
|
Select all
|
||||||
@@ -1443,7 +1676,7 @@ function DeckBuilder({
|
|||||||
filter={filterQuery}
|
filter={filterQuery}
|
||||||
onFilterChange={setFilterQuery}
|
onFilterChange={setFilterQuery}
|
||||||
onAdvanced={() => setAdvOpen(true)}
|
onAdvanced={() => setAdvOpen(true)}
|
||||||
advancedActive={advFilter.active}
|
advancedActive={advFilter.active || tagsOn}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedCards.size > 0 && (
|
{selectedCards.size > 0 && (
|
||||||
@@ -1463,6 +1696,9 @@ function DeckBuilder({
|
|||||||
>
|
>
|
||||||
Add to library
|
Add to library
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setTagDialogOpen(true)} disabled={bulkBusy}>
|
||||||
|
Tag…
|
||||||
|
</button>
|
||||||
{physical && (
|
{physical && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1617,12 +1853,23 @@ function DeckBuilder({
|
|||||||
{advOpen && (
|
{advOpen && (
|
||||||
<AdvancedFilterDialog
|
<AdvancedFilterDialog
|
||||||
state={advFilter}
|
state={advFilter}
|
||||||
matched={advMatches?.size ?? null}
|
tagFilter={tagFilter}
|
||||||
|
onTagFilterChange={setTagFilter}
|
||||||
|
deckTags={deckTags}
|
||||||
|
matched={advMatchedCount}
|
||||||
total={contents.length}
|
total={contents.length}
|
||||||
error={advError}
|
error={advError}
|
||||||
onClose={() => setAdvOpen(false)}
|
onClose={() => setAdvOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tagDialogOpen && selectedCards.size > 0 && (
|
||||||
|
<TagCardsDialog
|
||||||
|
entries={contents.filter((e) => selectedCards.has(e.oracle_id))}
|
||||||
|
suggestions={deckTags}
|
||||||
|
onChange={(add, remove) => changeTags([...selectedCards], add, remove)}
|
||||||
|
onClose={() => setTagDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{handOpen && (
|
{handOpen && (
|
||||||
<SampleHandModal
|
<SampleHandModal
|
||||||
hand={hand}
|
hand={hand}
|
||||||
|
|||||||
Reference in New Issue
Block a user