feat: add image viewer, carousel, and pan/zoom features

This commit is contained in:
2026-05-29 18:56:20 -07:00
parent ac1890391c
commit 9e15f900ba
6 changed files with 721 additions and 0 deletions
+128
View File
@@ -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>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { useState } from "react";
import Image from "next/image";
import { IconZoomInFilled } from "@tabler/icons-react";
interface ProjectImageProps {
src: string;
alt: string;
caption?: string;
onClick?: () => void;
}
export default function ProjectImage({ src, alt, caption, onClick }: ProjectImageProps) {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
return (
<div
className="relative cursor-pointer"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
<Image
src={src}
alt={alt}
width={1200}
height={750}
className={`h-auto w-full rounded-lg border border-neutral-800`}
/>
<div className="flex items-center justify-center pt-2">
<p className="text-sm text-neutral-500">{caption ?? alt}</p>
</div>
<div className={`pointer-events-none absolute inset-0 z-100 hidden md:flex transition-opacity duration-300 justify-center items-center w-full h-full`}>
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
</div>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { IconX, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
import { usePanZoom } from '@/hooks/usePanZoom';
import type { Screenshot } from '@/constants/projects';
type ImageViewerProps = {
items: Screenshot[];
index: number;
onIndexChange: (i: number) => void;
onClose: () => void;
};
/** Caption to display — falls back to the required `alt` text. */
const captionOf = (s: Screenshot) => s.caption ?? s.alt;
/**
* Full-bleed fullscreen image viewer with pan / pinch / wheel zoom, keyboard
* navigation, and a thumbnail rail (vertical on desktop, horizontal at the
* bottom on mobile). Borrowed from imagelab-gpu's FullscreenImage, trimmed to
* the parts this portfolio needs — no slideshow.
*/
export default function ImageViewer({ items, index, onIndexChange, onClose }: ImageViewerProps) {
// Natural size is tagged with the index it was measured for, so it reads as
// null until the *current* image's onLoad fires — no reset effect needed.
const [sized, setSized] = useState<{ i: number; w: number; h: number } | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
const activeThumbRef = useRef<HTMLButtonElement>(null);
const len = items.length;
const item = items[index];
const prev = () => onIndexChange((index - 1 + len) % len);
const next = () => onIndexChange((index + 1) % len);
const { zoom, offset, reframing, handlers } = usePanZoom(stageRef, {
resetKey: index,
onSwipe: (dir) => onIndexChange((index + dir + len) % len),
onTap: onClose,
});
// Lock body scroll while the viewer is open.
useEffect(() => {
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = original; };
}, []);
// Keyboard navigation: arrows to move, Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') onIndexChange((index - 1 + len) % len);
else if (e.key === 'ArrowRight') onIndexChange((index + 1) % len);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [index, len, onClose, onIndexChange]);
// Keep the active thumbnail scrolled into view.
useEffect(() => {
activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [index]);
if (!item) return null;
const url = item.src;
return createPortal(
<div className="fixed inset-0 z-[80] flex flex-col overflow-hidden bg-black md:flex-row">
{/* Ambient backdrop sampled from the current image via heavy blur. */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt=""
aria-hidden
className="h-full w-full scale-125 object-cover opacity-40"
style={{ filter: 'blur(60px)' }}
/>
<div className="absolute inset-0 bg-black/60" />
</div>
{/* Stage — owns the pan/zoom gestures. */}
<div
ref={stageRef}
className="relative z-10 min-h-0 flex-1 overflow-hidden"
style={{ touchAction: 'none' }}
onWheel={handlers.onWheel}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerUp}
onClick={handlers.onClick}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={item.alt}
draggable={false}
onLoad={(e) => setSized({ i: index, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
className="max-h-full max-w-full select-none rounded-md shadow-[0_20px_70px_rgba(0,0,0,0.7)]"
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transition: reframing ? 'transform 250ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined,
}}
/>
</div>
{/* Prev / next */}
{len > 1 && (
<>
<button
type="button"
aria-label="Previous image"
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
>
<IconChevronLeft size={24} />
</button>
<button
type="button"
aria-label="Next image"
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
>
<IconChevronRight size={24} />
</button>
</>
)}
{/* Close */}
<button
type="button"
aria-label="Close viewer"
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
>
<IconX size={18} />
</button>
{/* Page counter + dimensions */}
<div className="pointer-events-none absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/40 px-3 py-1.5 font-mono text-[12px] text-white/70 backdrop-blur-md">
<span>{index + 1} / {len}</span>
</div>
{/* Caption */}
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center px-16">
<p className="max-w-2xl rounded-full bg-black/40 px-4 py-1.5 text-center text-sm text-white/90 backdrop-blur-md">
{captionOf(item)}
</p>
</div>
</div>
{/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */}
{len > 1 && (
<div className="z-10 flex shrink-0 gap-2 overflow-x-auto border-t border-white/10 bg-black/40 p-3 backdrop-blur-md md:w-32 md:flex-col md:overflow-x-visible md:overflow-y-auto md:border-l md:border-t-0">
{items.map((shot, i) => {
const isActive = i === index;
return (
<button
key={shot.src}
ref={isActive ? activeThumbRef : null}
type="button"
aria-label={`View ${captionOf(shot)}`}
aria-current={isActive}
onClick={() => onIndexChange(i)}
className={`relative shrink-0 overflow-hidden rounded-md border transition-all md:w-full ${
isActive
? 'border-white/80 opacity-100 ring-2 ring-white/60'
: 'border-white/10 opacity-60 hover:opacity-100'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt=""
aria-hidden
className="h-16 w-24 object-cover md:h-20 md:w-full"
/>
</button>
);
})}
</div>
)}
</div>,
document.body,
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { IconXFilled } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { createPortal } from 'react-dom';
type ModalProps = {
children?: React.ReactNode;
headline?: string;
open?: boolean;
setOpen?: (open: boolean) => void;
};
export default function Modal({ children, headline, open, setOpen }: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original;
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && handleClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
const handleClose = () => {
if (setOpen) setOpen(false);
};
if (!mounted || !open) return null;
return createPortal(
<div
onClick={handleClose}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xl"
>
<div
onClick={(e) => e.stopPropagation()}
className="fixed inset-0 m-2 sm:m-24 sm:mx-64 xs:mx-64 flex flex-col rounded-md border border-neutral-700 bg-neutral-900/50 p-2"
>
<nav className="flex justify-between items-center p-2">
<div className="text-lg font-medium text-center w-full">
{headline}
</div>
<button
onClick={handleClose}
className="text-neutral-400 hover:text-neutral-200 rounded-md p-1 transition-colors duration-200 cursor-pointer"
aria-label="Close modal"
>
<IconXFilled />
</button>
</nav>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-5">
{children}
</div>
</div>
</div>,
document.body
);
}