Add deck synergy suggestions, a responsive deck layout, and undoable library bulk-add
Suggestion scoring is fully offline (tribal/mechanic/archetype/mana-base signals derived at ingest, no external synergy database), with an "unusual picks" mode that surfaces underappreciated cards instead of staples. The deck page's top panels now use a CSS grid so wide windows don't waste horizontal space. Library's bulk add-to-deck can clobber existing deck quantities, so it now offers an undo (snackbar + command palette, the latter with no timeout) that restores each card's prior state exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
import { SuggestionsModal } from "../SuggestionsModal";
|
||||
|
||||
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
|
||||
function boardToggleProps(e: DeckEntry) {
|
||||
@@ -327,8 +328,14 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
|
||||
* groupable by card attribute; "Combine sections" merges these two into one
|
||||
* list. The maybeboard is always shown as its own separate section. Deck-level
|
||||
* format + commander metadata sit at the top.
|
||||
*
|
||||
* `refreshSignal` is bumped by `App` when something outside this view's own
|
||||
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
|
||||
* from the command palette while this view is already mounted — so the
|
||||
* card list, mana curve, and legality tile recompute immediately instead of
|
||||
* only on the next mount.
|
||||
*/
|
||||
export function DeckView() {
|
||||
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
|
||||
const [newName, setNewName] = useState("");
|
||||
@@ -336,6 +343,7 @@ export function DeckView() {
|
||||
const [view, setView] = useViewMode("deck-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [dialog, setDialog] = useState<DeckDialog>(null);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
|
||||
@@ -357,6 +365,7 @@ export function DeckView() {
|
||||
const [handOpen, setHandOpen] = useState(false);
|
||||
const [hand, setHand] = useState<DeckEntry[]>([]);
|
||||
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
|
||||
const deck = decks.find((d) => d.id === selected) ?? null;
|
||||
|
||||
@@ -387,8 +396,22 @@ export function DeckView() {
|
||||
setHandOpen(false);
|
||||
setHand([]);
|
||||
setLibraryStack([]);
|
||||
setFilterQuery("");
|
||||
}, [loadContents]);
|
||||
|
||||
// Refetch on an external refresh signal without resetting the UI state
|
||||
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
||||
// selection, or open sample hand). Skips the first render so it doesn't
|
||||
// duplicate the mount-time fetch above.
|
||||
const skipFirstRefresh = useRef(true);
|
||||
useEffect(() => {
|
||||
if (skipFirstRefresh.current) {
|
||||
skipFirstRefresh.current = false;
|
||||
return;
|
||||
}
|
||||
loadContents();
|
||||
}, [refreshSignal]);
|
||||
|
||||
// Cancel any in-flight removal timers on unmount so they don't fire against
|
||||
// an unmounted component.
|
||||
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
||||
@@ -671,9 +694,18 @@ export function DeckView() {
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0);
|
||||
const maybeboard = contents.filter((e) => e.is_maybeboard);
|
||||
// Quick-search over cards already in the deck/maybeboard — a display
|
||||
// filter only, distinct from the CardSearchBox boxes below (which search
|
||||
// the library/reference db to find NEW cards to add).
|
||||
function matchesFilter(e: DeckEntry): boolean {
|
||||
const q = filterQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return e.name.toLowerCase().includes(q) || e.type_line.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
|
||||
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
|
||||
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
||||
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
|
||||
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
||||
@@ -863,6 +895,13 @@ export function DeckView() {
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setSuggestOpen(true)}
|
||||
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
|
||||
>
|
||||
{Icons.cards}
|
||||
Suggest cards
|
||||
</button>
|
||||
<button onClick={() => exportDeck(deck)}>
|
||||
{Icons.download}
|
||||
Export deck
|
||||
@@ -877,111 +916,121 @@ export function DeckView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
)}
|
||||
<div className="deck-top-grid">
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<div className="deck-top-commander">
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span className="muted">Format</span>
|
||||
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f || "—"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-top-meta">
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span className="muted">Format</span>
|
||||
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f || "—"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||
<CardSearchBox
|
||||
placeholder={
|
||||
commanderEntry
|
||||
? "Search to change the commander…"
|
||||
: "Search to set the commander…"
|
||||
}
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
onPick={setCommander}
|
||||
/>
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||
<CardSearchBox
|
||||
placeholder={
|
||||
commanderEntry
|
||||
? "Search to change the commander…"
|
||||
: "Search to set the commander…"
|
||||
}
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
onPick={setCommander}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contents.length > 0 && (
|
||||
<div className="deck-top-stats">
|
||||
<div className="deck-stats">
|
||||
<div className="deck-stats-head">Deck analysis</div>
|
||||
<div className="deck-stats-body">
|
||||
<div className="deck-stats-numbers">
|
||||
<div className="stat-tiles-row">
|
||||
<StatTile value={deckStats.totalCards} label="cards" />
|
||||
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||
<StatTile value={deckStats.landCount} label="lands" />
|
||||
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||
</div>
|
||||
<div className="deck-stats-curve-label">Color identity</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.colorCounts}
|
||||
colorlessCount={deckStats.colorlessCount}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana production</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaProduction}
|
||||
colorlessCount={deckStats.colorlessProduction}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaConsumption}
|
||||
colorlessCount={deckStats.colorlessConsumption}
|
||||
/>
|
||||
</div>
|
||||
<div className="deck-stats-curve">
|
||||
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||
<ManaCurve buckets={deckStats.curve} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deck.format !== "Other" && (
|
||||
<div className="deck-top-legality">
|
||||
<div className={`legality-tile ${deckLegality.status}`}>
|
||||
<div className="legality-tile-head">
|
||||
<span className="legality-tile-icon">
|
||||
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
||||
</span>
|
||||
<div className="legality-tile-info">
|
||||
<span className="legality-tile-status">
|
||||
{deckLegality.status === "legal"
|
||||
? "Legal"
|
||||
: deckLegality.status === "illegal"
|
||||
? "Illegal"
|
||||
: "Not checked"}
|
||||
</span>
|
||||
<span className="legality-tile-detail">
|
||||
{deckLegality.status === "legal"
|
||||
? `Meets all ${deck.format} deck-building rules.`
|
||||
: deckLegality.status === "illegal"
|
||||
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
||||
: deck.format
|
||||
? `Legality isn't checked for "${deck.format}".`
|
||||
: "Set a format to check legality."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{deckLegality.status === "illegal" && (
|
||||
<ul className="legality-tile-reasons">
|
||||
{deckLegality.reasons.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contents.length > 0 && (
|
||||
<div className="deck-stats">
|
||||
<div className="deck-stats-head">Deck analysis</div>
|
||||
<div className="deck-stats-body">
|
||||
<div className="deck-stats-numbers">
|
||||
<div className="stat-tiles-row">
|
||||
<StatTile value={deckStats.totalCards} label="cards" />
|
||||
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||
<StatTile value={deckStats.landCount} label="lands" />
|
||||
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||
</div>
|
||||
<div className="deck-stats-curve-label">Color identity</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.colorCounts}
|
||||
colorlessCount={deckStats.colorlessCount}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana production</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaProduction}
|
||||
colorlessCount={deckStats.colorlessProduction}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaConsumption}
|
||||
colorlessCount={deckStats.colorlessConsumption}
|
||||
/>
|
||||
</div>
|
||||
<div className="deck-stats-curve">
|
||||
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||
<ManaCurve buckets={deckStats.curve} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deck.format !== "Other" && (
|
||||
<div className={`legality-tile ${deckLegality.status}`}>
|
||||
<div className="legality-tile-head">
|
||||
<span className="legality-tile-icon">
|
||||
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
||||
</span>
|
||||
<div className="legality-tile-info">
|
||||
<span className="legality-tile-status">
|
||||
{deckLegality.status === "legal"
|
||||
? "Legal"
|
||||
: deckLegality.status === "illegal"
|
||||
? "Illegal"
|
||||
: "Not checked"}
|
||||
</span>
|
||||
<span className="legality-tile-detail">
|
||||
{deckLegality.status === "legal"
|
||||
? `Meets all ${deck.format} deck-building rules.`
|
||||
: deckLegality.status === "illegal"
|
||||
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
||||
: deck.format
|
||||
? `Legality isn't checked for "${deck.format}".`
|
||||
: "Set a format to check legality."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{deckLegality.status === "illegal" && (
|
||||
<ul className="legality-tile-reasons">
|
||||
{deckLegality.reasons.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="deck-section-divider" />
|
||||
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
@@ -1007,6 +1056,12 @@ export function DeckView() {
|
||||
Combine sections
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
placeholder="Filter cards in deck…"
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span className="spacer" />
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
@@ -1157,6 +1212,13 @@ export function DeckView() {
|
||||
onClose={() => setHandOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{suggestOpen && deck && (
|
||||
<SuggestionsModal
|
||||
deckId={deck.id}
|
||||
onClose={() => setSuggestOpen(false)}
|
||||
onCardAdded={loadContents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,19 @@ const REMOVAL_GRACE_MS = 5000;
|
||||
|
||||
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
||||
|
||||
/** Browse & filter the owned library by name and card criteria. */
|
||||
export function LibraryBrowser() {
|
||||
/**
|
||||
* Browse & filter the owned library by name and card criteria.
|
||||
*
|
||||
* `onOfferUndo` is threaded in from `App` (rather than owning an `UndoBar`
|
||||
* here) so the resulting "Undo" is also reachable from the command palette,
|
||||
* which is mounted at the app level and outlives this view across tab
|
||||
* switches.
|
||||
*/
|
||||
export function LibraryBrowser({
|
||||
onOfferUndo,
|
||||
}: {
|
||||
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [commander, setCommander] = useState(0);
|
||||
const [colorsInclude, setColorsInclude] = useState(0);
|
||||
@@ -178,6 +189,12 @@ export function LibraryBrowser() {
|
||||
* cards already in the deck rather than skipping them, so it doubles as a
|
||||
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
|
||||
* deck's maybeboard instead of the mainboard.
|
||||
*
|
||||
* Since this overwrites rather than skips, it can clobber an existing
|
||||
* in-deck quantity/board — so it snapshots each target's prior deck_cards
|
||||
* row (or absence of one) first and offers an "Undo" that restores exactly
|
||||
* that prior state (deleting the row again for cards that weren't in the
|
||||
* deck before).
|
||||
*/
|
||||
async function bulkAddToDeck(mode: "one" | "all") {
|
||||
if (bulkDeckId === "") return;
|
||||
@@ -186,6 +203,8 @@ export function LibraryBrowser() {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||
const before = await api.deckContents(deck.id);
|
||||
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
|
||||
await Promise.all(
|
||||
targets.map((r) =>
|
||||
api.setDeckCard(
|
||||
@@ -198,6 +217,24 @@ export function LibraryBrowser() {
|
||||
),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onOfferUndo(
|
||||
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
|
||||
async () => {
|
||||
await Promise.all(
|
||||
targets.map((r) => {
|
||||
const prev = prevByOracle.get(r.oracle_id);
|
||||
return api.setDeckCard(
|
||||
deck.id,
|
||||
r.oracle_id,
|
||||
prev?.quantity ?? 0,
|
||||
prev?.is_commander ?? false,
|
||||
prev?.is_maybeboard ?? false,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user