"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(null); const { onPointerDown, wasTap } = useTapGuard(scrollerRef); /** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys * should be able to reach exactly what a swipe can, and you can't swipe * past the end. */ const scrollToIndex = useCallback( (i: number, behavior: ScrollBehavior) => { const el = scrollerRef.current; if (!el) return; const target = Math.max(0, Math.min(i, count - 1)); el.scrollTo({ left: target * el.offsetWidth, behavior }); }, [count], ); const goPrev = useCallback(() => { if (index === null) return; scrollToIndex(index - 1, "smooth"); }, [index, scrollToIndex]); const goNext = useCallback(() => { if (index === null) return; scrollToIndex(index + 1, "smooth"); }, [index, scrollToIndex]); // Jump to the tapped photo when the viewer opens. Deliberately keyed on // `open` only — re-running on every `index` change would yank the strip back // mid-swipe, since scrolling is what updates `index` in the first place. useEffect(() => { if (!open) return; scrollToIndex(index ?? 0, "auto"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); else if (e.key === "ArrowLeft") goPrev(); else if (e.key === "ArrowRight") goNext(); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, onClose, goPrev, goNext]); // Lock body scroll while open so the feed underneath doesn't drift. useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [open]); function onScroll(e: React.UIEvent) { const el = e.currentTarget; const w = el.offsetWidth; if (w === 0) return; const i = Math.round(el.scrollLeft / w); if (i !== index && i >= 0 && i < count) onIndexChange(i); } if (!open || typeof document === "undefined") return null; if (!images[index]) return null; return ( createPortal(
1 ? `Photo ${index + 1} of ${count}` : "Photo"} onClick={onClose} className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0" > {/* The strip sits inset from the edges, leaving a backdrop ring that also closes. `absolute inset-*` rather than padding because padding does NOT inset an absolutely-positioned `fill` child. */}
{ // Swiping between photos must never dismiss the viewer; only a // genuine tap falls through to the backdrop's onClose. if (!wasTap(e)) e.stopPropagation(); }} className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" > {images.map((img, i) => (
))}
{count > 1 ? ( <> {/* Counter sits top-left so it never collides with the caption scrim along the bottom. */} {index + 1} / {count} {/* Dot indicators, mirroring the in-feed carousel. */}
{images.map((img, i) => ( ))}
) : null} {/* Caption scrim. `pointer-events-none` so it never intercepts a tap meant for the backdrop, and the gradient fades upward so the photo's lower edge stays readable rather than cut by a hard band. */} {caption?.trim() ? (

“{caption.trim()}”

) : null}
, document.body, ) ); }