Files
mtgc/src/SuggestionsModal.tsx

307 lines
11 KiB
TypeScript
Raw Normal View History

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