feat: add image viewer, carousel, and pan/zoom features
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconChevronLeft, IconChevronRight, IconZoomInFilled } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import ImageViewer from "@/components/ui/ImageViewer";
|
||||
import type { Screenshot } from "@/constants/projects";
|
||||
|
||||
interface ProjectCarouselProps {
|
||||
screenshots: Screenshot[];
|
||||
}
|
||||
|
||||
/** Milliseconds each slide stays up before auto-advancing. */
|
||||
const AUTOPLAY_MS = 5000;
|
||||
|
||||
export default function ProjectCarousel({ screenshots }: ProjectCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const len = screenshots.length;
|
||||
const go = (i: number) => setIndex(((i % len) + len) % len);
|
||||
|
||||
useEffect(() => {
|
||||
if (len < 2 || paused || viewerOpen) return;
|
||||
const t = setInterval(() => setIndex((i) => (i + 1) % len), AUTOPLAY_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [len, paused, viewerOpen]);
|
||||
|
||||
if (len === 0) return null;
|
||||
|
||||
const active = screenshots[index];
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, i: number) => {
|
||||
e.stopPropagation();
|
||||
go(i);
|
||||
setPaused(true);
|
||||
setTimeout(() => setPaused(false), AUTOPLAY_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => { setPaused(true); setIsHovered(true); }}
|
||||
onMouseLeave={() => { setPaused(false); setIsHovered(false); }}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={() => setPaused(false)}
|
||||
>
|
||||
<div
|
||||
className="relative aspect-[8/5] w-full cursor-pointer overflow-hidden rounded-lg bg-black/50"
|
||||
onClick={() => setViewerOpen(true)}
|
||||
>
|
||||
{screenshots.map((shot, i) => (
|
||||
<Image
|
||||
key={shot.src}
|
||||
src={shot.src}
|
||||
alt={shot.alt}
|
||||
fill
|
||||
sizes="(min-width: 768px) 720px, 100vw"
|
||||
priority={i === 0}
|
||||
className={`object-contain transition-opacity duration-500 ${
|
||||
i === index ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 hidden items-center justify-center md:flex">
|
||||
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<p key={index} className="animate-fade-in text-sm text-neutral-500">
|
||||
{active.caption ?? active.alt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{len > 1 && (
|
||||
<div className="flex justify-center gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
onClick={(e) => { handleButtonClick(e, index - 1); }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||
>
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
{screenshots.map((shot, i) => (
|
||||
<button
|
||||
key={shot.src}
|
||||
type="button"
|
||||
aria-label={`Go to image ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
onClick={() => go(i)}
|
||||
className={`h-3 rounded-full transition-all cursor-pointer ${
|
||||
i === index ? "w-6 bg-neutral-300" : "w-3 bg-neutral-700 hover:bg-neutral-500"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
onClick={(e) => { handleButtonClick(e, index + 1); }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||
>
|
||||
<IconChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewerOpen && (
|
||||
<ImageViewer
|
||||
items={screenshots}
|
||||
index={index}
|
||||
onIndexChange={go}
|
||||
onClose={() => setViewerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user