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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user