"use client"; /** * PostImageLightbox — full-screen, swipeable viewer for a post's photos. * * Opened by tapping any image on a PostCard or PostScrollCard. Controlled by * the caller: `index` is the photo on screen (null = closed), so the card owns * the state and this stays presentation-only. * * Navigation is a native CSS scroll-snap strip — the same primitive the feed * carousel uses — so swiping works on touch for free, and the ← / → keys and * chevrons simply scroll that strip. The scroller is the single source of * truth: buttons/keys never set `index` directly, they scroll, and the scroll * handler reports the settled index back up. That one-way flow is what keeps a * programmatic scroll from fighting the user's own gesture. * * Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps * are distinguished from swipes by useTapGuard, so navigating never closes. * * Rendered through a portal onto
so it escapes the feed's virtualised, * transformed rows — an overlay nested inside one would be positioned against * that transform and clipped by its stacking context, not the viewport. */ import { useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import type { PostImage } from "@/lib/social"; import { useTapGuard } from "./use-tap-guard"; export function PostImageLightbox({ images, index, caption, onIndexChange, onClose, }: { images: PostImage[]; /** Photo currently shown; null closes the viewer. */ index: number | null; /** The post's caption, shown in a gradient scrim along the bottom. * Clamped to three lines with an ellipsis so a long passage can't cover * the photo. Omit (or pass empty) to hide the scrim entirely. */ caption?: string | null; onIndexChange: (i: number) => void; onClose: () => void; }) { const open = index !== null; const count = images.length; const scrollerRef = useRef“{caption.trim()}”