diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index d98e5fe..d575eac 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { api, CardRef, Deck, DeckEntry } from "../api"; import { CardSearchBox, @@ -11,6 +11,7 @@ import { IconButton, Icons, PromptDialog, + QtyStepper, ViewToggle, groupCards, usePersistedState, @@ -28,6 +29,11 @@ function friendlyDeckError(err: unknown): string { 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", @@ -64,6 +70,12 @@ export function DeckView() { 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 () => { @@ -89,8 +101,30 @@ export function DeckView() { 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([]); @@ -134,8 +168,64 @@ export function DeckView() { async function setQty(oracleId: string, qty: number) { if (selected === null) return; - await api.setDeckCard(selected, oracleId, qty, false); - await loadContents(); + 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() { @@ -211,19 +301,27 @@ export function DeckView() { setQty(e.oracle_id, 0)} - /> - } - footer={ - - {e.is_commander ? "★ " : ""}in deck ×{e.quantity} + selected={selectedCards.has(e.oracle_id)} + cornerLeft={ + + 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} /> ))} @@ -235,18 +333,25 @@ export function DeckView() { + - {entries.map((e) => ( + @@ -259,23 +364,15 @@ export function DeckView() { - ))} @@ -367,13 +464,42 @@ export function DeckView() { ) : ( <>
-

{deck.name}

+
+ +

{deck.name}

+
+ {selectedCards.size > 0 && ( +
+ {selectedCards.size} selected +
+ +
+ + +
+ )} + {/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
Colors Name Type In deck Owned
+ toggleCard(e.oracle_id)} + /> + {e.type_line} - setQty(e.oracle_id, Number(ev.target.value))} + 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} - setQty(e.oracle_id, 0)} - /> -