fix: new user scroll fix, add image lightbox

This commit is contained in:
gosangam
2026-08-09 10:58:49 +05:30
parent ce373e3e03
commit df49e7afcc
4 changed files with 271 additions and 15 deletions
@@ -0,0 +1,168 @@
"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 <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.
*/
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(
<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. */}
<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"
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"
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>
</div>
) : null}
</div>,
document.body,
);
}