From 70c89a56446196a88d410428c5b6b6778705a03a Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Thu, 29 Oct 2026 14:42:40 +0000 Subject: [PATCH] Deck builder: tag cards within a deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mtgc-core/src/db.rs | 1 + crates/mtgc-core/src/decks.rs | 124 +++++++- crates/mtgc-core/src/model.rs | 4 + .../src/sql/library/0009_deck_card_tags.sql | 5 + crates/mtgc-core/src/sync.rs | 45 ++- crates/mtgc-server/tests/api.rs | 14 + docs/deck-tags-design.md | 8 +- src-tauri/src/commands.rs | 18 ++ src-tauri/src/lib.rs | 1 + src/api.ts | 11 +- src/components.tsx | 21 ++ src/styles.css | 59 ++++ src/views/DeckView.tsx | 281 ++++++++++++++++-- 13 files changed, 557 insertions(+), 35 deletions(-) create mode 100644 crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 69a81aa..e8915b2 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -21,6 +21,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[ include_str!("sql/library/0006_printings.sql"), include_str!("sql/library/0007_deck_cover.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. diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 8abc80d..29cb6fe 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -65,7 +65,7 @@ impl Catalog { } /// 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, /// not a second set of physical cards. pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result { @@ -81,8 +81,8 @@ impl Catalog { )?; let new_id = self.conn.last_insert_rowid(); self.conn.execute( - "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) - SELECT ?1, 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, tags FROM deck_cards WHERE deck_id = ?2", params![new_id, id], )?; @@ -160,7 +160,7 @@ impl Catalog { oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id, oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, ''), - {copy_limit} + {copy_limit}, dc.tags 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 @@ -209,6 +209,7 @@ impl Catalog { quantity_spare: 0, quantity_in_box: 0, claimed_by: Vec::new(), + tags: parse_tags(&r.get::<_, String>(23)?), }; Ok((r.get(19)?, entry)) })? @@ -230,6 +231,51 @@ impl Catalog { 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 = add.iter().filter_map(|t| normalize_tag(t)).collect(); + let remove: Vec = 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 = 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 /// oracle id. fn physical_holdings(&self) -> Result>> { @@ -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 { + let tag: String = tag.split_whitespace().collect::>().join(" "); + let tag: String = tag.chars().take(MAX_TAG_LEN).collect::().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 { + 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) -> String { + serde_json::to_string(&tags.into_iter().collect::>()).expect("strings always serialise") +} + #[cfg(test)] mod tests { use crate::test_support; @@ -978,4 +1046,52 @@ mod tests { assert_eq!(limit("llanowar-elves"), Some(0)); 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"); + } } diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 789b942..1b9523d 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -250,6 +250,10 @@ pub struct DeckEntry { /// [`crate::roles::card_roles`]. Empty when nothing matches. #[serde(default)] pub roles: Vec, + /// 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, } /// A building/built deck's claim on a card's copies. diff --git a/crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql b/crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql new file mode 100644 index 0000000..8db98c0 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql @@ -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 '[]'; diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs index 1726df1..ba347ce 100644 --- a/crates/mtgc-core/src/sync.rs +++ b/crates/mtgc-core/src/sync.rs @@ -57,9 +57,9 @@ 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. -/// Version 3 does the same for deck covers, and 4 for deck statuses and -/// pulled copies. -pub const DOC_VERSION: u32 = 4; +/// Version 3 does the same for deck covers, 4 for deck statuses and pulled +/// copies, and 5 for deck card tags. +pub const DOC_VERSION: u32 = 5; /// The format of a document with no printing data in it. const BASE_VERSION: u32 = 1; @@ -90,6 +90,9 @@ pub struct DeckCardEntry { /// Copies physically in the deck (see migration 0008). #[serde(default, skip_serializing_if = "is_zero")] 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, } /// The [`CardEntry::printings`] key for a copy group: `"/"`. @@ -185,10 +188,13 @@ impl LibraryDoc { self.cards.is_empty() && self.decks.is_empty() } - /// The lowest format that can hold this document: 4 once any deck has a - /// status or pulled copies, 3 once any deck has a cover, 2 once it carries + /// The lowest format that can hold this document: 5 once any deck card + /// 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`]. 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 .decks .values() @@ -543,7 +549,7 @@ pub fn read_doc(conn: &Connection) -> Result { let mut stmt = conn.prepare( "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, - quantity_pulled + quantity_pulled, tags FROM deck_cards", )?; let mut rows = stmt.query([])?; @@ -561,6 +567,7 @@ pub fn read_doc(conn: &Connection) -> Result { maybeboard: row.get::<_, i64>(4)? != 0, printing: row.get(5)?, 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 { tx.execute( "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, - preferred_scryfall_id, quantity_pulled) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + preferred_scryfall_id, quantity_pulled, tags) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander, is_maybeboard = excluded.is_maybeboard, preferred_scryfall_id = excluded.preferred_scryfall_id, - quantity_pulled = excluded.quantity_pulled", + quantity_pulled = excluded.quantity_pulled, + tags = excluded.tags", rusqlite::params![ deck_id, oracle_id, @@ -666,7 +674,8 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { card.commander as i64, card.maybeboard as i64, card.printing, - card.pulled + card.pulled, + crate::decks::tags_json(&card.tags) ], )?; } @@ -1021,6 +1030,19 @@ mod tests { 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] fn deck_status_merges_like_other_deck_fields() { let base = doc(&[], &[("u1", "Fub", &[])]); @@ -1072,8 +1094,11 @@ mod tests { c.set_location("card-a", "Box 1").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_tags(deck.id, &["card-a"], &["ramp", "to remove"], &[]).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().status, DeckStatus::Building); assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1); diff --git a/crates/mtgc-server/tests/api.rs b/crates/mtgc-server/tests/api.rs index 330cc1f..c0a334a 100644 --- a/crates/mtgc-server/tests/api.rs +++ b/crates/mtgc-server/tests/api.rs @@ -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); } +#[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] async fn each_user_has_their_own_library() { let s = Server::local(); diff --git a/docs/deck-tags-design.md b/docs/deck-tags-design.md index c092c66..ba76667 100644 --- a/docs/deck-tags-design.md +++ b/docs/deck-tags-design.md @@ -1,6 +1,6 @@ # Design: deck card tags -Status: **proposed — not implemented.** +Status: **implemented.** ## Context @@ -132,8 +132,10 @@ v2–v4 followed the same rule. - `toggleSelectAllCards` works on the shown cards (main board and maybeboard, after filtering). - **[`components.tsx`](../src/components.tsx):** - - `PreviewContext` gains `tags?`, `tagSuggestions?` and `onTagsChange?`. - - The preview's deck section renders a `TagInput` when they're provided. + - `PreviewContext` gains `tagSuggestions?`, and `PreviewCard` gains + `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. - **[`styles.css`](../src/styles.css):** tag chips (clickable look, and an active state while filtering), tile tag badges. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ffcac1f..e86d61f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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()) } +/// 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, + add: Vec, + remove: Vec, +) -> 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] pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4454743..0dbad53 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,7 @@ pub fn run() { commands::set_deck_cover, commands::set_deck_status, commands::set_deck_card_pulled, + commands::set_deck_card_tags, commands::take_deck_card_copy, commands::set_deck_commander, commands::set_deck_commanders, diff --git a/src/api.ts b/src/api.ts index c9e13ae..dbfdf2d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -68,7 +68,8 @@ export interface DeckEntry extends CardRef { copy_limit: number | null; /** What the card does in a deck ("Removal", "Mill", …), read from its * 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 { @@ -131,6 +132,8 @@ export type PreviewCard = CardRef & { quantity_in_decks?: number; location?: string; 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; /** That deck's chosen cover card, so the preview can offer to change it. */ 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 { @@ -556,6 +562,9 @@ export const api = { /** Record copies physically in a deck; returns the (clamped) count stored. */ setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) => invoke("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("set_deck_card_tags", { deckId, oracleIds, add, remove }), /** Move one pulled copy between decks; false if there was nothing to move. */ takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) => invoke("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }), diff --git a/src/components.tsx b/src/components.tsx index b91ad0f..b9bd292 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1117,6 +1117,7 @@ function PrintingsSection({ const [error, setError] = useState(null); // Tracked here: `ctx` is a snapshot from when the preview opened. const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id); + const [tags, setTags] = useState(card.tags ?? []); useEffect(() => { let live = true; @@ -1173,6 +1174,15 @@ function PrintingsSection({ 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) { const mode = picking; setPicking(null); @@ -1219,6 +1229,17 @@ function PrintingsSection({ )} + {ctx.tagSuggestions && ( +
+ Tags + +
+ )} )}
diff --git a/src/styles.css b/src/styles.css index f232b67..9fe0c9f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2094,6 +2094,65 @@ button.pull-action { max-height: 220px; 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 diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 2c1915d..9c9d73e 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -39,6 +39,7 @@ import { SortDir, SortMode, StatTile, + TagInput, TileColumnsControl, ViewToggle, groupCards, @@ -80,7 +81,7 @@ function boardToggleProps(e: DeckEntry) { : { 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. const DECK_COLUMNS: ColumnDef[] = [ @@ -91,6 +92,7 @@ const DECK_COLUMNS: ColumnDef[] = [ { id: "owned", label: "Owned" }, { id: "price", label: "Price" }, { id: "where", label: "Where" }, + { id: "tags", label: "Tags" }, { 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 ( + + {tags.map((t) => { + const active = !filter.untagged && filter.tags.some((f) => f.toLowerCase() === t.toLowerCase()); + return ( + + ); + })} + + ); +} + +/** 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; + onClose: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const counts = new Map(); + 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 ( + + {used.length > 0 && ( +
    + {used.map(({ tag, n }) => ( +
  • + {tag} + + {n} of {entries.length} + + + + +
  • + ))} +
+ )} + + {error &&

{error}

} +
+ +
+
+ ); +} + /** 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. */ function AdvancedFilterDialog({ state, + tagFilter, + onTagFilterChange, + deckTags, matched, total, error, onClose, }: { 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; total: number; error: string | null; @@ -344,15 +483,62 @@ function AdvancedFilterDialog({ useEffect(() => { 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 ( +
+ Tags + {deckTags.length === 0 ? ( + No cards in this deck are tagged yet. + ) : ( + <> +
+ onTagFilterChange({ ...tagFilter, tags: known(tags) })} + suggestions={deckTags} + placeholder="Filter by tag…" + /> +
+
+ + +
+ + )} +
{error &&

{error}

}
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`} - + {physical && ( <>