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({
|
||||
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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user