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 (
{card.image_uri ? ( {card.name} open(card)} {...handlers(card)} /> ) : (
{card.name}
)}
Commander {card.name} {card.type_line}
); } // 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([]); const [selected, setSelected] = usePersistedState("deck-selected", null); const [newName, setNewName] = useState(""); const [contents, setContents] = useState([]); const [view, setView] = useViewMode("deck-view"); const [groupBy, setGroupBy] = usePersistedState("deck-group", "none"); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(null); const [libQuery, setLibQuery] = useState(""); const [libResults, setLibResults] = useState([]); const [anyQuery, setAnyQuery] = useState(""); const [anyResults, setAnyResults] = useState([]); const [cmdQuery, setCmdQuery] = useState(""); const [cmdResults, setCmdResults] = useState([]); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); const [selectedCards, setSelectedCards] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const selectAllRef = useRef(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 (
{entries.map((e) => ( toggleCard(e.oracle_id)} /> } corner={ 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 ? ★ commander : undefined} /> ))}
); } function deckTable(entries: DeckEntry[]) { return ( {entries.map((e) => ( ))}
Colors Name Type In deck Owned
toggleCard(e.oracle_id)} /> {e.is_commander && ★ } {e.name} {e.type_line} 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)} /> {e.quantity_owned}
); } function cardsGroup(entries: DeckEntry[]) { if (entries.length === 0) return

None yet.

; return groupCards(entries, groupBy).map((g) => (
{g.label && (

{g.label} {g.cards.length}

)} {view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
)); } return (

Decks

setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && createDeck()} />
    {decks.map((d) => (
  • setSelected(d.id)} >
    {d.name}
    {d.format ? {d.format} : } { e.stopPropagation(); setDialogError(null); setDialog({ kind: "rename", deck: d }); }} /> { e.stopPropagation(); setDialogError(null); setDialog({ kind: "duplicate", deck: d }); }} /> { e.stopPropagation(); setDialog({ kind: "delete", deck: d }); }} />
  • ))} {decks.length === 0 && (
  • No decks yet.
  • )}
{selected === null || !deck ? (

Select or create a deck.

) : ( <>

{deck.name}

{selectedCards.size > 0 && (
{selectedCards.size} selected
)} {COMMANDER_FORMATS.includes(deck.format) && commanderEntry && ( )} {contents.length > 0 && (
Deck analysis
Color identity
Mana production
Mana consumption
Mana curve (nonland)
)} {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
{COMMANDER_FORMATS.includes(deck.format) && (
{!commanderEntry && No commander set yet.}
)}

In your library {owned.length}

{cardsGroup(owned)}

Not owned {unowned.length}

{cardsGroup(unowned)}
)}
{dialog?.kind === "delete" && ( setDialog(null)} /> )} {dialog?.kind === "rename" && ( setDialog(null)} /> )} {dialog?.kind === "duplicate" && ( setDialog(null)} /> )}
); }