From eefe1dc954a01fff933f56d1f2aba75e9ca98d52 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 7 Jul 2026 20:55:18 -0500 Subject: [PATCH] Card preview modal, library quantity steppers, and UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 65 +++++++++---- src/api.ts | 1 + src/components.tsx | 157 +++++++++++++++++++++++++++--- src/styles.css | 179 ++++++++++++++++++++++++++++++++++- src/views/CardEntry.tsx | 18 ++-- src/views/DeckView.tsx | 2 +- src/views/LibraryBrowser.tsx | 86 ++++++++++++++--- 7 files changed, 448 insertions(+), 60 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c90690e..179418a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,11 +9,11 @@ import { DeckView } from "./views/DeckView"; type Tab = "add" | "library" | "decks"; -const svg = (children: ReactNode) => ( +const svg = (children: ReactNode, size = 20) => ( ( ); +const ICON_OPEN_LIBRARY = svg( + , + 16, +); +const ICON_NEW_LIBRARY = svg( + <> + + + + , + 16, +); +const ICON_DOWNLOAD = svg( + <> + + + + , + 16, +); + const NAV: { id: Tab; label: string; icon: ReactNode }[] = [ { id: "add", label: "Add Cards", icon: svg() }, { @@ -124,14 +145,25 @@ export default function App() { await api.cancelUpdateCardData(); } + async function switchLibrary(path: string) { + try { + setStatus(await api.setLibraryPath(path)); + } catch (err) { + setProgress({ + stage: "error", + message: `Couldn't open that library: ${String(err)}`, + overall_percent: 0, + stages: lastProgress.current?.stages ?? [], + }); + } + } + async function openLibrary() { const path = await open({ title: "Open an existing library.db", filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], }); - if (typeof path === "string") { - setStatus(await api.setLibraryPath(path)); - } + if (typeof path === "string") await switchLibrary(path); } async function newLibrary() { @@ -140,9 +172,7 @@ export default function App() { defaultPath: "library.db", filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], }); - if (typeof path === "string") { - setStatus(await api.setLibraryPath(path)); - } + if (typeof path === "string") await switchLibrary(path); } const ready = status?.reference_ready ?? false; @@ -177,19 +207,19 @@ export default function App() {
- {status && ( - - {status.library_path} - - )} - + + {status?.library_path ?? ""} +
@@ -249,6 +279,7 @@ export default function App() { be synced.

diff --git a/src/api.ts b/src/api.ts index b7f283a..a64fdba 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,7 @@ export interface CardRef { color_identity: number; // WUBRG bitmask type_line: string; image_uri: string; // Scryfall "normal" image URL, or "" if unavailable + back_image_uri: string; // back face for transform/MDFC/reversible cards, else "" } export interface LibraryCard extends CardRef { diff --git a/src/components.tsx b/src/components.tsx index 4c9cda4..92588a4 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -40,6 +40,58 @@ export function LocationField({ ); } +/** + * A small pill-shaped [− N +] quantity stepper, used consistently for owned-copy + * counts on the Add Cards page and both Library views. + * + * - `overlay`: dark translucent variant for use atop card art (tile corners), + * instead of the default panel-toned variant used in rows/lists. + * - `warn`: tints the pill amber (e.g. "assigned to more decks than owned"). + * - `pending`: dims the pill (e.g. mid grace-period before a 0-qty row is removed). + */ +export function QtyStepper({ + value, + onDecrement, + onIncrement, + overlay, + warn, + pending, +}: { + value: number; + onDecrement: () => void; + onIncrement: () => void; + overlay?: boolean; + warn?: boolean; + pending?: boolean; +}) { + return ( + + + {value} + + + ); +} + /** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */ export function ColorPips({ mask }: { mask: number }) { const letters = maskToLetters(mask); @@ -61,9 +113,14 @@ export function ColorPips({ mask }: { mask: number }) { ); } -/** Small card-image thumbnail. Hovering it shows the full-size preview. */ +/** + * Small card-image thumbnail. Hovering it shows a cursor-tracking preview; + * clicking it opens a larger, centered preview (with a flip button for + * double-faced cards). + */ export function CardThumb({ card }: { card: CardRef }) { const { handlers } = useHoverPreview(); + const { open } = useClickPreview(); if (!card.image_uri) { return ( @@ -77,6 +134,10 @@ export function CardThumb({ card }: { card: CardRef }) { src={card.image_uri} alt={card.name} loading="lazy" + onClick={(e) => { + e.stopPropagation(); + open(card); + }} {...handlers(card)} /> ); @@ -148,7 +209,7 @@ export function CardSearchBox({ {results.map((c, i) => (
  • setSel(i)} onClick={() => pick(c)} > @@ -163,35 +224,59 @@ export function CardSearchBox({ ); } -// --- Hover preview -------------------------------------------------------- +// --- Hover + click preview ------------------------------------------------- interface HoverApi { enter: (card: CardRef, e: React.MouseEvent) => void; move: (e: React.MouseEvent) => void; leave: () => void; } +interface ClickApi { + open: (card: CardRef) => void; +} const HoverCtx = createContext(null); +const ClickCtx = createContext(null); /** - * Provides a single floating card-image preview that appears while hovering a - * card and tracks the cursor. Wrap the app once; attach `handlers(card)` from - * [`useHoverPreview`] to any element that should trigger it. + * Provides card-image previews app-wide: a small floating preview that + * tracks the cursor while hovering (see [`useHoverPreview`]), and a larger + * centered modal on click (see [`useClickPreview`]) — with a flip button for + * double-faced cards. Wrap the app once. */ export function HoverPreviewProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState<{ card: CardRef; x: number; y: number } | null>(null); + const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null); + const [clicked, setClicked] = useState<{ card: CardRef; showBack: boolean } | null>(null); const enter = useCallback((card: CardRef, e: React.MouseEvent) => { - if (card.image_uri) setState({ card, x: e.clientX, y: e.clientY }); + if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY }); }, []); const move = useCallback((e: React.MouseEvent) => { - setState((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s)); + setHover((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s)); }, []); - const leave = useCallback(() => setState(null), []); + const leave = useCallback(() => setHover(null), []); + + const open = useCallback((card: CardRef) => { + if (!card.image_uri) return; + setHover(null); + setClicked({ card, showBack: false }); + }, []); + const close = useCallback(() => setClicked(null), []); + const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []); + + useEffect(() => { + if (!clicked) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && close(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [clicked, close]); return ( - {children} - {state && } + + {children} + {!clicked && hover && } + {clicked && } + ); } @@ -211,6 +296,12 @@ export function useHoverPreview() { }; } +/** Returns `open(card)` to trigger the larger click-to-preview modal. */ +export function useClickPreview() { + const api = useContext(ClickCtx); + return { open: (card: CardRef) => api?.open(card) }; +} + const PREVIEW_W = 320; const PREVIEW_H = 446; // ~ card aspect for the normal image const GAP = 24; @@ -231,6 +322,34 @@ function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) { ); } +function ClickPreviewModal({ + state, + onClose, + onFlip, +}: { + state: { card: CardRef; showBack: boolean }; + onClose: () => void; + onFlip: () => void; +}) { + const { card, showBack } = state; + const src = showBack && card.back_image_uri ? card.back_image_uri : card.image_uri; + return ( +
    +
    e.stopPropagation()}> + {card.name} + + {card.back_image_uri && ( + + )} +
    +
    + ); +} + // --- Grouping ------------------------------------------------------------- export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors"; @@ -405,7 +524,10 @@ export function ViewToggle({ ); } -/** A single card rendered as a full image tile, with optional corner + footer. */ +/** + * A single card rendered as a full image tile, with optional corner + footer. + * Clicking the image opens a larger click-to-preview modal. + */ export function CardTile({ card, corner, @@ -415,11 +537,18 @@ export function CardTile({ corner?: ReactNode; footer?: ReactNode; }) { + const { open } = useClickPreview(); return (
    {card.image_uri ? ( - {card.name} + {card.name} open(card)} + /> ) : (
    {card.name}
    )} diff --git a/src/styles.css b/src/styles.css index e9d3e2d..d44571c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -91,6 +91,9 @@ body { /* -------- Controls -------- */ button { + display: inline-flex; + align-items: center; + gap: 6px; background: var(--bg-input); color: var(--text); border: 1px solid var(--border); @@ -102,6 +105,9 @@ button { font-family: inherit; transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s; } +button svg { + flex-shrink: 0; +} button:hover { background: var(--bg-hover); border-color: var(--bg-elev); @@ -281,13 +287,14 @@ select:focus { border-bottom: 1px solid var(--border-soft); } .path { + flex: 1 1 auto; + min-width: 0; font-size: 12px; font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--faint); background: rgba(0, 0, 0, 0.25); padding: 4px 10px; border-radius: 6px; - max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -477,6 +484,102 @@ h4 { font-weight: 700; } +/* ============================================================ + Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards + and both Library views (list + tile) for a consistent look. + ============================================================ */ +.qty-stepper { + display: inline-flex; + align-items: center; + height: 26px; + border-radius: 999px; + background: var(--bg-input); + border: 1px solid var(--border); + overflow: hidden; + transition: opacity 0.25s ease, background 0.15s ease, border-color 0.15s ease; +} +.qty-stepper-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 100%; + padding: 0; + background: none; + border: none; + border-radius: 0; + color: var(--muted); + font-size: 15px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.qty-stepper-btn:hover:not(:disabled) { + background: var(--accent-subtle); + color: var(--accent-hover); +} +.qty-stepper-btn:active:not(:disabled) { + background: var(--accent); + color: #fff; + transform: none; +} +.qty-stepper-btn:disabled { + opacity: 0.3; + cursor: default; +} +.qty-stepper-value { + min-width: 22px; + text-align: center; + padding: 0 2px; + font-size: 13px; + font-weight: 700; + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} +.qty-stepper.warn { + border-color: var(--warn); +} +.qty-stepper.warn .qty-stepper-value { + color: var(--warn); +} +.qty-stepper.pending { + opacity: 0.5; +} + +/* Overlay variant: dark translucent pill for use atop card art. */ +.qty-stepper-overlay { + height: 24px; + background: rgba(0, 0, 0, 0.78); + border: none; + backdrop-filter: blur(2px); +} +.qty-stepper-overlay .qty-stepper-btn { + width: 22px; + color: rgba(255, 255, 255, 0.85); +} +.qty-stepper-overlay .qty-stepper-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.18); + color: #fff; +} +.qty-stepper-overlay .qty-stepper-btn:active:not(:disabled) { + background: var(--accent); + color: #fff; +} +.qty-stepper-overlay .qty-stepper-value { + color: #fff; +} +.qty-stepper-overlay.warn { + background: var(--warn); +} +.qty-stepper-overlay.warn .qty-stepper-value, +.qty-stepper-overlay.warn .qty-stepper-btn { + color: #241700; +} +.qty-stepper-overlay.warn .qty-stepper-btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.12); +} + /* ============================================================ Lists (search results, deck list) ============================================================ */ @@ -674,6 +777,79 @@ tbody tr:hover { pointer-events: none; } +.preview-modal-backdrop { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 10, 12, 0.72); + backdrop-filter: blur(2px); + cursor: zoom-out; + animation: preview-fade-in 0.12s ease; +} +@keyframes preview-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.preview-modal { + position: relative; + cursor: default; +} +.preview-modal-img { + display: block; + max-width: min(560px, 84vw); + max-height: 84vh; + border-radius: 18px; + box-shadow: var(--shadow-lg); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.preview-modal-close { + position: absolute; + top: -14px; + right: -14px; + width: 32px; + height: 32px; + padding: 0; + border-radius: 999px; + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-md); +} +.preview-modal-close:hover { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.preview-modal-flip { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + padding: 7px 16px; + border-radius: 999px; + background: rgba(20, 20, 24, 0.75); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.15); + font-size: 12px; + font-weight: 600; + backdrop-filter: blur(4px); +} +.preview-modal-flip:hover { + background: var(--accent); + border-color: var(--accent); +} + .tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); @@ -692,6 +868,7 @@ tbody tr:hover { display: block; border-radius: 12px; box-shadow: var(--shadow-sm); + cursor: zoom-in; transition: transform 0.12s, box-shadow 0.12s; } .tile:hover .tile-img { diff --git a/src/views/CardEntry.tsx b/src/views/CardEntry.tsx index 569166e..5a7d253 100644 --- a/src/views/CardEntry.tsx +++ b/src/views/CardEntry.tsx @@ -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() { {r.name} {r.location && {r.location}} - - owned ×{r.qty} - + adjust(r.oracleId, -1)} + onIncrement={() => adjust(r.oracleId, 1)} + />
  • ))} diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 5d98cfd..48325b4 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -269,7 +269,7 @@ export function DeckView() { {decks.map((d) => (
  • setSelected(d.id)} > {d.name} diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index a3df1eb..1e8d58d 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -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("library-group", "none"); const [refreshKey, setRefreshKey] = useState(0); + const [pendingRemoval, setPendingRemoval] = useState>(new Set()); + const removalTimers = useRef>>(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 ? ( {c.type_line} {c.mana_value} c.quantity_owned ? "over-assigned" : ""}> - {c.quantity_owned} - {c.quantity_assigned > c.quantity_owned && ( - - {" "} - ⚠ {c.quantity_assigned} assigned - - )} + + 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 && ( + + ⚠ {c.quantity_assigned} assigned + + )} + {deckOrLocation(c)} @@ -120,13 +175,14 @@ export function LibraryBrowser() { key={c.oracle_id} card={c} corner={ - c.quantity_owned ? "warn" : ""}`} - title="Copies owned" - > - ×{c.quantity_owned} - {c.quantity_assigned > c.quantity_owned ? " ⚠" : ""} - + 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)} />