feat: create ScreenshotLightbox component with zoom/pan and navigation (Task 1.1)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// components/ui/screenshot-lightbox.tsx
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface ScreenshotLightboxProps {
|
||||
screenshots: Array<{ id: string; url: string; width: number; height: number }>
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ScreenshotLightbox({
|
||||
screenshots,
|
||||
initialIndex,
|
||||
onClose,
|
||||
}: ScreenshotLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [zoomed, setZoomed] = useState(false)
|
||||
const hasMultiple = screenshots.length > 1
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setZoomed(false)
|
||||
setCurrentIndex((prev) => (prev < screenshots.length - 1 ? prev + 1 : prev))
|
||||
}, [screenshots.length])
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setZoomed(false)
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
}, [])
|
||||
|
||||
const toggleZoom = useCallback(() => {
|
||||
setZoomed((z) => !z)
|
||||
}, [])
|
||||
|
||||
// Keyboard handlers
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
} else if (e.key === "ArrowRight" && hasMultiple) {
|
||||
goNext()
|
||||
} else if (e.key === "ArrowLeft" && hasMultiple) {
|
||||
goPrev()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [onClose, goNext, goPrev, hasMultiple])
|
||||
|
||||
const current = screenshots[currentIndex]
|
||||
if (!current) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="lightbox-backdrop"
|
||||
className="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Previous arrow */}
|
||||
{hasMultiple && currentIndex > 0 && (
|
||||
<button
|
||||
onClick={goPrev}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasMultiple && currentIndex < screenshots.length - 1 && (
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image container — scrollable for panning when zoomed */}
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full overflow-auto"
|
||||
style={{ touchAction: zoomed ? "pan-x pan-y" : "pinch-zoom" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.url}
|
||||
alt={`Screenshot ${currentIndex + 1}`}
|
||||
className={`transition-transform duration-200 ${
|
||||
zoomed
|
||||
? "max-w-none cursor-zoom-out"
|
||||
: "max-w-[90vw] max-h-[90vh] object-contain cursor-zoom-in"
|
||||
}`}
|
||||
style={zoomed ? { width: current.width, height: current.height } : undefined}
|
||||
onClick={toggleZoom}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Counter */}
|
||||
{hasMultiple && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-xs font-medium">
|
||||
{currentIndex + 1} / {screenshots.length}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user