Add multi-select and bulk actions to the Library tab
- Checkboxes on every row/tile plus a "Select all" (filtered-set-aware,
indeterminate-aware) toggle in the toolbar.
- A bulk-action bar appears once anything's selected: add to a deck
("Add 1" sets each selected card to 1 copy, "Add All" sets it to
however many you own — the latter is the one-click fix for bulk-adding
a stack of basic lands), set location, and adjust owned quantity by a
configurable delta. All reuse the same per-card commands the
single-card UI already calls, so behavior stays consistent (including
the existing grace-period undo on quantity_owned hitting 0).
- CardTile gains a `cornerLeft` slot and `selected` prop (ring highlight,
keeps the checkbox visible without hovering) to support this in tile
view without disturbing the existing top-right qty-stepper corner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -744,15 +744,21 @@ export function ViewToggle({
|
|||||||
export function CardTile({
|
export function CardTile({
|
||||||
card,
|
card,
|
||||||
corner,
|
corner,
|
||||||
|
cornerLeft,
|
||||||
footer,
|
footer,
|
||||||
|
selected,
|
||||||
}: {
|
}: {
|
||||||
card: CardRef;
|
card: CardRef;
|
||||||
corner?: ReactNode;
|
corner?: ReactNode;
|
||||||
|
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
|
||||||
|
cornerLeft?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
||||||
|
selected?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { open } = useClickPreview();
|
const { open } = useClickPreview();
|
||||||
return (
|
return (
|
||||||
<div className="tile">
|
<div className={`tile ${selected ? "tile-selected" : ""}`}>
|
||||||
<div className="tile-img-wrap">
|
<div className="tile-img-wrap">
|
||||||
{card.image_uri ? (
|
{card.image_uri ? (
|
||||||
<img
|
<img
|
||||||
@@ -765,6 +771,7 @@ export function CardTile({
|
|||||||
) : (
|
) : (
|
||||||
<div className="tile-noimg">{card.name}</div>
|
<div className="tile-noimg">{card.name}</div>
|
||||||
)}
|
)}
|
||||||
|
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
|
||||||
{corner && <div className="tile-corner">{corner}</div>}
|
{corner && <div className="tile-corner">{corner}</div>}
|
||||||
</div>
|
</div>
|
||||||
{footer && <div className="tile-footer">{footer}</div>}
|
{footer && <div className="tile-footer">{footer}</div>}
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ select option {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
App shell: nav rail + main
|
App shell: nav rail + main
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -950,6 +957,39 @@ tbody tr:hover {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Bulk-selection action bar (Library tab)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
border: 1px solid var(--accent-glow);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bulk-bar-count {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bulk-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bulk-bar-delta {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Filters panel
|
Filters panel
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -1158,6 +1198,34 @@ tbody tr:hover {
|
|||||||
.tile:focus-within .tile-corner {
|
.tile:focus-within .tile-corner {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.tile-corner-left {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
/* Same reasoning as .tile-corner: don't cover the card name unless the
|
||||||
|
tile is being interacted with — except once selected, where the
|
||||||
|
checkbox should stay visible so the selection is visible at a glance. */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-corner-left,
|
||||||
|
.tile:focus-within .tile-corner-left,
|
||||||
|
.tile-selected .tile-corner-left {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.tile-selected .tile-img {
|
||||||
|
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
|
||||||
|
}
|
||||||
|
.tile-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
.tile-badge {
|
.tile-badge {
|
||||||
background: rgba(0, 0, 0, 0.78);
|
background: rgba(0, 0, 0, 0.78);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
GroupMode,
|
GroupMode,
|
||||||
LocationField,
|
LocationField,
|
||||||
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
@@ -41,6 +42,13 @@ export function LibraryBrowser() {
|
|||||||
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
||||||
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
|
||||||
|
const [bulkDelta, setBulkDelta] = useState(1);
|
||||||
|
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listDecks().then(setDecks);
|
api.listDecks().then(setDecks);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -49,6 +57,23 @@ export function LibraryBrowser() {
|
|||||||
// an unmounted component.
|
// an unmounted component.
|
||||||
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
||||||
|
|
||||||
|
// Keep selection in sync with what's actually visible — drop ids that fall
|
||||||
|
// out of the filtered set (filter changed, or a card's qty hit 0 and its
|
||||||
|
// grace period expired).
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const visible = new Set(rows.map((r) => r.oracle_id));
|
||||||
|
const next = new Set([...s].filter((id) => visible.has(id)));
|
||||||
|
return next.size === s.size ? s : next;
|
||||||
|
});
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectAllRef.current) {
|
||||||
|
selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length;
|
||||||
|
}
|
||||||
|
}, [selected, rows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filter: LibraryFilter = {
|
const filter: LibraryFilter = {
|
||||||
text: text.trim() || null,
|
text: text.trim() || null,
|
||||||
@@ -116,6 +141,64 @@ export function LibraryBrowser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleOne(oracleId: string) {
|
||||||
|
setSelected((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
if (next.has(oracleId)) next.delete(oracleId);
|
||||||
|
else next.add(oracleId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the in-deck quantity of every selected card to either 1 or the full
|
||||||
|
* owned count (e.g. select a handful of basic lands, "Add All" to put
|
||||||
|
* every copy you own into the deck at once) — overwrites the quantity for
|
||||||
|
* cards already in the deck rather than skipping them, so it doubles as a
|
||||||
|
* bulk "fix the land count" tool.
|
||||||
|
*/
|
||||||
|
async function bulkAddToDeck(mode: "one" | "all") {
|
||||||
|
if (bulkDeckId === "") return;
|
||||||
|
const deck = decks.find((d) => d.id === bulkDeckId);
|
||||||
|
if (!deck) return;
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||||
|
await Promise.all(
|
||||||
|
targets.map((r) =>
|
||||||
|
api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkSetLocation(value: string) {
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([...selected].map((id) => api.setLocation(id, value)));
|
||||||
|
setLocationDialogOpen(false);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkAdjust(delta: number) {
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([...selected].map((id) => adjust(id, delta)));
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deckOrLocation = (c: LibraryCard) =>
|
const deckOrLocation = (c: LibraryCard) =>
|
||||||
c.decks.length === 0 ? (
|
c.decks.length === 0 ? (
|
||||||
<LocationField
|
<LocationField
|
||||||
@@ -136,6 +219,7 @@ export function LibraryBrowser() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Colors</th>
|
<th>Colors</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -148,6 +232,13 @@ export function LibraryBrowser() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<tr key={c.oracle_id}>
|
<tr key={c.oracle_id}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(c.oracle_id)}
|
||||||
|
onChange={() => toggleOne(c.oracle_id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<CardThumb card={c} />
|
<CardThumb card={c} />
|
||||||
</td>
|
</td>
|
||||||
@@ -188,6 +279,16 @@ export function LibraryBrowser() {
|
|||||||
<CardTile
|
<CardTile
|
||||||
key={c.oracle_id}
|
key={c.oracle_id}
|
||||||
card={c}
|
card={c}
|
||||||
|
selected={selected.has(c.oracle_id)}
|
||||||
|
cornerLeft={
|
||||||
|
<span className="tile-select">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(c.oracle_id)}
|
||||||
|
onChange={() => toggleOne(c.oracle_id)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
corner={
|
corner={
|
||||||
<QtyStepper
|
<QtyStepper
|
||||||
overlay
|
overlay
|
||||||
@@ -210,15 +311,106 @@ export function LibraryBrowser() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
|
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
<h2 style={{ margin: 0 }}>
|
<div className="row" style={{ gap: 14 }}>
|
||||||
Library <span className="badge">{rows.length}</span>
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all filtered cards">
|
||||||
</h2>
|
<input
|
||||||
|
ref={selectAllRef}
|
||||||
|
type="checkbox"
|
||||||
|
checked={rows.length > 0 && selected.size === rows.length}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Select all
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<h2 style={{ margin: 0 }}>
|
||||||
|
Library <span className="badge">{rows.length}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="row" style={{ gap: 16 }}>
|
<div className="row" style={{ gap: 16 }}>
|
||||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="bulk-bar">
|
||||||
|
<span className="bulk-bar-count">{selected.size} selected</span>
|
||||||
|
<div className="bulk-bar-actions">
|
||||||
|
<span className="row" style={{ gap: 6 }}>
|
||||||
|
<select
|
||||||
|
value={bulkDeckId}
|
||||||
|
onChange={(e) => setBulkDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
>
|
||||||
|
<option value="">Add to deck…</option>
|
||||||
|
{decks.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => bulkAddToDeck("one")}
|
||||||
|
disabled={bulkBusy || bulkDeckId === ""}
|
||||||
|
title="Set each selected card to 1 copy in the deck"
|
||||||
|
>
|
||||||
|
Add 1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => bulkAddToDeck("all")}
|
||||||
|
disabled={bulkBusy || bulkDeckId === ""}
|
||||||
|
title="Set each selected card to every copy you own"
|
||||||
|
>
|
||||||
|
Add All
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
|
||||||
|
Set location…
|
||||||
|
</button>
|
||||||
|
<span className="row" style={{ gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => bulkAdjust(-bulkDelta)}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
title="Remove copies from each selected card"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="bulk-bar-delta"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={bulkDelta}
|
||||||
|
onChange={(e) => setBulkDelta(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => bulkAdjust(bulkDelta)}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
title="Add copies to each selected card"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationDialogOpen && (
|
||||||
|
<PromptDialog
|
||||||
|
title="Set location"
|
||||||
|
label={`Location for ${selected.size} card${selected.size === 1 ? "" : "s"}`}
|
||||||
|
defaultValue=""
|
||||||
|
confirmLabel="Apply"
|
||||||
|
onConfirm={bulkSetLocation}
|
||||||
|
onCancel={() => setLocationDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Name contains</label>
|
<label>Name contains</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user