"use client"; /** * PostImageLightbox — full-screen viewer for a post's attached photos. * * Opened by tapping any image on a PostCard. Controlled by the caller: `index` * is the photo being shown (null = closed), so the card owns the state and this * component stays a pure presentation layer. * * Dismiss: tap the backdrop ring around the photo, the ✕, or press Escape. * Navigate a multi-photo post with ← / → or the on-screen chevrons. * * Rendered through a portal onto so it escapes the feed's virtualised, * transformed rows — an overlay nested inside one of those would be clipped by * the row's stacking context and positioned against the transform, not the * viewport. */ import { useCallback, useEffect } 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"; 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; // Wrap around at both ends so a long press on → never dead-ends. const goPrev = useCallback(() => { if (index === null || count < 2) return; onIndexChange((index - 1 + count) % count); }, [index, count, onIndexChange]); const goNext = useCallback(() => { if (index === null || count < 2) return; onIndexChange((index + 1) % count); }, [index, count, onIndexChange]); 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]); if (!open || typeof document === "undefined") return null; const img = images[index]; if (!img) 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 photo sits inset from the edges, leaving a genuine backdrop ring that closes on tap. `fill` resolves against this box (padding would NOT inset an absolutely-positioned child), hence inset-* rather than p-*. stopPropagation keeps a tap on the photo itself from closing. */}
e.stopPropagation()} className="absolute inset-4 sm:inset-12" >
{count > 1 ? ( <> {/* Counter sits top-left so it never collides with the caption scrim along the bottom. */} {index + 1} / {count} ) : null} {/* Caption scrim. `pointer-events-none` so it never intercepts a tap meant for the backdrop — the whole strip stays click-to-close — and the gradient fades upward so the photo's lower edge stays readable rather than being cut by a hard band. */} {caption?.trim() ? (

“{caption.trim()}”

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