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 && ( <>