import { createContext, useCallback, useContext, useEffect, useRef, useState, type PropsWithChildren, } from "react"; import { createPortal } from "react-dom"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import type { Attachment } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; type LightboxState = { images: Attachment[]; index: number; caption?: string; postId?: string; } | null; type Ctx = { openLightbox: ( images: Attachment[], startIndex?: number, caption?: string, postId?: string, ) => void; closeLightbox: () => void; }; const LightboxContext = createContext(null); export function useLightbox(): Ctx { const ctx = useContext(LightboxContext); if (!ctx) throw new Error("useLightbox must be used inside ImageLightboxProvider"); return ctx; } export function ImageLightboxProvider({ children }: PropsWithChildren) { const [state, setState] = useState(null); const openLightbox = useCallback( ( images: Attachment[], startIndex = 0, caption?: string, postId?: string, ) => { if (!images.length) return; const i = Math.min(Math.max(0, startIndex), images.length - 1); setState({ images, index: i, caption, postId }); }, [], ); const closeLightbox = useCallback(() => setState(null), []); return ( {children} {state ? ( ) : null} ); } function Filmstrip({ images, index, onSelect, }: { images: Attachment[]; index: number; onSelect: (i: number) => void; }) { const activeRef = useRef(null); useEffect(() => { activeRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); }, [index]); return (
e.stopPropagation()} >
{images.map((image, i) => { const active = i === index; return ( ); })}
); } function LightboxView({ images, startIndex, caption: captionText, postId, onClose, }: { images: Attachment[]; startIndex: number; caption?: string; postId?: string; onClose: () => void; }) { const [index, setIndex] = useState(startIndex); const [isCaptionVisible, setIsCaptionVisible] = useState(true); const touchStartX = useRef(null); const goPrev = useCallback( () => setIndex((i) => (i - 1 + images.length) % images.length), [images.length], ); const goNext = useCallback( () => setIndex((i) => (i + 1) % images.length), [images.length], ); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft") goPrev(); if (e.key === "ArrowRight") goNext(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goPrev, goNext, onClose]); // Lock background scroll while the lightbox is open, then restore the exact // scroll position on close — otherwise dismissing the overlay leaves the page // scrolled back to the top instead of where the user was reading. useEffect(() => { const scrollY = window.scrollY; const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prevOverflow; window.scrollTo(0, scrollY); }; }, []); useEffect(() => { setIsCaptionVisible(true); }, [captionText, index]); const current = images[index]; const caption = captionText?.trim(); const showCaption = !!caption && isCaptionVisible; const hasMany = images.length > 1; if (!current) return null; return createPortal(
{/* Top bar: counter + actions */}
e.stopPropagation()} >
{hasMany ? ( {index + 1} / {images.length} ) : null}
{postId ? ( ) : null}
{/* Image stage */}
{hasMany ? ( <> ) : null}
{ e.stopPropagation(); if (caption) setIsCaptionVisible((visible) => !visible); }} onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }} onTouchEnd={(e) => { if (touchStartX.current == null) return; const dx = e.changedTouches[0].clientX - touchStartX.current; if (Math.abs(dx) > 40) { if (dx > 0) goPrev(); else goNext(); } touchStartX.current = null; }} > {current.filename}
{showCaption ? (
e.stopPropagation()} >
{autolink(caption)}
) : null}
{/* Bottom thumbnail filmstrip */} {hasMany ? ( ) : null}
, document.body, ); }