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. */} +
e.stopPropagation()} + className="absolute inset-4 sm:inset-12" + > + +
+ + + + {count > 1 ? ( + <> + + + {/* Counter sits top-left so it never collides with the caption + scrim along the bottom. */} + + {index + 1} / {count} + + + ) : 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() ? ( +
+

+ “{caption.trim()}” +

+
+ ) : null} +
, + document.body, + ); +} diff --git a/components/social/post/post-scroll-card.tsx b/components/social/post/post-scroll-card.tsx index 70cd69d2..8814b071 100644 --- a/components/social/post/post-scroll-card.tsx +++ b/components/social/post/post-scroll-card.tsx @@ -40,6 +40,7 @@ import { useAuth } from "@/components/auth/auth-provider"; import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { PostActionsFooter } from "./post-actions-footer"; import { PostCardMenu } from "./post-card-menu"; +import { PostImageLightbox } from "./post-image-lightbox"; import { FeedFollowButton } from "./feed-follow-button"; import { UpdateBackgroundModal } from "../update-background-modal"; import { MentionsDrawer } from "../mentions-drawer"; @@ -68,6 +69,8 @@ export function PostScrollCard({ const { user } = useAuth(); const isAuthor = !!user && user.id === post.user_id; const [bgModal, setBgModal] = useState(false); + // Which attached photo the full-screen viewer is showing (null = closed). + const [lightboxIndex, setLightboxIndex] = useState(null); const [expanded, setExpanded] = useState(false); const [mentionsOpen, setMentionsOpen] = useState(false); // Live mention count — seeded from the hydrated post, adjusted when the @@ -176,7 +179,7 @@ export function PostScrollCard({ {/* Media — image carousel or gradient-with-quote */}
{hasImages ? ( - + ) : ( )} @@ -225,14 +228,36 @@ export function PostScrollCard({ onClose={() => setMentionsOpen(false)} onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))} /> + + setLightboxIndex(null)} + /> ); } // ─── Pieces ────────────────────────────────────────────────────────────── -function ImageCarousel({ images }: { images: PostImage[] }) { +function ImageCarousel({ + images, + onOpen, +}: { + images: PostImage[]; + /** Tapping a slide opens the full-screen viewer at that index. */ + onOpen: (index: number) => void; +}) { const [active, setActive] = useState(0); + const scrollerRef = useRef(null); + // Where the pointer went down, plus the scroller's position at that moment. + // Swiping the carousel and tapping a photo have to stay independent: the + // carousel scrolls natively (CSS scroll-snap), and browsers still fire a + // click after some swipe gestures. Comparing both the pointer travel AND + // whether the strip actually scrolled lets us drop those. + const tapStart = useRef({ x: 0, y: 0, scrollLeft: 0 }); function onScroll(e: React.UIEvent) { const el = e.currentTarget; @@ -241,9 +266,35 @@ function ImageCarousel({ images }: { images: PostImage[] }) { setActive(Math.round(el.scrollLeft / w)); } + function onPointerDown(e: React.PointerEvent) { + tapStart.current = { + x: e.clientX, + y: e.clientY, + scrollLeft: scrollerRef.current?.scrollLeft ?? 0, + }; + } + + /** True only for a genuine tap — barely any pointer travel and no scroll. */ + function wasTap(e: React.MouseEvent) { + const moved = + Math.abs(e.clientX - tapStart.current.x) > 8 || + Math.abs(e.clientY - tapStart.current.y) > 8; + const scrolled = + Math.abs( + (scrollerRef.current?.scrollLeft ?? 0) - tapStart.current.scrollLeft, + ) > 4; + return !moved && !scrolled; + } + if (images.length === 1) { + // Single image: no scroller, so every click is unambiguously a tap. return ( -
+
+ ); } return (
{images.map((img, i) => ( -
{ + if (!wasTap(e)) return; // that was a swipe — let it scroll + onOpen(i); + }} aria-roledescription="slide" - aria-label={`${i + 1} of ${images.length}`} + aria-label={`View photo ${i + 1} of ${images.length}`} + className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > -
+ ))}