fix: new user scroll fix, add image lightbox
This commit is contained in:
@@ -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">
|
||||
“{caption.trim()}”
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user