From ecb946328acaeb8c0d5092e1ef74924fb0e4e566 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Wed, 22 Jul 2026 23:11:26 -0500 Subject: [PATCH] Add a card details info tile to the click-to-preview modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows type line, mana value/colors, price, per-format legality pills, and ownership context (owned copies, decks it's in) when the caller has that data — LibraryCard/DeckEntry rows do, bare CardRefs don't, so the section just omits itself when absent. The info tile sits beside the card art via flex-wrap and drops to its own line on narrow viewports, rather than shrinking either face to make room. Also shrinks a double-faced Battle's rotated front face when it has a sibling back face, so it no longer renders at full single-card size next to a much smaller back face. --- src/api.ts | 12 +++++ src/components.tsx | 111 +++++++++++++++++++++++++++++++++++++-------- src/styles.css | 108 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 20 deletions(-) diff --git a/src/api.ts b/src/api.ts index 7ffa6bb..57a7a09 100644 --- a/src/api.ts +++ b/src/api.ts @@ -38,6 +38,18 @@ export interface DeckEntry extends CardRef { quantity_owned: number; } +/** + * A `CardRef` plus whatever ownership context the caller happens to have + * (present on `LibraryCard`/`DeckEntry`, absent on a bare `CardRef`). The + * click-to-preview modal's info tile renders these fields when present and + * omits them otherwise — callers don't need to do anything special, since + * `LibraryCard`/`DeckEntry`/`CardRef` are all structurally assignable here. + */ +export type PreviewCard = CardRef & { + quantity_owned?: number; + decks?: string[]; +}; + export interface Deck { id: number; name: string; diff --git a/src/components.tsx b/src/components.tsx index 5cbbf37..0f032cd 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import { api, CardRef, COLORS, maskToLetters } from "./api"; +import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api"; // --- Icons ------------------------------------------------------------ @@ -680,7 +680,7 @@ interface HoverApi { leave: () => void; } interface ClickApi { - open: (card: CardRef) => void; + open: (card: PreviewCard) => void; } const HoverCtx = createContext(null); const ClickCtx = createContext(null); @@ -693,7 +693,7 @@ const ClickCtx = createContext(null); */ export function HoverPreviewProvider({ children }: { children: ReactNode }) { const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null); - const [clicked, setClicked] = useState<{ card: CardRef; showBack: boolean } | null>(null); + const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null); const enter = useCallback((card: CardRef, e: React.MouseEvent) => { if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY }); @@ -703,7 +703,7 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) { }, []); const leave = useCallback(() => setHover(null), []); - const open = useCallback((card: CardRef) => { + const open = useCallback((card: PreviewCard) => { if (!card.image_uri) return; setHover(null); setClicked({ card, showBack: false }); @@ -747,7 +747,7 @@ 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) }; + return { open: (card: PreviewCard) => api?.open(card) }; } const PREVIEW_W = 320; @@ -795,35 +795,105 @@ const ROTATED_ASPECT = 680 / 488; /** The largest landscape box (matching `.preview-modal-img`'s own * `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated * horizontal-card preview. Recomputed on resize so it stays live like the - * CSS-only sizing used for normal (portrait) cards. */ -function useRotatedPreviewBox() { + * CSS-only sizing used for normal (portrait) cards. + * + * `compact` shrinks the budget to roughly match a DFC back face's own + * `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img` + * rule) and leaves headroom below for the info tile — pass it whenever the + * horizontal front face has a sibling back face, so a transformed Battle + * doesn't render at full single-card size next to a much smaller back face + * and push the info tile off-screen. */ +function useRotatedPreviewBox(compact: boolean) { const compute = () => { - const boundW = Math.min(720, window.innerWidth * 0.9); - const boundH = window.innerHeight * 0.9; + const boundW = compact ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9); + const boundH = compact ? window.innerHeight * 0.55 : window.innerHeight * 0.9; const w = Math.min(boundH, boundW * ROTATED_ASPECT); return { w, h: w / ROTATED_ASPECT }; }; const [box, setBox] = useState(compute); useEffect(() => { + setBox(compute()); const onResize = () => setBox(compute()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); - }, []); + }, [compact]); return box; } +/** Legality status pills for every tracked format, e.g. inside the + * click-preview modal's info tile. Not-legal formats are shown too (dimmed) + * so the list reads as a complete checklist rather than a partial one. */ +function LegalityPills({ legalities }: { legalities: number }) { + return ( +
+ {LEGALITY_FORMATS.map((f) => { + const status = legalStatusFor(legalities, f.label); + return ( + + {f.label} + + ); + })} +
+ ); +} + +/** Details panel shown alongside the card art in the click-to-preview modal: + * type/mana/color, price, ownership context (when the caller had a + * `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format + * legality. */ +function CardInfoTile({ card }: { card: PreviewCard }) { + return ( +
+
{card.name}
+
{card.type_line}
+
+ Mana value {card.mana_value} + +
+ {card.price_usd > 0 && ( +
${card.price_usd.toFixed(2)}
+ )} + {card.quantity_owned !== undefined && ( +
+ Owned + + {card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"} + +
+ )} + {card.decks && card.decks.length > 0 && ( +
+ In decks +
+ {card.decks.map((d) => ( + + {d} + + ))} +
+
+ )} +
+ Legalities + +
+
+ ); +} + function ClickPreviewModal({ state, onClose, onFlip, }: { - state: { card: CardRef; showBack: boolean }; + state: { card: PreviewCard; showBack: boolean }; onClose: () => void; onFlip: () => void; }) { const { card, showBack } = state; - const rotatedBox = useRotatedPreviewBox(); const hasBack = !!card.back_image_uri; + const rotatedBox = useRotatedPreviewBox(hasBack); // Both faces are always in the DOM when the card has one; a wide-viewport // media query shows them side by side (no flip needed), while a narrow one // shows only the "active" face and the flip button toggles between them. @@ -863,15 +933,16 @@ function ClickPreviewModal({ alt={`${card.name} (back)`} /> )} + + + + {hasBack && ( + + )} - - - - {hasBack && ( - - )} + ); diff --git a/src/styles.css b/src/styles.css index fd6600c..9ccf684 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1503,6 +1503,8 @@ tbody tr:hover { display: flex; align-items: center; justify-content: center; + overflow-y: auto; + padding: 24px 12px; background: rgba(10, 10, 12, 0.72); backdrop-filter: blur(2px); cursor: zoom-out; @@ -1516,9 +1518,22 @@ tbody tr:hover { opacity: 1; } } +/* The info tile (see `.preview-info`) sits beside the card art whenever + there's room, and wraps onto its own line below when there isn't — + plain flex-wrap rather than a hand-tuned width breakpoint, so neither the + art nor the info tile ever has to shrink to make the other fit. Any + remaining vertical overflow (e.g. a double-faced card's full-size faces + plus a tall info tile on a short window) is handled by + `.preview-modal-backdrop`'s own scrolling, not by clipping content. */ .preview-modal { position: relative; cursor: default; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + gap: 16px; + max-width: 96vw; } .preview-modal-faces { position: relative; @@ -1611,6 +1626,99 @@ tbody tr:hover { border-color: var(--accent); } +.preview-info { + width: min(320px, 90vw); + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: 18px; + padding: 18px; + box-shadow: var(--shadow-lg); + color: var(--text); +} +.preview-info-name { + font-size: 18px; + font-weight: 700; + line-height: 1.25; + color: var(--text-strong); +} +.preview-info-type { + font-size: 13px; + color: var(--muted); +} +.preview-info-row { + display: flex; + align-items: center; + gap: 10px; +} +.preview-info-mv { + font-size: 13px; + font-weight: 600; +} +.preview-info-price { + font-size: 16px; + font-weight: 700; + color: var(--gold); +} +.preview-info-section { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--border-soft); +} +.preview-info-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); +} +.preview-info-deck-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.preview-info-legal-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.legal-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; +} +.legal-pill.legal-legal { + background: rgba(75, 176, 115, 0.16); + border-color: rgba(75, 176, 115, 0.4); + color: var(--success); +} +.legal-pill.legal-restricted { + background: rgba(224, 163, 74, 0.16); + border-color: rgba(224, 163, 74, 0.4); + color: var(--warn); +} +.legal-pill.legal-banned { + background: rgba(216, 80, 63, 0.16); + border-color: rgba(216, 80, 63, 0.4); + color: var(--danger); + text-decoration: line-through; +} +.legal-pill.legal-not_legal { + border-color: var(--border-soft); + color: var(--faint); + opacity: 0.55; +} + .tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));