129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
'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>
|
|
);
|
|
}
|