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/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.
|
||||
|
||||
@@ -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<Deck> {
|
||||
@@ -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<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
|
||||
/// oracle id.
|
||||
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)]
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ pub struct DeckEntry {
|
||||
/// [`crate::roles::card_roles`]. Empty when nothing matches.
|
||||
#[serde(default)]
|
||||
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.
|
||||
|
||||
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
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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<LibraryDoc> {
|
||||
|
||||
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<LibraryDoc> {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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]
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
src/api.ts
11
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<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. */
|
||||
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
|
||||
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
|
||||
|
||||
@@ -1117,6 +1117,7 @@ function PrintingsSection({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Tracked here: `ctx` is a snapshot from when the preview opened.
|
||||
const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id);
|
||||
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({
|
||||
)}
|
||||
</span>
|
||||
</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 className="preview-info-section">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DeckColumn>[] = [
|
||||
@@ -91,6 +92,7 @@ const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
||||
{ 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 (
|
||||
<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.
|
||||
* 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 (
|
||||
<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 />
|
||||
{error && <p className="sync-bad">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<span className="muted" style={{ marginRight: "auto" }}>
|
||||
{matched === null ? `All ${total} cards shown` : `${matched} of ${total} cards match`}
|
||||
</span>
|
||||
<button onClick={state.reset} disabled={!state.active}>
|
||||
<button
|
||||
onClick={() => {
|
||||
state.reset();
|
||||
onTagFilterChange(NO_TAG_FILTER);
|
||||
}}
|
||||
disabled={!state.active && !tagFilterActive(tagFilter)}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
<button className="primary" onClick={onClose}>
|
||||
@@ -412,6 +598,8 @@ function DeckBuilder({
|
||||
const [advOpen, setAdvOpen] = useState(false);
|
||||
const [advMatches, setAdvMatches] = useState<Set<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 [libResults, setLibResults] = useState<CardRef[]>([]);
|
||||
@@ -516,12 +704,6 @@ function DeckBuilder({
|
||||
});
|
||||
}, [contents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
|
||||
}
|
||||
}, [selectedCards, contents]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = libQuery.trim();
|
||||
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() {
|
||||
setPartialRemoval(new Map());
|
||||
setSelectedCards((s) =>
|
||||
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
||||
);
|
||||
setSelectedCards(allShownSelected ? new Set() : new Set(shownCards.map((e) => e.oracle_id)));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
await api.setDeckFormat(deckId, format);
|
||||
await onDecksChanged();
|
||||
@@ -863,6 +1062,7 @@ function DeckBuilder({
|
||||
// the library/reference db to find NEW cards to add).
|
||||
function matchesFilter(e: DeckEntry): boolean {
|
||||
if (advMatches && !advMatches.has(e.oracle_id)) return false;
|
||||
if (!matchesTagFilter(e, tagFilter)) return false;
|
||||
const q = fold(filterQuery.trim());
|
||||
if (!q) return true;
|
||||
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 leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
|
||||
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.
|
||||
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
||||
const commanders = contents
|
||||
@@ -957,6 +1178,7 @@ function DeckBuilder({
|
||||
const preview: PreviewContext = {
|
||||
deckId,
|
||||
coverOracleId: deck.cover_oracle_id,
|
||||
tagSuggestions: deckTags,
|
||||
onChanged: () => {
|
||||
loadContents();
|
||||
onDecksChanged();
|
||||
@@ -1067,12 +1289,17 @@ function DeckBuilder({
|
||||
</span>
|
||||
}
|
||||
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" }}>
|
||||
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
||||
{partialRemoval.has(e.oracle_id) && (
|
||||
<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)}
|
||||
</span>
|
||||
) : undefined
|
||||
@@ -1097,6 +1324,7 @@ function DeckBuilder({
|
||||
{columns.shown("owned") && <th>Owned</th>}
|
||||
{columns.shown("price") && <th>Price</th>}
|
||||
{columns.shown("where") && <th>Where</th>}
|
||||
{columns.shown("tags") && <th>Tags</th>}
|
||||
{columns.shown("actions") && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1156,6 +1384,11 @@ function DeckBuilder({
|
||||
</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") && (
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
@@ -1399,13 +1632,13 @@ function DeckBuilder({
|
||||
<hr className="deck-section-divider" />
|
||||
|
||||
<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
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={contents.length > 0 && selectedCards.size === contents.length}
|
||||
checked={allShownSelected}
|
||||
onChange={toggleSelectAllCards}
|
||||
disabled={contents.length === 0}
|
||||
disabled={shownCards.length === 0}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Select all
|
||||
@@ -1443,7 +1676,7 @@ function DeckBuilder({
|
||||
filter={filterQuery}
|
||||
onFilterChange={setFilterQuery}
|
||||
onAdvanced={() => setAdvOpen(true)}
|
||||
advancedActive={advFilter.active}
|
||||
advancedActive={advFilter.active || tagsOn}
|
||||
/>
|
||||
|
||||
{selectedCards.size > 0 && (
|
||||
@@ -1463,6 +1696,9 @@ function DeckBuilder({
|
||||
>
|
||||
Add to library
|
||||
</button>
|
||||
<button onClick={() => setTagDialogOpen(true)} disabled={bulkBusy}>
|
||||
Tag…
|
||||
</button>
|
||||
{physical && (
|
||||
<>
|
||||
<button
|
||||
@@ -1617,12 +1853,23 @@ function DeckBuilder({
|
||||
{advOpen && (
|
||||
<AdvancedFilterDialog
|
||||
state={advFilter}
|
||||
matched={advMatches?.size ?? null}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterChange={setTagFilter}
|
||||
deckTags={deckTags}
|
||||
matched={advMatchedCount}
|
||||
total={contents.length}
|
||||
error={advError}
|
||||
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 && (
|
||||
<SampleHandModal
|
||||
hand={hand}
|
||||
|
||||
Reference in New Issue
Block a user