👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

preview card on hover

+170 -13
+135
src/components/HoverCardPreview.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { 3 + createContext, 4 + type ReactNode, 5 + useCallback, 6 + useContext, 7 + useEffect, 8 + useMemo, 9 + useState, 10 + } from "react"; 11 + import { createPortal } from "react-dom"; 12 + import { getCardByIdQueryOptions } from "@/lib/queries"; 13 + import type { ScryfallId } from "@/lib/scryfall-types"; 14 + import { CardImage } from "./CardImage"; 15 + 16 + interface HoverState { 17 + cardId: ScryfallId; 18 + position: { x: number; y: number }; 19 + } 20 + 21 + interface HoverCardPreviewContextValue { 22 + showPreview: (cardId: ScryfallId, e: React.MouseEvent) => void; 23 + updatePosition: (e: React.MouseEvent) => void; 24 + hidePreview: () => void; 25 + } 26 + 27 + const HoverCardPreviewContext = 28 + createContext<HoverCardPreviewContextValue | null>(null); 29 + 30 + export function useHoverCardPreview() { 31 + return useContext(HoverCardPreviewContext); 32 + } 33 + 34 + /** 35 + * Hook for card hover preview that auto-cleans up on unmount. 36 + * Returns props to spread on the hoverable element. 37 + */ 38 + export function useCardHover(cardId: ScryfallId) { 39 + const ctx = useContext(HoverCardPreviewContext); 40 + 41 + useEffect(() => { 42 + return () => ctx?.hidePreview(); 43 + }, [ctx]); 44 + 45 + return useMemo( 46 + () => ({ 47 + onMouseEnter: (e: React.MouseEvent) => ctx?.showPreview(cardId, e), 48 + onMouseMove: (e: React.MouseEvent) => ctx?.updatePosition(e), 49 + onMouseLeave: () => ctx?.hidePreview(), 50 + }), 51 + [ctx, cardId], 52 + ); 53 + } 54 + 55 + interface HoverCardPreviewProviderProps { 56 + children: ReactNode; 57 + } 58 + 59 + export function HoverCardPreviewProvider({ 60 + children, 61 + }: HoverCardPreviewProviderProps) { 62 + const [hover, setHover] = useState<HoverState | null>(null); 63 + 64 + const showPreview = useCallback((cardId: ScryfallId, e: React.MouseEvent) => { 65 + setHover({ cardId, position: { x: e.clientX, y: e.clientY } }); 66 + }, []); 67 + 68 + const updatePosition = useCallback((e: React.MouseEvent) => { 69 + setHover((prev) => 70 + prev ? { ...prev, position: { x: e.clientX, y: e.clientY } } : null, 71 + ); 72 + }, []); 73 + 74 + const hidePreview = useCallback(() => { 75 + setHover(null); 76 + }, []); 77 + 78 + const contextValue = useMemo( 79 + () => ({ showPreview, updatePosition, hidePreview }), 80 + [showPreview, updatePosition, hidePreview], 81 + ); 82 + 83 + return ( 84 + <HoverCardPreviewContext.Provider value={contextValue}> 85 + {children} 86 + {hover && <HoverCardPreviewPortal hover={hover} />} 87 + </HoverCardPreviewContext.Provider> 88 + ); 89 + } 90 + 91 + const PREVIEW_WIDTH = 256; // w-64 92 + const CARD_ASPECT = 7 / 5; // height = width * 1.4 93 + const PADDING = 16; 94 + 95 + interface HoverCardPreviewPortalProps { 96 + hover: HoverState; 97 + } 98 + 99 + function HoverCardPreviewPortal({ hover }: HoverCardPreviewPortalProps) { 100 + const { data: card } = useQuery(getCardByIdQueryOptions(hover.cardId)); 101 + 102 + if (typeof window === "undefined" || !card) return null; 103 + 104 + const previewHeight = PREVIEW_WIDTH * CARD_ASPECT; 105 + const { innerWidth: vw, innerHeight: vh } = window; 106 + 107 + // Position below and to the right of cursor 108 + let left = hover.position.x + PADDING; 109 + let top = hover.position.y + PADDING; 110 + 111 + // Flip to left if would overflow right 112 + if (left + PREVIEW_WIDTH > vw - PADDING) { 113 + left = hover.position.x - PREVIEW_WIDTH - PADDING; 114 + } 115 + 116 + // Flip above cursor if would overflow bottom 117 + if (top + previewHeight > vh - PADDING) { 118 + top = hover.position.y - previewHeight - PADDING; 119 + } 120 + 121 + // Final clamp to viewport 122 + left = Math.max(PADDING, Math.min(left, vw - PREVIEW_WIDTH - PADDING)); 123 + top = Math.max(PADDING, Math.min(top, vh - previewHeight - PADDING)); 124 + 125 + return createPortal( 126 + <div className="fixed z-50 pointer-events-none w-64" style={{ left, top }}> 127 + <CardImage 128 + card={card} 129 + size="normal" 130 + className="w-full shadow-2xl shadow-black/50" 131 + /> 132 + </div>, 133 + document.body, 134 + ); 135 + }
+25 -6
src/components/richtext/RichtextRenderer.tsx
··· 1 1 import { sanitizeUrl } from "@braintree/sanitize-url"; 2 2 import { Link } from "@tanstack/react-router"; 3 3 import { memo, type ReactNode } from "react"; 4 + import { useCardHover } from "@/components/HoverCardPreview"; 4 5 import type { 5 6 BulletListBlock, 6 7 CodeBlock, ··· 13 14 } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 14 15 import type { Main as Facet } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 15 16 import { segmentize } from "@/lib/richtext-convert"; 17 + import type { ScryfallId } from "@/lib/scryfall-types"; 16 18 17 19 type Block = 18 20 | ParagraphBlock ··· 206 208 return content; 207 209 } 208 210 211 + function CardRefLink({ 212 + scryfallId, 213 + children, 214 + }: { 215 + scryfallId: ScryfallId; 216 + children: ReactNode; 217 + }) { 218 + const hoverProps = useCardHover(scryfallId); 219 + 220 + return ( 221 + <Link 222 + to="/card/$id" 223 + params={{ id: scryfallId }} 224 + className="inline-flex items-center px-1.5 py-0.5 rounded bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 text-sm font-medium hover:bg-sky-100 dark:hover:bg-sky-900/50" 225 + {...hoverProps} 226 + > 227 + {children} 228 + </Link> 229 + ); 230 + } 231 + 209 232 function applyFeature( 210 233 content: ReactNode, 211 234 feature: Facet["features"][number], ··· 254 277 255 278 case "com.deckbelcher.richtext.facet#cardRef": 256 279 return ( 257 - <Link 258 - to="/card/$id" 259 - params={{ id: feature.scryfallId }} 260 - className="inline-flex items-center px-1.5 py-0.5 rounded bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 text-sm font-medium hover:bg-sky-100 dark:hover:bg-sky-900/50" 261 - > 280 + <CardRefLink scryfallId={feature.scryfallId as ScryfallId}> 262 281 {content} 263 - </Link> 282 + </CardRefLink> 264 283 ); 265 284 266 285 default:
+10 -7
src/routes/__root.tsx
··· 7 7 import { lazy, Suspense } from "react"; 8 8 import { Toaster } from "sonner"; 9 9 import Header from "../components/Header"; 10 + import { HoverCardPreviewProvider } from "../components/HoverCardPreview"; 10 11 import { WorkerStatusIndicator } from "../components/WorkerStatusIndicator"; 11 12 import { initializeApp } from "../lib/app-init"; 12 13 import { AuthProvider } from "../lib/useAuth"; ··· 116 117 <body> 117 118 <ThemeProvider> 118 119 <AuthProvider> 119 - <WorkerStatusIndicator /> 120 - <Header /> 121 - {children} 122 - <Suspense> 123 - <DevTools /> 124 - </Suspense> 125 - <ThemedToaster /> 120 + <HoverCardPreviewProvider> 121 + <WorkerStatusIndicator /> 122 + <Header /> 123 + {children} 124 + <Suspense> 125 + <DevTools /> 126 + </Suspense> 127 + <ThemedToaster /> 128 + </HoverCardPreviewProvider> 126 129 </AuthProvider> 127 130 </ThemeProvider> 128 131 <Scripts />