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:
232
src/SuggestionsModal.tsx
Normal file
232
src/SuggestionsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user