Add multi-select bulk-remove and a quantity stepper to the Deck tab
- Mirror the Library tab's multi-select: checkboxes on every row/tile, a "Select all" toggle, and a bulk-action bar with a single "Remove from deck" action (reuses CardTile's cornerLeft/selected support and the existing .bulk-bar/.tile-select styling — no new CSS needed). - Replace the deck table's plain number input (which only committed on blur) and the tile view's static "in deck ×N" badge with the same QtyStepper used in the Library tab, so bumping a card's count no longer means removing it and re-adding it. Decrementing to 0 now gets the same grace-period undo as the Library tab (dims the row for 5s, + brings it back) instead of deleting it from the deck instantly. - QtyStepper's `warn` state now flags deck cards you don't own enough copies of (scoped to cards you own at least one of, so every row in the already-labeled "Not owned" section doesn't light up too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 () => {
|
||||
@@ -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() {
|
||||
<CardTile
|
||||
key={e.oracle_id}
|
||||
card={e}
|
||||
corner={
|
||||
<IconButton
|
||||
icon={Icons.close}
|
||||
label="Remove from deck"
|
||||
overlay
|
||||
onClick={() => setQty(e.oracle_id, 0)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<span className="tile-badge">
|
||||
{e.is_commander ? "★ " : ""}in deck ×{e.quantity}
|
||||
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>
|
||||
@@ -235,18 +333,25 @@ export function DeckView() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th></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>
|
||||
@@ -259,23 +364,15 @@ export function DeckView() {
|
||||
</td>
|
||||
<td className="card-type">{e.type_line}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
style={{ width: 60 }}
|
||||
key={e.quantity}
|
||||
defaultValue={e.quantity}
|
||||
onBlur={(ev) => setQty(e.oracle_id, Number(ev.target.value))}
|
||||
<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>
|
||||
<td>
|
||||
<IconButton
|
||||
icon={Icons.close}
|
||||
label="Remove from deck"
|
||||
onClick={() => setQty(e.oracle_id, 0)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -367,13 +464,42 @@ export function DeckView() {
|
||||
) : (
|
||||
<>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
|
||||
Reference in New Issue
Block a user