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:
2026-07-08 08:20:09 -05:00
parent d8a9787ecd
commit 2581b1fb24
3 changed files with 271 additions and 4 deletions

View File

@@ -744,15 +744,21 @@ export function ViewToggle({
export function CardTile({
card,
corner,
cornerLeft,
footer,
selected,
}: {
card: CardRef;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
footer?: ReactNode;
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
selected?: boolean;
}) {
const { open } = useClickPreview();
return (
<div className="tile">
<div className={`tile ${selected ? "tile-selected" : ""}`}>
<div className="tile-img-wrap">
{card.image_uri ? (
<img
@@ -765,6 +771,7 @@ export function CardTile({
) : (
<div className="tile-noimg">{card.name}</div>
)}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>}
</div>
{footer && <div className="tile-footer">{footer}</div>}

View File

@@ -203,6 +203,13 @@ select option {
color: var(--text);
}
input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
/* ============================================================
App shell: nav rail + main
============================================================ */
@@ -950,6 +957,39 @@ tbody tr:hover {
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
============================================================ */
@@ -1158,6 +1198,34 @@ tbody tr:hover {
.tile:focus-within .tile-corner {
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 {
background: rgba(0, 0, 0, 0.78);
color: #fff;

View File

@@ -8,6 +8,7 @@ import {
GroupBySelect,
GroupMode,
LocationField,
PromptDialog,
QtyStepper,
ViewToggle,
groupCards,
@@ -41,6 +42,13 @@ export function LibraryBrowser() {
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
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(() => {
api.listDecks().then(setDecks);
}, []);
@@ -49,6 +57,23 @@ export function LibraryBrowser() {
// an unmounted component.
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(() => {
const filter: LibraryFilter = {
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) =>
c.decks.length === 0 ? (
<LocationField
@@ -136,6 +219,7 @@ export function LibraryBrowser() {
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
@@ -148,6 +232,13 @@ export function LibraryBrowser() {
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td>
<CardThumb card={c} />
</td>
@@ -188,6 +279,16 @@ export function LibraryBrowser() {
<CardTile
key={c.oracle_id}
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={
<QtyStepper
overlay
@@ -210,15 +311,106 @@ export function LibraryBrowser() {
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>
Library <span className="badge">{rows.length}</span>
</h2>
<div className="row" style={{ gap: 14 }}>
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all filtered cards">
<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 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</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="field">
<label>Name contains</label>