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:
2026-07-21 21:04:36 -05:00
parent 008ab33723
commit 3c625a9830
14 changed files with 1596 additions and 117 deletions

View File

@@ -8,6 +8,7 @@ import {
IconButton,
Icons,
StatTile,
UndoBar,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
@@ -133,6 +134,12 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
// This only controls the snackbar's visibility — the underlying undo action
// stays available in the command palette well past this (see `undo` below),
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
@@ -140,6 +147,44 @@ export default function App() {
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
// so an undo that's missed in the snackbar is still reachable via Alt+/.
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<void> } | null>(
null,
);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Bumped once an undo actually runs, so views mounted elsewhere than where
// the undo was offered (e.g. the Decks tab, when the undo came from a
// Library bulk add-to-deck) know to refetch instead of showing stale data
// until the user leaves and comes back.
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
function offerUndo(message: string, run: () => void | Promise<void>) {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setUndo({ message, run });
setSnackbarVisible(true);
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
}
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
function dismissUndo() {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
}
function runUndo() {
if (!undo) return;
const { run } = undo;
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
}
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
@@ -393,18 +438,23 @@ export default function App() {
) : tab === "add" ? (
<CardEntry />
) : tab === "library" ? (
<LibraryBrowser />
<LibraryBrowser onOfferUndo={offerUndo} />
) : (
<DeckView />
<DeckView refreshSignal={deckRefreshSignal} />
)}
</div>
</div>
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
)}
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
);

View File

@@ -30,18 +30,25 @@ function subsequenceMatch(query: string, text: string): boolean {
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
* selecting a card opens the existing click-to-zoom preview modal without
* changing tabs.
* changing tabs. When an undoable action's grace window hasn't lapsed yet,
* an "Undo: …" entry is listed first, so Alt+/ then Enter is a fast way to
* undo without hunting for the floating snackbar's button.
*/
export function CommandPalette({
onNavigate,
onUpdateCardData,
onOpenLibrary,
onNewLibrary,
undo,
onRunUndo,
}: {
onNavigate: (tab: Tab) => void;
onUpdateCardData: () => void;
onOpenLibrary: () => void;
onNewLibrary: () => void;
/** The most recent undoable action, if its window hasn't lapsed yet. */
undo: { message: string; run: () => void | Promise<void> } | null;
onRunUndo: () => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -83,6 +90,7 @@ export function CommandPalette({
}
const staticCommands: PaletteItem[] = [
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },

232
src/SuggestionsModal.tsx Normal file
View File

@@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { api, CardSuggestion } from "./api";
import { CardThumb, ColorPips, DialogFrame } from "./components";
// Matches the backend's hardcoded per-page limit (src-tauri/src/commands.rs).
// A batch shorter than this means there's nothing left to fetch.
const PAGE_SIZE = 50;
// Fixed display order, matching the four synergy families the backend scores.
const CATEGORY_ORDER = ["Tribal", "Mechanic", "Archetype", "Mana base"];
function categorySlug(category: string): string {
return category.toLowerCase().replace(/[^a-z]+/g, "-");
}
/**
* On-demand modal listing scored card suggestions for a deck, each with an
* explainable synergy breakdown and "Add to deck"/"Add to maybeboard"
* actions. Fetches a page on open; toggleable chips filter which reason
* categories must be present (client-side, over everything loaded so far);
* "Suggest more" fetches and appends the next page. Adding a card removes
* its row in place so several can be added in one sitting. An "Unusual
* picks" toggle re-fetches from the backend restricted to (and reordered
* toward) cards that aren't widely played, surfacing underappreciated
* synergy hits instead of the usual staples — that reordering only makes
* sense server-side, before pagination slices the result, so toggling it
* resets and re-fetches rather than filtering what's already loaded.
*/
export function SuggestionsModal({
deckId,
onClose,
onCardAdded,
}: {
deckId: number;
onClose: () => void;
onCardAdded: () => void;
}) {
const [suggestions, setSuggestions] = useState<CardSuggestion[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<Set<string>>(new Set());
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [activeCategories, setActiveCategories] = useState<Set<string>>(new Set());
const [unusual, setUnusual] = useState(false);
useEffect(() => {
let cancelled = false;
setSuggestions(null);
setError(null);
api
.suggestCards(deckId, 0, unusual)
.then((s) => {
if (cancelled) return;
setSuggestions(s);
setHasMore(s.length === PAGE_SIZE);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, [deckId, unusual]);
async function loadMore() {
if (!suggestions) return;
setLoadingMore(true);
try {
const more = await api.suggestCards(deckId, suggestions.length, unusual);
setSuggestions((cur) => {
const seen = new Set((cur ?? []).map((s) => s.card.oracle_id));
return [...(cur ?? []), ...more.filter((s) => !seen.has(s.card.oracle_id))];
});
setHasMore(more.length === PAGE_SIZE);
} finally {
setLoadingMore(false);
}
}
async function add(s: CardSuggestion, maybeboard: boolean) {
const oid = s.card.oracle_id;
setBusy((b) => new Set(b).add(oid));
try {
await api.setDeckCard(deckId, oid, 1, false, maybeboard);
onCardAdded();
setSuggestions((cur) => (cur ? cur.filter((x) => x.card.oracle_id !== oid) : cur));
} finally {
setBusy((b) => {
const next = new Set(b);
next.delete(oid);
return next;
});
}
}
function toggleCategory(cat: string) {
setActiveCategories((cur) => {
const next = new Set(cur);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
}
const availableCategories = suggestions
? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat)))
: [];
const visible =
suggestions && activeCategories.size > 0
? suggestions.filter((s) => s.reasons.some((r) => activeCategories.has(r.category)))
: suggestions;
return (
<DialogFrame title="Suggested cards" onCancel={onClose} wide>
<div className="row" style={{ gap: 8, marginBottom: 12 }}>
<button
type="button"
className={`filter-chip ${unusual ? "active" : ""}`}
onClick={() => setUnusual((u) => !u)}
title="Only show cards that fit your deck's strategy but aren't widely played — cool, underappreciated picks."
>
✦ Unusual picks
</button>
</div>
{error ? (
<p className="hint">{error}</p>
) : suggestions === null ? (
<p className="hint">Analyzing your deck…</p>
) : suggestions.length === 0 ? (
<p className="hint">
{unusual
? "No unusual picks found — try turning off \"Unusual picks\" to see the widely-played suggestions too."
: "No suggestions yet — add a few cards (and a commander, if it's a Commander deck) so there's a strategy to build on."}
</p>
) : (
<>
{availableCategories.length > 1 && (
<div className="suggestion-filters">
<span className="muted" style={{ fontSize: 12 }}>
Filter by why:
</span>
{availableCategories.map((cat) => (
<button
key={cat}
type="button"
className={`filter-chip filter-chip-${categorySlug(cat)} ${
activeCategories.has(cat) ? "active" : ""
}`}
onClick={() => toggleCategory(cat)}
>
{cat}
</button>
))}
{activeCategories.size > 0 && (
<button
type="button"
className="filter-chip-clear"
onClick={() => setActiveCategories(new Set())}
>
Clear
</button>
)}
</div>
)}
{visible && visible.length === 0 ? (
<p className="hint">No suggestions match the selected filters.</p>
) : (
<ul className="result-list suggestion-list">
{visible?.map((s) => (
<li key={s.card.oracle_id}>
<div className="suggestion-row">
<CardThumb card={s.card} />
<div className="suggestion-main">
<div className="suggestion-head">
<ColorPips mask={s.card.color_identity} />
<span className="card-name">{s.card.name}</span>
{s.quantity_owned > 0 ? (
<span className="badge">Owned ×{s.quantity_owned}</span>
) : (
<span className="muted" style={{ fontSize: 12 }}>
Not owned
</span>
)}
{s.edhrec_rank !== null && s.edhrec_rank < 2000 && (
<span className="muted" style={{ fontSize: 12 }}>
★ Popular
</span>
)}
<span className="spacer" />
<span className="suggestion-score">{s.score.toFixed(0)}</span>
</div>
<div className="suggestion-reasons">
{s.reasons.map((r, i) => (
<span key={i} className={`reason-chip reason-chip-${categorySlug(r.category)}`}>
<span className="reason-chip-category">{r.category}</span>
{r.detail}
</span>
))}
</div>
</div>
<div className="suggestion-actions">
<button onClick={() => add(s, false)} disabled={busy.has(s.card.oracle_id)}>
Add to deck
</button>
<button onClick={() => add(s, true)} disabled={busy.has(s.card.oracle_id)}>
Add to maybeboard
</button>
</div>
</div>
</li>
))}
</ul>
)}
{hasMore && (
<div className="row" style={{ justifyContent: "center", marginTop: 12 }}>
<button onClick={loadMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Suggest more"}
</button>
</div>
)}
</>
)}
<div className="dialog-actions">
<button className="primary" onClick={onClose} autoFocus>
Close
</button>
</div>
</DialogFrame>
);
}

View File

@@ -52,6 +52,19 @@ export interface LibraryImportResult {
unresolved: string[];
}
export interface SynergyReason {
category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base"
detail: string;
}
export interface CardSuggestion {
card: CardRef;
quantity_owned: number;
score: number;
reasons: SynergyReason[];
edhrec_rank: number | null;
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
@@ -186,6 +199,8 @@ export const api = {
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
suggestCards: (deckId: number, offset = 0, unusual = false) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, unusual }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),

View File

@@ -279,6 +279,31 @@ export function InfoDialog({
);
}
/** Floating snackbar offering to reverse the caller's last action. Auto-
* dismisses on a timer managed by the caller (this component is purely
* presentational) — pass `null`/omit rendering it once the window lapses. */
export function UndoBar({
message,
onUndo,
onDismiss,
}: {
message: string;
onUndo: () => void;
onDismiss: () => void;
}) {
return (
<div className="undo-bar">
<span>{message}</span>
<button className="primary" onClick={onUndo}>
Undo
</button>
<button className="undo-bar-close" onClick={onDismiss} title="Dismiss">
×
</button>
</div>
);
}
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
export function SampleHandModal({

View File

@@ -689,7 +689,6 @@ h4 {
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.legality-tile.legal {
background: rgba(75, 176, 115, 0.16);
@@ -1049,6 +1048,135 @@ li:focus-within .row-actions {
.deck-list-actions-row {
justify-content: space-between;
}
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
* synergy reason chips per row, with stacked actions on the right. Denser
* than the default .result-list row (smaller padding/gaps, chips instead of
* a bulleted list) since a card can carry several reasons at once. */
.result-list.suggestion-list li {
cursor: default;
align-items: stretch;
padding: 8px 12px;
}
.suggestion-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.suggestion-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.suggestion-head {
display: flex;
align-items: center;
gap: 6px;
}
.suggestion-score {
font-weight: 700;
color: var(--accent-hover);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.suggestion-reasons {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 999px;
padding: 1px 8px 1px 2px;
font-size: 11px;
line-height: 1.6;
background: var(--bg-elev);
color: var(--muted);
}
.reason-chip-category {
border-radius: 999px;
padding: 1px 7px;
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #fff;
}
.reason-chip-tribal .reason-chip-category {
background: var(--accent);
}
.reason-chip-mechanic .reason-chip-category {
background: var(--mtg-u);
}
.reason-chip-archetype .reason-chip-category {
background: var(--warn);
}
.reason-chip-mana-base .reason-chip-category {
background: var(--success);
}
.suggestion-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.suggestion-actions button {
font-size: 12px;
padding: 5px 10px;
white-space: nowrap;
}
/* Toggleable "filter by why" chips above the suggestion list — same color
* coding as .reason-chip-category, but as clickable, stateful buttons. */
.suggestion-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.filter-chip {
border-radius: 999px;
padding: 3px 12px;
font-size: 12px;
font-weight: 600;
background: var(--bg-elev);
color: var(--muted);
border: 1px solid var(--border-soft);
}
.filter-chip:hover {
background: var(--bg-hover);
}
.filter-chip.active {
color: #fff;
border-color: transparent;
}
.filter-chip-tribal.active {
background: var(--accent);
}
.filter-chip-mechanic.active {
background: var(--mtg-u);
}
.filter-chip-archetype.active {
background: var(--warn);
}
.filter-chip-mana-base.active {
background: var(--success);
}
.filter-chip-clear {
background: none;
border: none;
color: var(--muted);
font-size: 12px;
text-decoration: underline;
padding: 3px 4px;
}
.card-type {
color: var(--muted);
font-size: 13px;
@@ -1176,6 +1304,37 @@ tbody tr:hover {
padding: 8px 4px;
}
/* Floating "Undo" snackbar (e.g. after a bulk add-to-deck). Fixed so it stays
reachable regardless of scroll position; sits below dialogs (z-index 400+)
since it's only ever shown against the current view, not over a modal. */
.undo-bar {
position: fixed;
left: 50%;
bottom: 28px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 10px 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 300;
}
.undo-bar-close {
background: transparent;
border: none;
color: var(--muted);
font-size: 16px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
}
.undo-bar-close:hover {
color: var(--text-strong);
}
/* ============================================================
Filters panel
============================================================ */
@@ -1317,7 +1476,10 @@ tbody tr:hover {
}
.hover-card {
position: fixed;
z-index: 200;
/* Above .dialog-backdrop (400) — cards inside a dialog (e.g. the
suggestions modal) can be hovered for a preview without it appearing
behind the dialog it's in. */
z-index: 450;
border-radius: 14px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -1581,7 +1743,6 @@ tbody tr:hover {
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
/* Separates the deck-level info panels (commander, format, analysis,
legality) from the card-management controls + lists below. */
@@ -1590,6 +1751,48 @@ tbody tr:hover {
border-top: 1px solid var(--border-soft);
margin: 4px 0 18px;
}
/* The four top-of-page info panels (commander, meta, analysis, legality).
Single column matches the existing top-to-bottom reading order on
narrow/portrait viewports; on wide viewports the compact panels move
into a side column so the analysis panel (the one panel that benefits
from extra width) gets the rest of the row instead of everything
stretching out with wasted horizontal space. grid-template-areas (not
flex wrapping) is what lets the two-column arrangement reorder visually
without touching DOM order. */
.deck-top-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"commander"
"meta"
"stats"
"legality";
gap: 16px;
margin-bottom: 16px;
}
.deck-top-commander {
grid-area: commander;
}
.deck-top-meta {
grid-area: meta;
}
.deck-top-stats {
grid-area: stats;
}
.deck-top-legality {
grid-area: legality;
}
@media (min-width: 1100px) {
.deck-top-grid {
grid-template-columns: minmax(360px, 560px) 1fr;
grid-template-areas:
"commander stats"
"meta stats"
"legality stats";
align-items: start;
}
}
.commander-box {
display: flex;
flex-direction: column;
@@ -1607,7 +1810,6 @@ tbody tr:hover {
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.commander-hero-img {
width: 84px;
@@ -1662,7 +1864,6 @@ tbody tr:hover {
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 16px;
}
.deck-stats-head {
font-size: 11px;
@@ -1686,6 +1887,7 @@ tbody tr:hover {
.deck-stats-curve {
flex: 1;
min-width: 220px;
max-width: 420px;
display: flex;
flex-direction: column;
}

View File

@@ -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>
);
}

View File

@@ -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);
}