feat: add page-flip animation, click and swipe navigation to deck viewer

Click left/right halves to navigate. Swipe on touch devices. 3D
perspective page-flip animation on transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 12:11:06 -08:00
parent 811190abd8
commit 3b63d62b95
1 changed files with 83 additions and 20 deletions

View File

@ -1,37 +1,100 @@
"use client" "use client"
import { useState } from "react" import { useState, useRef, useCallback } from "react"
import Image from "next/image" import Image from "next/image"
import { ChevronLeft, ChevronRight } from "lucide-react" import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
const TOTAL_SLIDES = 12 const TOTAL_SLIDES = 12
const SWIPE_THRESHOLD = 50
export default function SponsorshipFlipbook() { export default function SponsorshipFlipbook() {
const [currentSlide, setCurrentSlide] = useState(0) const [currentSlide, setCurrentSlide] = useState(0)
const [isFlipping, setIsFlipping] = useState(false)
const [flipDirection, setFlipDirection] = useState<"left" | "right">("left")
const touchStartX = useRef(0)
const touchEndX = useRef(0)
const prevSlide = () => setCurrentSlide((s) => Math.max(0, s - 1)) const goTo = useCallback(
const nextSlide = () => setCurrentSlide((s) => Math.min(TOTAL_SLIDES - 1, s + 1)) (direction: "prev" | "next") => {
if (isFlipping) return
if (direction === "prev" && currentSlide === 0) return
if (direction === "next" && currentSlide >= TOTAL_SLIDES - 1) return
setFlipDirection(direction === "next" ? "left" : "right")
setIsFlipping(true)
setTimeout(() => {
setCurrentSlide((s) =>
direction === "next"
? Math.min(TOTAL_SLIDES - 1, s + 1)
: Math.max(0, s - 1)
)
setIsFlipping(false)
}, 400)
},
[currentSlide, isFlipping]
)
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left
if (clickX < rect.width / 2) {
goTo("prev")
} else {
goTo("next")
}
}
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
touchEndX.current = e.touches[0].clientX
}
const handleTouchMove = (e: React.TouchEvent) => {
touchEndX.current = e.touches[0].clientX
}
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current
if (Math.abs(diff) > SWIPE_THRESHOLD) {
goTo(diff > 0 ? "next" : "prev")
}
}
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="relative w-full overflow-hidden rounded-xl border border-border shadow-lg bg-black"> <div
className="relative w-full overflow-hidden rounded-xl border border-border shadow-lg bg-black cursor-pointer select-none"
style={{ perspective: "1200px" }}
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div <div
className="flex transition-transform duration-500 ease-in-out" className="w-full"
style={{ transform: `translateX(-${currentSlide * 100}%)` }} style={{
transformOrigin:
flipDirection === "left" ? "left center" : "right center",
transition: isFlipping
? "transform 0.4s ease-in-out, opacity 0.4s ease-in-out"
: "none",
transform: isFlipping
? `rotateY(${flipDirection === "left" ? "-90deg" : "90deg"})`
: "rotateY(0deg)",
opacity: isFlipping ? 0 : 1,
}}
> >
{Array.from({ length: TOTAL_SLIDES }, (_, i) => ( <Image
<div key={i} className="w-full flex-shrink-0"> src={`/slides/slide-${String(currentSlide + 1).padStart(2, "0")}.jpg`}
<Image alt={`Sponsorship deck slide ${currentSlide + 1}`}
src={`/slides/slide-${String(i + 1).padStart(2, "0")}.jpg`} width={1200}
alt={`Sponsorship deck slide ${i + 1}`} height={675}
width={1200} className="w-full h-auto"
height={675} priority
className="w-full h-auto" draggable={false}
priority={i <= 1} />
/>
</div>
))}
</div> </div>
</div> </div>
@ -39,7 +102,7 @@ export default function SponsorshipFlipbook() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={prevSlide} onClick={() => goTo("prev")}
disabled={currentSlide === 0} disabled={currentSlide === 0}
aria-label="Previous slide" aria-label="Previous slide"
> >
@ -51,7 +114,7 @@ export default function SponsorshipFlipbook() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={nextSlide} onClick={() => goTo("next")}
disabled={currentSlide >= TOTAL_SLIDES - 1} disabled={currentSlide >= TOTAL_SLIDES - 1}
aria-label="Next slide" aria-label="Next slide"
> >