Files
mtgc/src/views/DeckView.tsx

779 lines
26 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
import {
CardSearchBox,
CardThumb,
CardTile,
ColorDistributionBar,
ColorPips,
ConfirmDialog,
GroupBySelect,
GroupMode,
IconButton,
Icons,
ManaCurve,
PromptDialog,
QtyStepper,
StatTile,
ViewToggle,
groupCards,
useClickPreview,
useHoverPreview,
usePersistedState,
useViewMode,
} from "../components";
type DeckDialog =
| { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck }
| { kind: "duplicate"; deck: Deck }
| null;
function friendlyDeckError(err: unknown): string {
const msg = String(err);
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
}
interface DeckStats {
totalCards: number;
uniqueCards: number;
landCount: number;
avgManaValue: number;
curve: {
label: string;
count: number;
colorCounts: [number, number, number, number, number];
colorlessCount: number;
}[];
colorCounts: [number, number, number, number, number];
colorlessCount: number;
manaProduction: [number, number, number, number, number];
colorlessProduction: number;
manaConsumption: [number, number, number, number, number];
colorlessConsumption: number;
}
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) — matches the usual "99 + commander" framing even for
* non-Commander decks, where it's just "everything but the flagged card".
*/
function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
const avgManaValue = nonlandCount
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
: 0;
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
const curveBuckets = new Map(
bucketLabels.map((l) => [
l,
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
]),
);
for (const e of nonlands) {
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
const bucket = curveBuckets.get(label)!;
bucket.count += e.quantity;
if (e.color_identity === 0) {
bucket.colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
});
}
}
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessCount = 0;
for (const e of cards) {
if (e.color_identity === 0) {
colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
});
}
}
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessProduction = 0;
for (const e of cards) {
if (e.produces_colorless) colorlessProduction += e.quantity;
COLORS.forEach((c, i) => {
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
});
}
// Consumption: colored cost requirements of the spells actually cast —
// lands are excluded since they have no colored casting cost.
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessConsumption = 0;
for (const e of nonlands) {
if (e.colors === 0) {
colorlessConsumption += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
});
}
}
return {
totalCards,
uniqueCards: cards.length,
landCount,
avgManaValue,
curve,
colorCounts,
colorlessCount,
manaProduction,
colorlessProduction,
manaConsumption,
colorlessConsumption,
};
}
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
<div className="commander-hero">
{card.image_uri ? (
<img
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card)}
{...handlers(card)}
/>
) : (
<div className="commander-hero-noimg">{card.name}</div>
)}
<div className="commander-hero-info">
<span className="commander-hero-label">Commander</span>
<span className="commander-hero-name">{card.name}</span>
<span className="commander-hero-type">{card.type_line}</span>
<ColorPips mask={card.color_identity} />
</div>
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
</div>
);
}
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
// before actually dropping out of the deck — same grace-period pattern as
// the Library tab, so a mistaken removal can be undone with +.
const REMOVAL_GRACE_MS = 5000;
const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
/**
* Deck editor. A deck's cards are split into two groups — "In your library"
* (owned) and "Not owned" — each with its own search box, and each groupable by
* card attribute. Deck-level format + commander metadata sit at the top.
*/
export function DeckView() {
const [decks, setDecks] = useState<Deck[]>([]);
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
const [newName, setNewName] = useState("");
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState<CardRef[]>([]);
const [anyQuery, setAnyQuery] = useState("");
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const deck = decks.find((d) => d.id === selected) ?? null;
const loadDecks = useCallback(async () => {
const d = await api.listDecks();
setDecks(d);
// Keep the persisted selection if it still exists, else fall back to the first.
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
setSelected(d[0].id);
}
}, [selected, setSelected]);
useEffect(() => {
loadDecks();
}, [loadDecks]);
const loadContents = useCallback(async () => {
if (selected === null) {
setContents([]);
return;
}
setContents(await api.deckContents(selected));
}, [selected]);
useEffect(() => {
loadContents();
setSelectedCards(new Set());
}, [loadContents]);
// Cancel any in-flight removal timers on unmount so they don't fire against
// an unmounted component.
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
// Keep selection in sync with what's actually visible — drop ids that fall
// out of the deck (deck switched, or a card's qty hit 0 and its grace
// period expired).
useEffect(() => {
setSelectedCards((s) => {
const visible = new Set(contents.map((e) => e.oracle_id));
const next = new Set([...s].filter((id) => visible.has(id)));
return next.size === s.size ? s : next;
});
}, [contents]);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
}
}, [selectedCards, contents]);
useEffect(() => {
const q = libQuery.trim();
if (!q) return setLibResults([]);
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
return () => clearTimeout(t);
}, [libQuery]);
useEffect(() => {
const q = anyQuery.trim();
if (!q) return setAnyResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
return () => clearTimeout(t);
}, [anyQuery]);
useEffect(() => {
const q = cmdQuery.trim();
if (!q) return setCmdResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
return () => clearTimeout(t);
}, [cmdQuery]);
async function createDeck() {
if (!newName.trim()) return;
const d = await api.createDeck(newName.trim(), null);
setNewName("");
await loadDecks();
setSelected(d.id);
}
async function addToDeck(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false);
setLibQuery("");
setLibResults([]);
setAnyQuery("");
setAnyResults([]);
await loadContents();
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
const clamped = Math.max(0, qty);
await api.setDeckCard(selected, oracleId, clamped, false);
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
// Re-adding during the grace period below cancels the pending removal.
const timer = removalTimers.current.get(oracleId);
if (timer) {
clearTimeout(timer);
removalTimers.current.delete(oracleId);
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
}
if (clamped <= 0) {
// deck_contents wouldn't include this card on a fresh fetch anymore —
// rather than dropping the row immediately, dim it and give the user a
// window to undo a mistaken decrement (via +) before it disappears.
setPendingRemoval((p) => new Set(p).add(oracleId));
removalTimers.current.set(
oracleId,
setTimeout(() => {
setContents((cs) => cs.filter((e) => e.oracle_id !== oracleId));
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
removalTimers.current.delete(oracleId);
}, REMOVAL_GRACE_MS),
);
}
}
function toggleCard(oracleId: string) {
setSelectedCards((s) => {
const next = new Set(s);
if (next.has(oracleId)) next.delete(oracleId);
else next.add(oracleId);
return next;
});
}
function toggleSelectAllCards() {
setSelectedCards((s) =>
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
);
}
async function bulkRemoveFromDeck() {
setBulkBusy(true);
try {
await Promise.all([...selectedCards].map((id) => setQty(id, 0)));
} finally {
setBulkBusy(false);
}
}
async function confirmDelete() {
if (dialog?.kind !== "delete") return;
await api.deleteDeck(dialog.deck.id);
if (selected === dialog.deck.id) setSelected(null);
setDialog(null);
await loadDecks();
}
async function confirmRename(name: string) {
if (dialog?.kind !== "rename") return;
try {
await api.renameDeck(dialog.deck.id, name);
setDialog(null);
setDialogError(null);
await loadDecks();
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function confirmDuplicate(newName: string) {
if (dialog?.kind !== "duplicate") return;
try {
const d = await api.duplicateDeck(dialog.deck.id, newName);
setDialog(null);
setDialogError(null);
await loadDecks();
setSelected(d.id);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
}
async function changeFormat(format: string) {
if (selected === null) return;
await api.setDeckFormat(selected, format);
await loadDecks();
}
async function setCommander(card: CardRef) {
if (selected === null) return;
// Ensure the commander is in the deck, then flag it.
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
await api.setDeckCard(selected, card.oracle_id, 1, true);
}
await api.setDeckCommander(selected, card.oracle_id);
setCmdQuery("");
setCmdResults([]);
await loadContents();
await loadDecks();
}
async function clearCommander() {
if (selected === null) return;
await api.setDeckCommander(selected, null);
await loadContents();
await loadDecks();
}
const owned = contents.filter((e) => e.quantity_owned > 0);
const unowned = contents.filter((e) => e.quantity_owned === 0);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
function deckTiles(entries: DeckEntry[]) {
return (
<div className="tile-grid">
{entries.map((e) => (
<CardTile
key={e.oracle_id}
card={e}
selected={selectedCards.has(e.oracle_id)}
cornerLeft={
<span className="tile-select">
<input
type="checkbox"
checked={selectedCards.has(e.oracle_id)}
onChange={() => toggleCard(e.oracle_id)}
/>
</span>
}
corner={
<QtyStepper
overlay
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
pending={pendingRemoval.has(e.oracle_id)}
/>
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
/>
))}
</div>
);
}
function deckTable(entries: DeckEntry[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.oracle_id}>
<td>
<input
type="checkbox"
checked={selectedCards.has(e.oracle_id)}
onChange={() => toggleCard(e.oracle_id)}
/>
</td>
<td>
<CardThumb card={e} />
</td>
<td>
<ColorPips mask={e.color_identity} />
</td>
<td className="card-name">
{e.is_commander && <span title="Commander">★ </span>}
{e.name}
</td>
<td className="card-type">{e.type_line}</td>
<td>
<QtyStepper
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
pending={pendingRemoval.has(e.oracle_id)}
/>
</td>
<td>{e.quantity_owned}</td>
</tr>
))}
</tbody>
</table>
);
}
function cardsGroup(entries: DeckEntry[]) {
if (entries.length === 0) return <p className="hint">None yet.</p>;
return groupCards(entries, groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4>
{g.label} <span className="badge">{g.cards.length}</span>
</h4>
)}
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
</div>
));
}
return (
<div style={{ display: "flex", gap: 20 }}>
<div style={{ width: 240, flexShrink: 0 }}>
<h2>Decks</h2>
<div className="row" style={{ marginBottom: 10 }}>
<input
value={newName}
placeholder="New deck name"
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createDeck()}
/>
<button className="primary" onClick={createDeck}>
+
</button>
</div>
<ul className="result-list deck-list">
{decks.map((d) => (
<li
key={d.id}
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
onClick={() => setSelected(d.id)}
>
<div className="deck-list-row">
<span className="card-name">{d.name}</span>
</div>
<div className="deck-list-row deck-list-actions-row">
{d.format ? <span className="deck-chip">{d.format}</span> : <span />}
<span className="row-actions">
<IconButton
icon={Icons.pencil}
label="Rename deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "rename", deck: d });
}}
/>
<IconButton
icon={Icons.copy}
label="Duplicate deck"
onClick={(e) => {
e.stopPropagation();
setDialogError(null);
setDialog({ kind: "duplicate", deck: d });
}}
/>
<IconButton
icon={Icons.trash}
label="Delete deck"
danger
onClick={(e) => {
e.stopPropagation();
setDialog({ kind: "delete", deck: d });
}}
/>
</span>
</div>
</li>
))}
{decks.length === 0 && (
<li style={{ cursor: "default" }} className="hint">
No decks yet.
</li>
)}
</ul>
</div>
<div style={{ flex: 1 }}>
{selected === null || !deck ? (
<p className="hint">Select or create a deck.</p>
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<div className="row" style={{ gap: 14 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
<input
ref={selectAllRef}
type="checkbox"
checked={contents.length > 0 && selectedCards.size === contents.length}
onChange={toggleSelectAllCards}
disabled={contents.length === 0}
/>
<span className="muted" style={{ fontSize: 12 }}>
Select all
</span>
</label>
<h2>{deck.name}</h2>
</div>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<CommanderHero card={commanderEntry} onClear={clearCommander} />
)}
{contents.length > 0 && (
<div className="deck-stats">
<div className="deck-stats-head">Deck analysis</div>
<div className="deck-stats-body">
<div className="deck-stats-numbers">
<div className="stat-tiles-row">
<StatTile value={deckStats.totalCards} label="cards" />
<StatTile value={deckStats.uniqueCards} label="unique" />
<StatTile value={deckStats.landCount} label="lands" />
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
</div>
<div className="deck-stats-curve-label">Color identity</div>
<ColorDistributionBar
colorCounts={deckStats.colorCounts}
colorlessCount={deckStats.colorlessCount}
/>
<div className="deck-stats-curve-label">Mana production</div>
<ColorDistributionBar
colorCounts={deckStats.manaProduction}
colorlessCount={deckStats.colorlessProduction}
/>
<div className="deck-stats-curve-label">Mana consumption</div>
<ColorDistributionBar
colorCounts={deckStats.manaConsumption}
colorlessCount={deckStats.colorlessConsumption}
/>
</div>
<div className="deck-stats-curve">
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
<ManaCurve buckets={deckStats.curve} />
</div>
</div>
</div>
)}
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
{!commanderEntry && <span className="muted">No commander set yet.</span>}
<CardSearchBox
placeholder={
commanderEntry
? "Search to change the commander…"
: "Search to set the commander…"
}
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
</div>
)}
</div>
<section style={{ marginTop: 18 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
</h3>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
onChange={setLibQuery}
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add ones you don't own…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup(unowned)}
</section>
</>
)}
</div>
{dialog?.kind === "delete" && (
<ConfirmDialog
title="Delete deck"
message={`Delete "${dialog.deck.name}"? This can't be undone.`}
confirmLabel="Delete"
danger
onConfirm={confirmDelete}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "rename" && (
<PromptDialog
title="Rename deck"
label="Deck name"
defaultValue={dialog.deck.name}
confirmLabel="Rename"
error={dialogError}
onConfirm={confirmRename}
onCancel={() => setDialog(null)}
/>
)}
{dialog?.kind === "duplicate" && (
<PromptDialog
title="Duplicate deck"
label="Name for the copy"
defaultValue={`${dialog.deck.name} (copy)`}
confirmLabel="Duplicate"
error={dialogError}
onConfirm={confirmDuplicate}
onCancel={() => setDialog(null)}
/>
)}
</div>
);
}