diff --git a/src/components.tsx b/src/components.tsx index 158fdc0..4e4a72f 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useRef, useState, } from "react"; @@ -896,6 +897,35 @@ function CardInfoTile({ card }: { card: PreviewCard }) { ); } +type PreviewPane = "card" | "info"; + +/** Segmented control for the compact (one-pane-at-a-time) click preview: + * front face / back face (double-faced cards only) / info tile. */ +function PreviewPaneToggle({ + hasBack, + showBack, + pane, + onSelect, +}: { + hasBack: boolean; + showBack: boolean; + pane: PreviewPane; + onSelect: (pane: PreviewPane, back: boolean) => void; +}) { + const btn = (label: string, active: boolean, onClick: () => void) => ( + + ); + return ( + + {btn(hasBack ? "Front" : "Card", pane === "card" && !showBack, () => onSelect("card", false))} + {hasBack && btn("Back", pane === "card" && showBack, () => onSelect("card", true))} + {btn("Info", pane === "info", () => onSelect("info", showBack))} + + ); +} + function ClickPreviewModal({ state, onClose, @@ -908,15 +938,64 @@ function ClickPreviewModal({ const { card, showBack } = state; const hasBack = !!card.back_image_uri; const rotatedBox = useRotatedPreviewBox(hasBack); + const backdropRef = useRef(null); + const modalRef = useRef(null); + // Compact mode: when the full layout (art + info tile) would be taller than + // the viewport — i.e. on phones, where the tile wraps below the art — show + // one pane at a time behind a Front/Back/Info toggle instead of making the + // user scroll. Decided by measuring the full layout itself: every resize + // drops back to it, and the layout effect below re-measures and switches + // before paint, so there's no flash and no feedback loop (compact mode is + // never what gets measured). The art's size doesn't depend on the image + // having loaded (see `.preview-modal-img`), so measuring on mount is exact. + const [compact, setCompact] = useState(false); + const [pane, setPane] = useState("card"); + const [measureKey, setMeasureKey] = useState(0); + useEffect(() => { + const onResize = () => { + setCompact(false); + setMeasureKey((k) => k + 1); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + useLayoutEffect(() => { + const backdrop = backdropRef.current; + const modal = modalRef.current; + if (compact || !backdrop || !modal) return; + const cs = getComputedStyle(backdrop); + const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + if (modal.getBoundingClientRect().height > avail) setCompact(true); + }, [compact, measureKey, card, showBack]); + + const selectPane = (next: PreviewPane, back: boolean) => { + setPane(next); + if (back !== showBack) onFlip(); + }; + // 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. return ( -
+
e.stopPropagation()} > + {compact && ( +
+ + +
+ )}
{/* Only the front face can be horizontal (Battles) — a transformed Battle's back face is always a normal, correctly-oriented diff --git a/src/styles.css b/src/styles.css index 72b0ba4..7da7c7d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2018,10 +2018,19 @@ tbody tr:hover { .preview-modal-faces { position: relative; } +/* Sized explicitly (Scryfall "normal" images are 488x680) rather than from + the image's intrinsic size, so the box is already its final size before the + image loads — ClickPreviewModal measures the layout on mount to decide on + compact mode. Same result as the old intrinsic sizing capped at 90vw/90vh; + `--preview-img-h` is the height budget, shrunk in compact mode. */ +.preview-modal { + --preview-img-h: 90vh; +} .preview-modal-img { display: none; + width: min(488px, 90vw, calc(var(--preview-img-h) * 488 / 680)); + aspect-ratio: 488 / 680; max-width: min(720px, 90vw); - max-height: 90vh; border-radius: 18px; box-shadow: var(--shadow-lg); border: 1px solid rgba(255, 255, 255, 0.08); @@ -2075,6 +2084,43 @@ tbody tr:hover { display: none; } } +/* Compact mode (see ClickPreviewModal): the full layout would be taller than + the viewport, so a toolbar (pane toggle + close) shows one pane at a time — + a single face, or the info tile — with the art shrunk to fit the height + left over after the backdrop padding, toolbar and gap. */ +.preview-modal-compact { + /* A column (not the base wrapping row) so the toolbar stretches to exactly + the visible pane's width instead of the row's full 96vw. */ + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + --preview-img-h: calc( + 100vh - 48px - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 36px - 16px + ); +} +.preview-modal-bar { + flex-basis: 100%; + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.preview-modal-compact .preview-modal-faces { + display: block; +} +.preview-modal-compact .preview-modal-img:not(.active) { + display: none; +} +.preview-modal-compact .preview-modal-img:not(.preview-modal-img-rotated) { + max-width: min(720px, 90vw); +} +.preview-modal-compact .preview-modal-close, +.preview-modal-compact .preview-modal-flip, +.preview-modal-compact:not(.preview-modal-show-info) .preview-info, +.preview-modal-show-info .preview-modal-faces { + display: none; +} .preview-modal-close { position: absolute; top: -14px;