"use client"; /** * CollectionScrollCard — Instagram-style horizontal carousel for one * collection. * * • Slide 1 is the collection's "cover" — cover image (or themed * gradient if none) overlaid with the title and item count. * • Slides 2..N are PostGridCards for each post in the collection. * * Posts are fetched lazily: an IntersectionObserver triggers * `collections.get(id)` the first time the carousel scrolls into view, * so a feed of many collections doesn't blow N+1 requests on mount. * * The carousel uses native CSS scroll-snap (horizontal, mandatory) so * touch swipes feel right on mobile. A page-dot indicator below tracks * the current slide. */ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { ChevronLeft, ChevronRight, Globe, Loader2, Lock, Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { collections, type Collection, type Post } from "@/lib/social"; import { PostGridCard } from "./post-grid-card"; import { quoteCardBgStyle } from "./post-card-shared"; type FetchState = "idle" | "loading" | "loaded" | "error"; export function CollectionScrollCard({ collection: c, href, initialPosts, onEdit, }: { collection: Collection; /** Link target for the "Open" footer + cover slide click-through. */ href: string; /** Pre-loaded posts — skip the lazy fetch when the parent already has * them (e.g. the single-collection page renders one card after its * own collections.get call). */ initialPosts?: Post[]; /** When provided, shows a compact Edit chip in the cover slide's * bottom-right corner. Owner-only — caller decides whether to pass it. */ onEdit?: () => void; }) { const scrollRef = useRef(null); const cardRef = useRef(null); // `fetched` only holds posts pulled by the lazy IntersectionObserver // fetch. When the parent supplies `initialPosts`, we derive `posts` // from it directly — no mirror-into-state effect required, which means // fresh data from the parent (e.g. after an edit) shows up on the very // next render without a setState-cascade. const [fetched, setFetched] = useState([]); const [fetchState, setFetchState] = useState( initialPosts ? "loaded" : "idle", ); const [activeIndex, setActiveIndex] = useState(0); const posts = initialPosts ?? fetched; // Fetch posts the first time the carousel enters the viewport (with a // 200px rootMargin so we kick it off just before it's visible). Skipped // entirely when `initialPosts` was provided (fetchState starts at // "loaded" in that case so the early-return below fires). useEffect(() => { if (fetchState !== "idle") return; const el = cardRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { observer.disconnect(); setFetchState("loading"); collections .get(c.id) .then(({ posts: rows }) => { setFetched(rows ?? []); setFetchState("loaded"); }) .catch(() => setFetchState("error")); return; } } }, { rootMargin: "200px" }, ); observer.observe(el); return () => observer.disconnect(); }, [c.id, fetchState]); // Track which slide is currently snapped. We use scrollLeft / slideWidth // rather than another observer because the slides are full-width siblings // — much cheaper to compute. function onScroll() { const el = scrollRef.current; if (!el) return; const slideWidth = el.offsetWidth; if (slideWidth === 0) return; setActiveIndex(Math.round(el.scrollLeft / slideWidth)); } function scrollByOne(direction: 1 | -1) { const el = scrollRef.current; if (!el) return; el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" }); } const slideCount = 1 + posts.length; const showArrows = slideCount > 1; return (
{/* Cover slide */} {/* Post slides (or placeholders while loading) */} {fetchState === "loaded" && posts.map((p) => (
))} {fetchState === "loading" ? (
) : null}
{showArrows ? ( <> scrollByOne(-1)} disabled={activeIndex === 0} /> scrollByOne(1)} disabled={activeIndex >= slideCount - 1} /> ) : null}
{/* Dot indicator */} {slideCount > 1 ? (
{Array.from({ length: slideCount }).map((_, i) => ( ))}
) : null}
); } // ─── Slide pieces ──────────────────────────────────────────────────────── function CoverSlide({ collection: c, href, onEdit, }: { collection: Collection; href: string; onEdit?: () => void; }) { // The slide wrapper hosts the Link AND any owner-action chip as // siblings. Nesting a button inside the Link is an a11y anti-pattern // (interactive nested in interactive), so we keep them peers and let // DOM z-order put the chip on top of the Link's bottom-right corner. return (
{c.cover_url ? ( <> {/* Light scrim — enough darkness at the bottom for the title and post-count line to read, but the top stays close to the original image so the cover photo isn't muted into a near-black plate. */}
) : (
)}
{c.is_public ? ( ) : ( )} Collection

{c.title}

{c.description ? (

{c.description}

) : null}

{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse →

{onEdit ? ( ) : null}
); } function CarouselArrow({ direction, onClick, disabled, }: { direction: "prev" | "next"; onClick: () => void; disabled: boolean; }) { const Icon = direction === "prev" ? ChevronLeft : ChevronRight; return ( ); }