2026-07-21 21:04:36 -05:00
|
|
|
|
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());
|
2026-08-14 01:02:21 -05:00
|
|
|
|
// Second level: the specific tribe/keyword/archetype. Knowing a suggestion is
|
|
|
|
|
|
// an "Archetype" hit is rarely enough — a deck usually has several and you
|
|
|
|
|
|
// want one of them.
|
|
|
|
|
|
const [activeSubjects, setActiveSubjects] = useState<Set<string>>(new Set());
|
2026-07-21 21:04:36 -05:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-08-14 01:02:21 -05:00
|
|
|
|
// Subjects belong to categories, so narrowing the categories can strand a
|
|
|
|
|
|
// selected subject that's no longer offered — clear rather than leave an
|
|
|
|
|
|
// invisible filter applied.
|
|
|
|
|
|
setActiveSubjects(new Set());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleSubject(subject: string) {
|
|
|
|
|
|
setActiveSubjects((cur) => {
|
|
|
|
|
|
const next = new Set(cur);
|
|
|
|
|
|
if (next.has(subject)) next.delete(subject);
|
|
|
|
|
|
else next.add(subject);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
2026-07-21 21:04:36 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const availableCategories = suggestions
|
|
|
|
|
|
? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat)))
|
|
|
|
|
|
: [];
|
2026-08-14 01:02:21 -05:00
|
|
|
|
/** Reasons still in play once the category chips are applied. */
|
|
|
|
|
|
const inCategory = (r: { category: string }) =>
|
|
|
|
|
|
activeCategories.size === 0 || activeCategories.has(r.category);
|
|
|
|
|
|
|
|
|
|
|
|
// Offered subjects follow the category selection, so the second row is always
|
|
|
|
|
|
// a drill-down into the first rather than an unrelated list. Ordered by how
|
|
|
|
|
|
// many suggestions each explains, so the most useful narrowing comes first.
|
|
|
|
|
|
const subjectCounts = new Map<string, { count: number; category: string }>();
|
|
|
|
|
|
for (const s of suggestions ?? []) {
|
|
|
|
|
|
for (const r of s.reasons) {
|
|
|
|
|
|
if (!inCategory(r) || !r.subject) continue;
|
|
|
|
|
|
const prev = subjectCounts.get(r.subject);
|
|
|
|
|
|
subjectCounts.set(r.subject, {
|
|
|
|
|
|
count: (prev?.count ?? 0) + 1,
|
|
|
|
|
|
category: prev?.category ?? r.category,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const availableSubjects = [...subjectCounts.entries()]
|
|
|
|
|
|
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
|
|
|
|
|
|
.map(([subject, meta]) => ({ subject, ...meta }));
|
|
|
|
|
|
|
|
|
|
|
|
const visible = suggestions?.filter((s) =>
|
|
|
|
|
|
s.reasons.some(
|
|
|
|
|
|
(r) =>
|
|
|
|
|
|
inCategory(r) && (activeSubjects.size === 0 || activeSubjects.has(r.subject)),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-07-21 21:04:36 -05:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-08-14 01:02:21 -05:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveCategories(new Set());
|
|
|
|
|
|
setActiveSubjects(new Set());
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{availableSubjects.length > 1 && (
|
|
|
|
|
|
<div className="suggestion-filters">
|
|
|
|
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
|
|
|
|
Narrow to:
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{availableSubjects.map(({ subject, count, category }) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={subject}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`filter-chip filter-chip-${categorySlug(category)} ${
|
|
|
|
|
|
activeSubjects.has(subject) ? "active" : ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => toggleSubject(subject)}
|
|
|
|
|
|
title={`${category} · ${count} suggestion${count === 1 ? "" : "s"}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{subject} <span className="filter-chip-count">{count}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{activeSubjects.size > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="filter-chip-clear"
|
|
|
|
|
|
onClick={() => setActiveSubjects(new Set())}
|
2026-07-21 21:04:36 -05:00
|
|
|
|
>
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|