Filtering by category alone wasn't granular enough: a deck usually matches several archetypes and tribes at once, so "show me Archetype suggestions" still returns most of the list. SynergyReason now carries the matched thing itself (`subject`) alongside the human-readable `detail`, populated at all four match sites. The modal adds a second chip row listing every subject present in the current suggestions, sorted by how many cards each explains and labelled with that count, so the most useful narrowing is obvious before clicking.
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
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());
|
||
// 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());
|
||
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;
|
||
});
|
||
// 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;
|
||
});
|
||
}
|
||
|
||
const availableCategories = suggestions
|
||
? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat)))
|
||
: [];
|
||
/** 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)),
|
||
),
|
||
);
|
||
|
||
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());
|
||
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())}
|
||
>
|
||
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>
|
||
);
|
||
}
|