From 9e15f900ba5eb7db26fe0ee91d1b686045c917a9 Mon Sep 17 00:00:00 2001 From: angelmankel Date: Fri, 29 May 2026 18:56:20 -0700 Subject: [PATCH] feat: add image viewer, carousel, and pan/zoom features --- components/content/ProjectCarousel.tsx | 128 +++++++++++++++ components/content/ProjectImage.tsx | 48 ++++++ components/ui/ImageViewer.tsx | 191 ++++++++++++++++++++++ components/ui/Modal.tsx | 71 +++++++++ hooks/usePanZoom.ts | 213 +++++++++++++++++++++++++ lib/momentum.ts | 70 ++++++++ 6 files changed, 721 insertions(+) create mode 100644 components/content/ProjectCarousel.tsx create mode 100644 components/content/ProjectImage.tsx create mode 100644 components/ui/ImageViewer.tsx create mode 100644 components/ui/Modal.tsx create mode 100644 hooks/usePanZoom.ts create mode 100644 lib/momentum.ts diff --git a/components/content/ProjectCarousel.tsx b/components/content/ProjectCarousel.tsx new file mode 100644 index 0000000..b1cb185 --- /dev/null +++ b/components/content/ProjectCarousel.tsx @@ -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 ( +
+
{ setPaused(true); setIsHovered(true); }} + onMouseLeave={() => { setPaused(false); setIsHovered(false); }} + onFocusCapture={() => setPaused(true)} + onBlurCapture={() => setPaused(false)} + > +
setViewerOpen(true)} + > + {screenshots.map((shot, i) => ( + {shot.alt} + ))} + +
+ {isHovered && } +
+
+
+ +
+

+ {active.caption ?? active.alt} +

+
+ + {len > 1 && ( +
+ + {screenshots.map((shot, i) => ( + +
+ )} + + {viewerOpen && ( + setViewerOpen(false)} + /> + )} +
+ ); +} diff --git a/components/content/ProjectImage.tsx b/components/content/ProjectImage.tsx new file mode 100644 index 0000000..eb39014 --- /dev/null +++ b/components/content/ProjectImage.tsx @@ -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 ( +
+ {alt} +
+

{caption ?? alt}

+
+ + +
+ ); +} diff --git a/components/ui/ImageViewer.tsx b/components/ui/ImageViewer.tsx new file mode 100644 index 0000000..5cb11e4 --- /dev/null +++ b/components/ui/ImageViewer.tsx @@ -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(null); + const activeThumbRef = useRef(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( +
+ {/* Ambient backdrop sampled from the current image via heavy blur. */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ + {/* Stage — owns the pan/zoom gestures. */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.alt} 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, + }} + /> +
+ + {/* Prev / next */} + {len > 1 && ( + <> + + + + )} + + {/* Close */} + + + {/* Page counter + dimensions */} +
+ {index + 1} / {len} +
+ + {/* Caption */} +
+

+ {captionOf(item)} +

+
+
+ + {/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */} + {len > 1 && ( +
+ {items.map((shot, i) => { + const isActive = i === index; + return ( + + ); + })} +
+ )} +
, + document.body, + ); +} diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx new file mode 100644 index 0000000..032818a --- /dev/null +++ b/components/ui/Modal.tsx @@ -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( +
+
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" + > + +
+ {children} +
+
+
, + document.body + ); +} diff --git a/hooks/usePanZoom.ts b/hooks/usePanZoom.ts new file mode 100644 index 0000000..e1bc5f2 --- /dev/null +++ b/hooks/usePanZoom.ts @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import { THROW_MIN_DISTANCE, createVelocityTracker, runInertia } from '@/lib/momentum'; + +const ZOOM_MIN = 1; +const ZOOM_MAX = 8; +const SWIPE_THRESHOLD = 80; // px of horizontal flick (at zoom ~1) to navigate +const CLICK_BUFFER = 6; // px of travel under which a press counts as a tap + +type Options = { + resetKey: unknown; + onSwipe?: (dir: -1 | 1) => void; + onTap?: () => void; +}; + +export type PanZoom = { + zoom: number; + offset: { x: number; y: number }; + reframing: boolean; + handlers: { + onWheel: (e: React.WheelEvent) => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onClick: (e: React.MouseEvent) => void; + }; +}; + +/** + * Pan + pinch-zoom + wheel-zoom + release momentum for a CSS-transformed + * element. Pure gesture mechanics built on `lib/momentum` — the consumer + * supplies `onSwipe` for un-zoomed horizontal flicks and `onTap` for plain + * presses, and applies the returned `zoom`/`offset` as a transform. + */ +export function usePanZoom( + containerRef: RefObject, + { resetKey, onSwipe, onTap }: Options, +): PanZoom { + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [reframing, setReframing] = useState(false); + const reframeTimerRef = useRef(null); + + const reframe = () => { + setZoom(ZOOM_MIN); + setOffset({ x: 0, y: 0 }); + setReframing(true); + if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); + reframeTimerRef.current = window.setTimeout(() => setReframing(false), 260); + }; + + const pointersRef = useRef(new Map()); + const downPosRef = useRef<{ x: number; y: number } | null>(null); + const panStartRef = useRef<{ ox: number; oy: number } | null>(null); + const pinchStartRef = useRef<{ d: number; z: number; ox: number; oy: number; cx: number; cy: number } | null>(null); + const velocity = useRef(createVelocityTracker()); + const cancelInertiaRef = useRef<(() => void) | null>(null); + const zoomRef = useRef(zoom); + const offsetRef = useRef(offset); + useEffect(() => { zoomRef.current = zoom; }, [zoom]); + useEffect(() => { offsetRef.current = offset; }, [offset]); + + const stopInertia = () => { + cancelInertiaRef.current?.(); + cancelInertiaRef.current = null; + }; + + const [prevKey, setPrevKey] = useState(resetKey); + if (prevKey !== resetKey) { + setPrevKey(resetKey); + setZoom(ZOOM_MIN); + setOffset({ x: 0, y: 0 }); + setReframing(false); + } + + useEffect(() => { + return () => { + stopInertia(); + if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); + }; + }, [resetKey]); + useEffect(() => () => { + stopInertia(); + if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); + }, []); + + const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y); + + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + stopInertia(); + const r = containerRef.current?.getBoundingClientRect(); + if (!r) return; + const mx = e.clientX - r.left - r.width / 2; + const my = e.clientY - r.top - r.height / 2; + const factor = Math.exp(-e.deltaY * 0.001); + const z = zoomRef.current; + const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z * factor)); + // Hit the floor → reframe to the original framing. + if (newZoom <= ZOOM_MIN + 0.001 && z > ZOOM_MIN + 0.001) { + reframe(); + return; + } + const k = newZoom / z - 1; + setOffset(o => ({ x: o.x - mx * k, y: o.y - my * k })); + setZoom(newZoom); + }; + + const onPointerDown = (e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + stopInertia(); + e.currentTarget.setPointerCapture(e.pointerId); + const map = pointersRef.current; + map.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY }); + + if (map.size === 1) { + panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y }; + pinchStartRef.current = null; + downPosRef.current = { x: e.clientX, y: e.clientY }; + velocity.current.reset(e.clientX, e.clientY); + } else if (map.size === 2) { + downPosRef.current = null; + const [a, b] = [...map.values()]; + const r = containerRef.current!.getBoundingClientRect(); + pinchStartRef.current = { + d: dist(a, b), + z: zoomRef.current, + ox: offsetRef.current.x, + oy: offsetRef.current.y, + cx: (a.x + b.x) / 2 - r.left - r.width / 2, + cy: (a.y + b.y) / 2 - r.top - r.height / 2, + }; + panStartRef.current = null; + } + }; + + const onPointerMove = (e: React.PointerEvent) => { + const map = pointersRef.current; + const pt = map.get(e.pointerId); + if (!pt) return; + pt.x = e.clientX; + pt.y = e.clientY; + + if (map.size === 1 && panStartRef.current) { + const only = [...map.values()][0]; + setOffset({ + x: panStartRef.current.ox + (only.x - only.sx), + y: panStartRef.current.oy + (only.y - only.sy), + }); + velocity.current.sample(e.clientX, e.clientY); + } else if (map.size === 2 && pinchStartRef.current) { + const [a, b] = [...map.values()]; + const r = containerRef.current?.getBoundingClientRect(); + if (!r) return; + const start = pinchStartRef.current; + const newD = dist(a, b); + const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, start.z * newD / start.d)); + const ncx = (a.x + b.x) / 2 - r.left - r.width / 2; + const ncy = (a.y + b.y) / 2 - r.top - r.height / 2; + const imgPx = (start.cx - start.ox) / start.z; + const imgPy = (start.cy - start.oy) / start.z; + setZoom(newZoom); + setOffset({ x: ncx - newZoom * imgPx, y: ncy - newZoom * imgPy }); + } + }; + + const onPointerUp = (e: React.PointerEvent) => { + const map = pointersRef.current; + const pt = map.get(e.pointerId); + if (!pt) return; + const dx = pt.x - pt.sx; + const dy = pt.y - pt.sy; + map.delete(e.pointerId); + + if (map.size === 0) { + if (zoomRef.current <= ZOOM_MIN + 0.001 && (offsetRef.current.x !== 0 || offsetRef.current.y !== 0)) { + reframe(); + } else if (panStartRef.current) { + const moved = Math.hypot(dx, dy); + if (zoomRef.current <= 1.05) { + if (moved > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) { + onSwipe?.(dx > 0 ? -1 : 1); + } else { + setOffset({ x: 0, y: 0 }); + } + } else if (moved > THROW_MIN_DISTANCE) { + stopInertia(); + cancelInertiaRef.current = runInertia(velocity.current.release(), (sdx, sdy) => + setOffset(o => ({ x: o.x + sdx, y: o.y + sdy }))); + } + } + panStartRef.current = null; + pinchStartRef.current = null; + } else if (map.size === 1) { + const remaining = [...map.values()][0]; + remaining.sx = remaining.x; + remaining.sy = remaining.y; + panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y }; + pinchStartRef.current = null; + velocity.current.reset(remaining.x, remaining.y); + } + }; + + const onClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + const down = downPosRef.current; + downPosRef.current = null; + if (!down) return; + if (Math.hypot(e.clientX - down.x, e.clientY - down.y) <= CLICK_BUFFER) onTap?.(); + }; + + return { zoom, offset, reframing, handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp, onClick } }; +} diff --git a/lib/momentum.ts b/lib/momentum.ts new file mode 100644 index 0000000..ddccbbc --- /dev/null +++ b/lib/momentum.ts @@ -0,0 +1,70 @@ +export const THROW_MIN_DISTANCE = 14; +export const THROW_GRACE_MS = 24; +export const THROW_STALE_MS = 90; + +export function freshness(idleMs: number): number { + if (idleMs <= THROW_GRACE_MS) return 1; + return Math.max(0, 1 - (idleMs - THROW_GRACE_MS) / (THROW_STALE_MS - THROW_GRACE_MS)); +} + +export type Vec = { x: number; y: number }; + +export function createVelocityTracker() { + let last: { x: number; y: number; t: number } | null = null; + let vx = 0; + let vy = 0; + + return { + reset(x: number, y: number) { + last = { x, y, t: performance.now() }; + vx = 0; + vy = 0; + }, + sample(x: number, y: number) { + const now = performance.now(); + if (last) { + const dt = now - last.t; + if (dt > 0) { + vx = vx * 0.7 + ((x - last.x) / dt) * 0.3; + vy = vy * 0.7 + ((y - last.y) / dt) * 0.3; + } + } + last = { x, y, t: now }; + }, + release(): Vec { + const idle = last ? performance.now() - last.t : Infinity; + const f = freshness(idle); + return { x: vx * f, y: vy * f }; + }, + }; +} + +export function runInertia( + velocity: Vec, + onStep: (dx: number, dy: number) => void, + opts: { minSpeed?: number; stopSpeed?: number; decayPerFrame?: number } = {}, +): () => void { + const minSpeed = opts.minSpeed ?? 0.04; + const stopSpeed = opts.stopSpeed ?? 0.012; + const decayPerFrame = opts.decayPerFrame ?? 0.94; + let { x: vx, y: vy } = velocity; + if (Math.hypot(vx, vy) < minSpeed) return () => { /* nothing started */ }; + + let raf = 0; + let last = performance.now(); + const tick = (now: number) => { + const dt = Math.min(40, now - last); + last = now; + onStep(vx * dt, vy * dt); + const decay = Math.pow(decayPerFrame, dt / 16); + vx *= decay; + vy *= decay; + if (Math.hypot(vx, vy) > stopSpeed) { + raf = requestAnimationFrame(tick); + } else { + raf = 0; + } + }; + raf = requestAnimationFrame(tick); + return () => { if (raf) cancelAnimationFrame(raf); }; +}