Card preview modal, library quantity steppers, and UI polish

- Fix keyboard-nav selection highlight (search results, deck list): the
  Discord-styling rewrite dropped the --panel-2 CSS variable it relied
  on, so ↑/↓ moved selection but rendered no visible highlight.
- Add click-to-preview: clicking a card thumbnail/tile now opens a
  larger centered modal (list and tile views), with a flip button for
  double-faced cards' back face. Hover preview is unchanged and clears
  when a click-preview opens.
- Add a QtyStepper component (rounded [− N +] pill, with an `overlay`
  variant for tile corners) and use it consistently for owned-copy
  counts on Add Cards, and the Library list and tile views.
- Add matching +/- steppers to the Library views; decrementing to 0
  dims the row for a 5s grace period (still fully interactive) before
  it drops out, so an accidental removal can be undone with +.
- Topbar: fill remaining width with the library path (no more premature
  truncation), add icons to Open/New Library and Download/Update card
  data, and drop the trailing "…" from the library buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:55:18 -05:00
parent e18664ea8c
commit eefe1dc954
7 changed files with 448 additions and 60 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { api, CardRef } from "../api";
import { CardSearchBox } from "../components";
import { CardSearchBox, QtyStepper } from "../components";
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
export function CardEntry() {
@@ -81,17 +81,11 @@ export function CardEntry() {
<span className="card-name">{r.name}</span>
{r.location && <span className="deck-chip">{r.location}</span>}
<span className="spacer" />
<button
onClick={() => adjust(r.oracleId, -1)}
disabled={r.qty <= 0}
title="Remove a copy"
>
−
</button>
<span className="badge">owned ×{r.qty}</span>
<button onClick={() => adjust(r.oracleId, 1)} title="Add another copy">
+
</button>
<QtyStepper
value={r.qty}
onDecrement={() => adjust(r.oracleId, -1)}
onIncrement={() => adjust(r.oracleId, 1)}
/>
</li>
))}
</ul>

View File

@@ -269,7 +269,7 @@ export function DeckView() {
{decks.map((d) => (
<li
key={d.id}
style={d.id === selected ? { background: "var(--panel-2)" } : undefined}
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
onClick={() => setSelected(d.id)}
>
<span className="card-name">{d.name}</span>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
import {
CardThumb,
@@ -8,12 +8,18 @@ import {
GroupBySelect,
GroupMode,
LocationField,
QtyStepper,
ViewToggle,
groupCards,
usePersistedState,
useViewMode,
} from "../components";
// How long a card that's been decremented to 0 lingers (dimmed, still
// adjustable) before actually dropping out of the library view — gives the
// user a window to undo an accidental removal by clicking + again.
const REMOVAL_GRACE_MS = 5000;
/** Browse & filter the owned library by name and card criteria. */
export function LibraryBrowser() {
const [text, setText] = useState("");
@@ -31,11 +37,17 @@ export function LibraryBrowser() {
const [view, setView] = useViewMode("library-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [refreshKey, setRefreshKey] = useState(0);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
api.listDecks().then(setDecks);
}, []);
// Cancel any in-flight removal timers on unmount so they don't fire against
// an unmounted component.
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
useEffect(() => {
const filter: LibraryFilter = {
text: text.trim() || null,
@@ -54,6 +66,42 @@ export function LibraryBrowser() {
return () => clearTimeout(t);
}, [text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, refreshKey]);
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
// 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 (qty <= 0) {
// search_library only returns owned (>0) cards — rather than dropping
// the row immediately, dim it and give the user a window to undo a
// mistaken decrement (via +) before it actually disappears.
setPendingRemoval((p) => new Set(p).add(oracleId));
removalTimers.current.set(
oracleId,
setTimeout(() => {
setRows((rs) => rs.filter((c) => c.oracle_id !== oracleId));
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
removalTimers.current.delete(oracleId);
}, REMOVAL_GRACE_MS),
);
}
}
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
@@ -96,13 +144,20 @@ export function LibraryBrowser() {
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned && (
<span title="Assigned to more decks than you own">
{" "}
⚠ {c.quantity_assigned} assigned
</span>
)}
<span className="row" style={{ gap: 8 }}>
<QtyStepper
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
warn={c.quantity_assigned > c.quantity_owned}
pending={pendingRemoval.has(c.oracle_id)}
/>
{c.quantity_assigned > c.quantity_owned && (
<span title="Assigned to more decks than you own">
⚠ {c.quantity_assigned} assigned
</span>
)}
</span>
</td>
<td>{deckOrLocation(c)}</td>
</tr>
@@ -120,13 +175,14 @@ export function LibraryBrowser() {
key={c.oracle_id}
card={c}
corner={
<span
className={`tile-badge ${c.quantity_assigned > c.quantity_owned ? "warn" : ""}`}
title="Copies owned"
>
×{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned ? " ⚠" : ""}
</span>
<QtyStepper
overlay
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
warn={c.quantity_assigned > c.quantity_owned}
pending={pendingRemoval.has(c.oracle_id)}
/>
}
footer={deckOrLocation(c)}
/>