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:
@@ -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 (
|
||||
<span
|
||||
className={[
|
||||
"qty-stepper",
|
||||
overlay ? "qty-stepper-overlay" : "",
|
||||
warn ? "warn" : "",
|
||||
pending ? "pending" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="qty-stepper-btn"
|
||||
onClick={onDecrement}
|
||||
disabled={value <= 0}
|
||||
title="Remove a copy"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="qty-stepper-value">{value}</span>
|
||||
<button type="button" className="qty-stepper-btn" onClick={onIncrement} title="Add a copy">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span className="thumb-empty" title="No image">
|
||||
@@ -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) => (
|
||||
<li
|
||||
key={c.oracle_id}
|
||||
style={i === sel ? { background: "var(--panel-2)" } : undefined}
|
||||
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
||||
onMouseEnter={() => 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<HoverApi | null>(null);
|
||||
const ClickCtx = createContext<ClickApi | null>(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 (
|
||||
<HoverCtx.Provider value={{ enter, move, leave }}>
|
||||
{children}
|
||||
{state && <FloatingCard card={state.card} x={state.x} y={state.y} />}
|
||||
<ClickCtx.Provider value={{ open }}>
|
||||
{children}
|
||||
{!clicked && hover && <FloatingCard card={hover.card} x={hover.x} y={hover.y} />}
|
||||
{clicked && <ClickPreviewModal state={clicked} onClose={close} onFlip={flip} />}
|
||||
</ClickCtx.Provider>
|
||||
</HoverCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="preview-modal-backdrop" onClick={onClose}>
|
||||
<div className="preview-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="preview-modal-img" src={src} alt={card.name} />
|
||||
<button className="preview-modal-close" onClick={onClose} title="Close (Esc)">
|
||||
✕
|
||||
</button>
|
||||
{card.back_image_uri && (
|
||||
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
|
||||
⟲ Flip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<div className="tile">
|
||||
<div className="tile-img-wrap">
|
||||
{card.image_uri ? (
|
||||
<img className="tile-img" src={card.image_uri} alt={card.name} loading="lazy" />
|
||||
<img
|
||||
className="tile-img"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
loading="lazy"
|
||||
onClick={() => open(card)}
|
||||
/>
|
||||
) : (
|
||||
<div className="tile-noimg">{card.name}</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user