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

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