diff --git a/components/feed/new-users-strip.tsx b/components/feed/new-users-strip.tsx
index e7c27fdb..6d5974e9 100644
--- a/components/feed/new-users-strip.tsx
+++ b/components/feed/new-users-strip.tsx
@@ -89,8 +89,13 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
- {/* Bleed to the section edges (-mx-5) but keep a px-5 gutter so the
- first/last tiles have breathing room as they scroll past. */}
+ {/* Bleed to the page edges, with a matching inner gutter so the first and
+ last tiles still have breathing room as they scroll past.
+ The negative margin MUST match the feed container's horizontal padding
+ (`main` is `px-4 sm:px-6`). It was `-mx-5` (20px) against a 16px
+ mobile padding, so the row hung 4px past each edge and gave the whole
+ document a horizontal scrollbar. Keep these three in lockstep with the
+ page padding if it ever changes. */}
{users.map((u) => (
void;
}) {
+ // Which attached photo the full-screen viewer is showing (null = closed).
+ // Owned here rather than inside the lightbox so the thumbnails can open it
+ // at their own index.
+ const [lightboxIndex, setLightboxIndex] = useState(null);
+
const sourceHref =
hasHighlight(post.source_url) || !post.caption
? post.source_url
@@ -125,10 +132,20 @@ export function PostCard({
: "grid-cols-3"
}`}
>
- {post.images.map((img) => (
-
(
+ // Each thumbnail opens the full-screen viewer at its own index.
+ // A
))}
) : null}
+ setLightboxIndex(null)}
+ />
+
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. */}
+