fix: carousel image lightbox

This commit is contained in:
gosangam
2026-08-09 11:11:35 +05:30
parent df49e7afcc
commit baf36d3d67
4 changed files with 220 additions and 130 deletions
+162 -101
View File
@@ -1,26 +1,33 @@
"use client";
/**
* PostImageLightbox — full-screen viewer for a post's attached photos.
* PostImageLightbox — full-screen, swipeable viewer for a post's 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.
* 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.
*
* Dismiss: tap the backdrop ring around the photo, the ✕, or press Escape.
* Navigate a multi-photo post with ← / → or the on-screen chevrons.
* 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 <body> 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.
* 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 } from "react";
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,
@@ -41,17 +48,40 @@ export function PostImageLightbox({
}) {
const open = index !== null;
const count = images.length;
const scrollerRef = useRef<HTMLDivElement>(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],
);
// 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]);
if (index === null) return;
scrollToIndex(index - 1, "smooth");
}, [index, scrollToIndex]);
const goNext = useCallback(() => {
if (index === null || count < 2) return;
onIndexChange((index + 1) % count);
}, [index, count, onIndexChange]);
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;
@@ -74,95 +104,126 @@ export function PostImageLightbox({
};
}, [open]);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
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;
const img = images[index];
if (!img) return null;
if (!images[index]) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={
count > 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. */}
return (
createPortal(
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-4 sm:inset-12"
>
<Image
// Keyed so switching photos swaps the element instead of
// cross-fading a stale bitmap into the new slot.
key={img.id}
src={img.url}
alt=""
fill
sizes="100vw"
className="object-contain"
unoptimized
priority
/>
</div>
<button
type="button"
role="dialog"
aria-modal="true"
aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
onClick={onClose}
aria-label="Close"
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
>
<X className="size-5" />
</button>
{count > 1 ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
aria-label="Previous photo"
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
aria-label="Next photo"
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<ChevronRight className="size-5" />
</button>
{/* Counter sits top-left so it never collides with the caption
scrim along the bottom. */}
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
{index + 1} / {count}
</span>
</>
) : 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() ? (
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-6">
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
&ldquo;{caption.trim()}&rdquo;
</p>
{/* 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. */}
<div
ref={scrollerRef}
onScroll={onScroll}
onPointerDown={onPointerDown}
onClick={(e) => {
// 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) => (
<div
key={img.id}
className="relative h-full w-full shrink-0 snap-center"
>
<Image
src={img.url}
alt=""
fill
sizes="100vw"
className="object-contain"
unoptimized
/>
</div>
))}
</div>
) : null}
</div>,
document.body,
<button
type="button"
onClick={onClose}
aria-label="Close"
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<X className="size-5" />
</button>
{count > 1 ? (
<>
<button
type="button"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
aria-label="Previous photo"
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
disabled={index === count - 1}
onClick={(e) => {
e.stopPropagation();
goNext();
}}
aria-label="Next photo"
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
<ChevronRight className="size-5" />
</button>
{/* Counter sits top-left so it never collides with the caption
scrim along the bottom. */}
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
{index + 1} / {count}
</span>
{/* Dot indicators, mirroring the in-feed carousel. */}
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
{images.map((img, i) => (
<span
key={img.id}
aria-hidden
className={`size-1.5 rounded-full transition-all ${
i === index ? "bg-white" : "bg-white/40"
}`}
/>
))}
</div>
</>
) : 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() ? (
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
&ldquo;{caption.trim()}&rdquo;
</p>
</div>
) : null}
</div>,
document.body,
)
);
}