import { useEffect, useRef, useState, useCallback } from "react"; import { api, CardRef, Deck, DeckEntry } from "../api"; import { CardSearchBox, CardThumb, CardTile, ColorPips, ConfirmDialog, GroupBySelect, GroupMode, IconButton, Icons, PromptDialog, QtyStepper, ViewToggle, groupCards, 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; } // 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 commanderName = deck?.commander_oracle_id && (contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ?? deck.commander_oracle_id); 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
)} {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
{COMMANDER_FORMATS.includes(deck.format) && (
Commander: {commanderName || "none"} {commanderName && ( )}
)}

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