From eaf695a2810779d45544c11ce9d2e9b66cbe0fb3 Mon Sep 17 00:00:00 2001 From: Kishan Asokan Date: Thu, 26 Feb 2026 21:24:29 -0600 Subject: [PATCH 1/3] Add image zoom/lightbox feature for puzzle images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to inspect radiological images more closely with a fullscreen lightbox modal supporting scroll-to-zoom (desktop), pinch-to-zoom (mobile), and drag-to-pan on both platforms. Zero new dependencies — built entirely with CSS transforms and native touch/mouse/wheel event handlers. Co-Authored-By: Claude Opus 4.6 --- app/globals.css | 28 +++ components/GamePage.tsx | 38 +++- components/ImageZoomModal.tsx | 328 ++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 components/ImageZoomModal.tsx diff --git a/app/globals.css b/app/globals.css index 39f7149..608a8be 100644 --- a/app/globals.css +++ b/app/globals.css @@ -236,3 +236,31 @@ body { .animate-bar-fill { animation: bar-fill 0.5s ease-out backwards; } + +/* Image zoom modal — scale-up entrance */ +@keyframes zoom-modal-enter { + from { + opacity: 0; + transform: scale(0.92); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-zoom-modal-enter { + animation: zoom-modal-enter 0.2s ease-out forwards; +} + +/* Zoom hint toast — fade in, hold, fade out */ +@keyframes zoom-hint { + 0% { opacity: 0; transform: translateY(0.5rem); } + 15% { opacity: 1; transform: translateY(0); } + 75% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(0); } +} + +.animate-zoom-hint { + animation: zoom-hint 5s ease-out forwards; +} diff --git a/components/GamePage.tsx b/components/GamePage.tsx index 34bdb57..7d8c695 100644 --- a/components/GamePage.tsx +++ b/components/GamePage.tsx @@ -3,10 +3,12 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import Image from 'next/image'; import Link from 'next/link'; +import { ZoomIn } from 'lucide-react'; import { Puzzle, Hint, Condition } from '@/lib/supabase'; import GameClient from './GameClient'; import StatsModal from './StatsModal'; import FeedbackModal from './FeedbackModal'; +import ImageZoomModal from './ImageZoomModal'; import { GameState, getStatistics, Statistics } from '@/lib/localStorage'; interface GamePageProps { @@ -21,6 +23,12 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi const [gameState, setGameState] = useState(null); const [showStats, setShowStats] = useState(false); const [showFeedback, setShowFeedback] = useState(false); + const [showZoom, setShowZoom] = useState(false); + const zoomClosedAt = useRef(0); + const handleZoomClose = useCallback(() => { + zoomClosedAt.current = Date.now(); + setShowZoom(false); + }, []); const [stats, setStats] = useState({ gamesPlayed: 0, gamesWon: 0, @@ -86,6 +94,11 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi : null; const showAnnotated = !!annotatedImageUrl && (gameState?.guesses.length ?? 0) >= 1; + // Active image URL for zoom modal — show whichever version is currently displayed + const activeImageUrl = showAnnotated && annotatedImageUrl + ? annotatedImageUrl + : puzzle.image_url; + return (
{/* Gradient Background - fixed on desktop so it doesn't scroll with content */} @@ -207,7 +220,14 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi {/* Medical Image Display - sticky on mobile so it stays visible when keyboard opens */}
-
+
puzzle.image_url && Date.now() - zoomClosedAt.current > 300 && setShowZoom(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && puzzle.image_url) { e.preventDefault(); setShowZoom(true); } }} + aria-label="Click to zoom image" + > {puzzle.image_url ? (
)} + {/* Zoom indicator icon */} + {puzzle.image_url && ( +
+ +
+ )}
@@ -323,6 +349,16 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi onClose={() => setShowFeedback(false)} pageContext={isArchive ? `archive/day-${dayNumber}` : `day-${dayNumber}`} /> + + {/* Image Zoom Modal */} + {activeImageUrl && ( + + )}
); } diff --git a/components/ImageZoomModal.tsx b/components/ImageZoomModal.tsx new file mode 100644 index 0000000..8a192b6 --- /dev/null +++ b/components/ImageZoomModal.tsx @@ -0,0 +1,328 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import Image from 'next/image'; +import { X } from 'lucide-react'; + +interface ImageZoomModalProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + altText: string; +} + +export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: ImageZoomModalProps) { + const [scale, setScale] = useState(1); + const [translate, setTranslate] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [hasTransition, setHasTransition] = useState(true); + const [showHint, setShowHint] = useState(false); + + const dragStart = useRef({ x: 0, y: 0 }); + const translateStart = useRef({ x: 0, y: 0 }); + const containerRef = useRef(null); + + // Pinch-to-zoom refs + const initialPinchDistance = useRef(0); + const scaleAtPinchStart = useRef(1); + + // Interaction tracking — prevents close after zoom/pan/pinch gestures + const pointerDownPos = useRef({ x: 0, y: 0 }); + const interactionCooldown = useRef(false); + const cooldownTimer = useRef>(); + + // Drag-ready flags — set on pointer down, cleared on up, reset on modal open + const mouseDownReady = useRef(false); + const touchDownReady = useRef(false); + + // Refs mirroring state for native event listeners (avoid stale closures) + const isDraggingRef = useRef(false); + const scaleRef = useRef(1); + + // Set a cooldown that blocks close for a short period after zoom/pan activity + const startCooldown = useCallback(() => { + interactionCooldown.current = true; + clearTimeout(cooldownTimer.current); + cooldownTimer.current = setTimeout(() => { + interactionCooldown.current = false; + }, 300); + }, []); + + // Keep refs in sync with state for native event listeners + useEffect(() => { isDraggingRef.current = isDragging; }, [isDragging]); + useEffect(() => { scaleRef.current = scale; }, [scale]); + + // Reset state when modal opens + show hint + useEffect(() => { + if (isOpen) { + setScale(1); + setTranslate({ x: 0, y: 0 }); + setIsDragging(false); + setHasTransition(true); + interactionCooldown.current = false; + mouseDownReady.current = false; + touchDownReady.current = false; + clearTimeout(cooldownTimer.current); + + setShowHint(true); + const hideTimer = setTimeout(() => setShowHint(false), 5000); + return () => clearTimeout(hideTimer); + } else { + setShowHint(false); + } + }, [isOpen]); + + // Cleanup timer on unmount + useEffect(() => { + return () => clearTimeout(cooldownTimer.current); + }, []); + + // Lock body scroll + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + } + }, [isOpen]); + + // ESC to close + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + // Desktop: wheel zoom — native listener for passive:false + useEffect(() => { + if (!isOpen) return; + const container = containerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + startCooldown(); + setHasTransition(true); + setScale(prev => { + const delta = e.deltaY > 0 ? -0.15 : 0.15; + const newScale = Math.min(Math.max(prev + delta, 1), 4); + if (newScale <= 1) { + setTranslate({ x: 0, y: 0 }); + } + return newScale; + }); + }; + + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [isOpen, startCooldown]); + + // Mobile: touchmove — native listener for passive:false (preventDefault required) + useEffect(() => { + if (!isOpen) return; + const container = containerRef.current; + if (!container) return; + + const handleNativeTouchMove = (e: TouchEvent) => { + e.preventDefault(); + if (e.touches.length === 2) { + // Pinch zoom + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const distance = Math.sqrt(dx * dx + dy * dy); + const pinchScale = distance / initialPinchDistance.current; + const newScale = Math.min(Math.max(scaleAtPinchStart.current * pinchScale, 1), 4); + setScale(newScale); + startCooldown(); + if (newScale <= 1) { + setTranslate({ x: 0, y: 0 }); + } + } else if (e.touches.length === 1) { + // Start drag only once finger actually moves + if (touchDownReady.current && !isDraggingRef.current) { + const dx = e.touches[0].clientX - dragStart.current.x; + const dy = e.touches[0].clientY - dragStart.current.y; + if (Math.sqrt(dx * dx + dy * dy) >= 3) { + setIsDragging(true); + isDraggingRef.current = true; + setHasTransition(false); + startCooldown(); + } + return; + } + if (!isDraggingRef.current) return; + const dx = e.touches[0].clientX - dragStart.current.x; + const dy = e.touches[0].clientY - dragStart.current.y; + setTranslate({ + x: translateStart.current.x + dx / scaleRef.current, + y: translateStart.current.y + dy / scaleRef.current, + }); + } + }; + + container.addEventListener('touchmove', handleNativeTouchMove, { passive: false }); + return () => container.removeEventListener('touchmove', handleNativeTouchMove); + }, [isOpen, startCooldown]); + + // --- Pointer down: record position for all interactions --- + const handlePointerDown = useCallback((e: React.PointerEvent) => { + pointerDownPos.current = { x: e.clientX, y: e.clientY }; + }, []); + + // --- Pointer up: close only on clean tap (no drag, no zoom, no cooldown) --- + const handlePointerUp = useCallback((e: React.PointerEvent) => { + // Don't close during cooldown (just finished zooming/pinching) + if (interactionCooldown.current) return; + // Don't close if currently dragging + if (isDragging) return; + // Don't close if pointer moved significantly (was a drag) + const dx = e.clientX - pointerDownPos.current.x; + const dy = e.clientY - pointerDownPos.current.y; + if (Math.sqrt(dx * dx + dy * dy) >= 5) return; + // Clean tap — close the modal + onClose(); + }, [isDragging, onClose]); + + // --- Mouse handlers (desktop drag-to-pan) --- + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (scale <= 1) return; + e.preventDefault(); + mouseDownReady.current = true; + dragStart.current = { x: e.clientX, y: e.clientY }; + translateStart.current = { ...translate }; + }, [scale, translate]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + // Start dragging only once mouse actually moves (not on click) + if (mouseDownReady.current && !isDragging) { + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + if (Math.sqrt(dx * dx + dy * dy) >= 3) { + setIsDragging(true); + setHasTransition(false); + startCooldown(); + } + return; + } + if (!isDragging) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setTranslate({ + x: translateStart.current.x + dx / scale, + y: translateStart.current.y + dy / scale, + }); + }, [isDragging, scale, startCooldown]); + + const handleMouseUp = useCallback(() => { + mouseDownReady.current = false; + if (isDragging) { + setIsDragging(false); + setHasTransition(true); + startCooldown(); + } + }, [isDragging, startCooldown]); + + // --- Touch handlers (mobile drag + pinch-to-zoom) --- + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (e.touches.length === 2) { + // Pinch start + touchDownReady.current = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + initialPinchDistance.current = Math.sqrt(dx * dx + dy * dy); + scaleAtPinchStart.current = scale; + setHasTransition(false); + startCooldown(); + } else if (e.touches.length === 1 && scale > 1) { + // Ready to drag — but don't start until actual movement + touchDownReady.current = true; + dragStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + translateStart.current = { ...translate }; + } + }, [scale, translate, startCooldown]); + + const handleTouchEnd = useCallback(() => { + touchDownReady.current = false; + if (isDragging) { + setIsDragging(false); + isDraggingRef.current = false; + setHasTransition(true); + startCooldown(); + } + initialPinchDistance.current = 0; + }, [isDragging, startCooldown]); + + if (!isOpen) return null; + + return ( +
+ {/* Close button */} + + + {/* Zoomable image container — fills entire modal */} +
+ {/* Entrance animation wrapper — separate from zoom transform + so CSS animation doesn't override inline transform */} +
+ {/* Zoom/pan transform div */} +
1 + ? isDragging ? 'cursor-grabbing' : 'cursor-grab' + : 'cursor-default' + }`} + style={{ + transform: `scale(${scale}) translate(${translate.x}px, ${translate.y}px)`, + willChange: 'transform', + transition: hasTransition ? 'transform 0.15s ease-out' : 'none', + }} + > + {altText} +
+
+
+ + {/* Usage hint — shows briefly on first few opens */} + {showHint && ( +
+
+ Scroll to zoom · Drag to pan + Pinch to zoom · Drag to pan +
+
+ )} +
+ ); +} From 333b02e5361829a5a2953132a4952fdd8a223633 Mon Sep 17 00:00:00 2001 From: Kishan Asokan Date: Thu, 26 Feb 2026 21:31:57 -0600 Subject: [PATCH 2/3] Fix CI lint and type errors for image zoom feature - Remove isOpen prop; parent conditionally renders + uses key for fresh remount - Replace useRef counter with useState for zoom key (refs can't be read in render) - Provide initial value to cooldownTimer useRef (TypeScript strict mode) - Simplify effects by removing isOpen guards (component only mounts when open) Co-Authored-By: Claude Opus 4.6 --- components/GamePage.tsx | 9 +++--- components/ImageZoomModal.tsx | 55 ++++++++++++----------------------- 2 files changed, 23 insertions(+), 41 deletions(-) diff --git a/components/GamePage.tsx b/components/GamePage.tsx index 7d8c695..18f6f46 100644 --- a/components/GamePage.tsx +++ b/components/GamePage.tsx @@ -24,6 +24,7 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi const [showStats, setShowStats] = useState(false); const [showFeedback, setShowFeedback] = useState(false); const [showZoom, setShowZoom] = useState(false); + const [zoomKey, setZoomKey] = useState(0); const zoomClosedAt = useRef(0); const handleZoomClose = useCallback(() => { zoomClosedAt.current = Date.now(); @@ -222,7 +223,7 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi
puzzle.image_url && Date.now() - zoomClosedAt.current > 300 && setShowZoom(true)} + onClick={() => { if (puzzle.image_url && Date.now() - zoomClosedAt.current > 300) { setZoomKey(k => k + 1); setShowZoom(true); } }} role="button" tabIndex={0} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && puzzle.image_url) { e.preventDefault(); setShowZoom(true); } }} @@ -350,10 +351,10 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi pageContext={isArchive ? `archive/day-${dayNumber}` : `day-${dayNumber}`} /> - {/* Image Zoom Modal */} - {activeImageUrl && ( + {/* Image Zoom Modal — key forces fresh remount on each open */} + {showZoom && activeImageUrl && ( void; imageUrl: string; altText: string; } -export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: ImageZoomModalProps) { +export default function ImageZoomModal({ onClose, imageUrl, altText }: ImageZoomModalProps) { + // Parent conditionally renders this component and uses key={} to force remount + // on each open, so all state starts fresh — no reset logic needed. const [scale, setScale] = useState(1); const [translate, setTranslate] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [hasTransition, setHasTransition] = useState(true); - const [showHint, setShowHint] = useState(false); + const [showHint, setShowHint] = useState(true); const dragStart = useRef({ x: 0, y: 0 }); const translateStart = useRef({ x: 0, y: 0 }); @@ -29,9 +30,9 @@ export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: I // Interaction tracking — prevents close after zoom/pan/pinch gestures const pointerDownPos = useRef({ x: 0, y: 0 }); const interactionCooldown = useRef(false); - const cooldownTimer = useRef>(); + const cooldownTimer = useRef>(undefined); - // Drag-ready flags — set on pointer down, cleared on up, reset on modal open + // Drag-ready flags — set on pointer down, cleared on up const mouseDownReady = useRef(false); const touchDownReady = useRef(false); @@ -52,52 +53,35 @@ export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: I useEffect(() => { isDraggingRef.current = isDragging; }, [isDragging]); useEffect(() => { scaleRef.current = scale; }, [scale]); - // Reset state when modal opens + show hint + // Auto-hide hint after 5 seconds useEffect(() => { - if (isOpen) { - setScale(1); - setTranslate({ x: 0, y: 0 }); - setIsDragging(false); - setHasTransition(true); - interactionCooldown.current = false; - mouseDownReady.current = false; - touchDownReady.current = false; - clearTimeout(cooldownTimer.current); - - setShowHint(true); - const hideTimer = setTimeout(() => setShowHint(false), 5000); - return () => clearTimeout(hideTimer); - } else { - setShowHint(false); - } - }, [isOpen]); + if (!showHint) return; + const hideTimer = setTimeout(() => setShowHint(false), 5000); + return () => clearTimeout(hideTimer); + }, [showHint]); // Cleanup timer on unmount useEffect(() => { return () => clearTimeout(cooldownTimer.current); }, []); - // Lock body scroll + // Lock body scroll (component only mounts when open) useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; }; - } - }, [isOpen]); + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, []); // ESC to close useEffect(() => { - if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); + }, [onClose]); // Desktop: wheel zoom — native listener for passive:false useEffect(() => { - if (!isOpen) return; const container = containerRef.current; if (!container) return; @@ -117,11 +101,10 @@ export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: I container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); - }, [isOpen, startCooldown]); + }, [startCooldown]); // Mobile: touchmove — native listener for passive:false (preventDefault required) useEffect(() => { - if (!isOpen) return; const container = containerRef.current; if (!container) return; @@ -164,7 +147,7 @@ export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: I container.addEventListener('touchmove', handleNativeTouchMove, { passive: false }); return () => container.removeEventListener('touchmove', handleNativeTouchMove); - }, [isOpen, startCooldown]); + }, [startCooldown]); // --- Pointer down: record position for all interactions --- const handlePointerDown = useCallback((e: React.PointerEvent) => { @@ -254,8 +237,6 @@ export default function ImageZoomModal({ isOpen, onClose, imageUrl, altText }: I initialPinchDistance.current = 0; }, [isDragging, startCooldown]); - if (!isOpen) return null; - return (
Date: Thu, 26 Feb 2026 21:33:42 -0600 Subject: [PATCH 3/3] Fix keyboard zoom open missing key increment Ensure Enter/Space keyboard trigger also increments zoomKey for fresh remount, matching the click handler behavior. Co-Authored-By: Claude Opus 4.6 --- components/GamePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/GamePage.tsx b/components/GamePage.tsx index 18f6f46..b422226 100644 --- a/components/GamePage.tsx +++ b/components/GamePage.tsx @@ -226,7 +226,7 @@ export default function GamePage({ puzzle, hints, conditions, dayNumber, isArchi onClick={() => { if (puzzle.image_url && Date.now() - zoomClosedAt.current > 300) { setZoomKey(k => k + 1); setShowZoom(true); } }} role="button" tabIndex={0} - onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && puzzle.image_url) { e.preventDefault(); setShowZoom(true); } }} + onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && puzzle.image_url) { e.preventDefault(); setZoomKey(k => k + 1); setShowZoom(true); } }} aria-label="Click to zoom image" > {puzzle.image_url ? (