From 2581b1fb24da4816aa7443db9ea50d0b4a66853b Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 8 Jul 2026 08:20:09 -0500 Subject: [PATCH] Add multi-select and bulk actions to the Library tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components.tsx | 9 +- src/styles.css | 68 ++++++++++++ src/views/LibraryBrowser.tsx | 198 ++++++++++++++++++++++++++++++++++- 3 files changed, 271 insertions(+), 4 deletions(-) diff --git a/src/components.tsx b/src/components.tsx index 54b7be3..dda6517 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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 ( -
+
{card.image_uri ? ( {card.name}
)} + {cornerLeft &&
{cornerLeft}
} {corner &&
{corner}
}
{footer &&
{footer}
} diff --git a/src/styles.css b/src/styles.css index 92eb438..c9d401b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index f0223ed..27c4167 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -8,6 +8,7 @@ import { GroupBySelect, GroupMode, LocationField, + PromptDialog, QtyStepper, ViewToggle, groupCards, @@ -41,6 +42,13 @@ export function LibraryBrowser() { const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); + const [selected, setSelected] = useState>(new Set()); + const [bulkDeckId, setBulkDeckId] = useState(""); + const [bulkDelta, setBulkDelta] = useState(1); + const [locationDialogOpen, setLocationDialogOpen] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + const selectAllRef = useRef(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 ? ( + Colors Name @@ -148,6 +232,13 @@ export function LibraryBrowser() { {cards.map((c) => ( + + toggleOne(c.oracle_id)} + /> + @@ -188,6 +279,16 @@ export function LibraryBrowser() { + toggleOne(c.oracle_id)} + /> + + } corner={
-

- Library {rows.length} -

+
+ +

+ Library {rows.length} +

+
+ {selected.size > 0 && ( +
+ {selected.size} selected +
+ + + + + + + + + setBulkDelta(Math.max(1, Number(e.target.value) || 1))} + /> + + +
+ + +
+ )} + + {locationDialogOpen && ( + setLocationDialogOpen(false)} + /> + )} +