233 lines
8.5 KiB
TypeScript
233 lines
8.5 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());
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|