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