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:
parent
811190abd8
commit
3b63d62b95
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue