chore: code refactor
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"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/post-grid-card";
|
||||
import { quoteCardBgStyle } from "../post/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<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLElement>(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<Post[]>([]);
|
||||
const [fetchState, setFetchState] = useState<FetchState>(
|
||||
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 (
|
||||
<article ref={cardRef} className="space-y-3 select-none">
|
||||
<div className="relative max-w-xl mx-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
|
||||
// Hide scrollbar — the carousel is gesture-driven; the dot
|
||||
// indicator below tracks position.
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{/* Cover slide */}
|
||||
<CoverSlide collection={c} href={href} onEdit={onEdit} />
|
||||
|
||||
{/* Post slides (or placeholders while loading) */}
|
||||
{fetchState === "loaded" &&
|
||||
posts.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="w-full shrink-0 snap-start p-px"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<PostGridCard post={p} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fetchState === "loading" ? (
|
||||
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showArrows ? (
|
||||
<>
|
||||
<CarouselArrow
|
||||
direction="prev"
|
||||
onClick={() => scrollByOne(-1)}
|
||||
disabled={activeIndex === 0}
|
||||
/>
|
||||
<CarouselArrow
|
||||
direction="next"
|
||||
onClick={() => scrollByOne(1)}
|
||||
disabled={activeIndex >= slideCount - 1}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dot indicator */}
|
||||
{slideCount > 1 ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: slideCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-full transition-all",
|
||||
i === activeIndex
|
||||
? "size-1.5 bg-foreground"
|
||||
: "size-1.5 bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="w-full shrink-0 snap-start relative aspect-square">
|
||||
<Link
|
||||
href={href}
|
||||
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
|
||||
aria-label={`Open collection ${c.title}`}
|
||||
>
|
||||
{c.cover_url ? (
|
||||
<>
|
||||
<Image
|
||||
src={c.cover_url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* 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. */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/40 to-black/15" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
|
||||
{c.is_public ? (
|
||||
<Globe className="size-3" />
|
||||
) : (
|
||||
<Lock className="size-3" />
|
||||
)}
|
||||
Collection
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-6 sm:p-8",
|
||||
// Text colour follows the surface underneath: white over the
|
||||
// image's dark overlay, theme foreground over the flat
|
||||
// fallback so it inverts cleanly with the palette.
|
||||
c.cover_url ? "text-white" : "text-foreground",
|
||||
c.cover_url
|
||||
? "justify-end"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 wrap-break-words",
|
||||
c.cover_url && "drop-shadow-md",
|
||||
)}
|
||||
>
|
||||
{c.title}
|
||||
</p>
|
||||
{c.description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-sm line-clamp-2",
|
||||
c.cover_url
|
||||
? "text-white/85 max-w-md"
|
||||
: "text-muted-foreground max-w-md mx-auto",
|
||||
)}
|
||||
>
|
||||
{c.description}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
|
||||
c.cover_url ? "text-white/75" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse
|
||||
→
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit collection"
|
||||
title="Edit collection"
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
|
||||
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
|
||||
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
|
||||
"px-2.5 py-1 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselArrow({
|
||||
direction,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
direction: "prev" | "next";
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
|
||||
className={cn(
|
||||
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
|
||||
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
|
||||
"hover:bg-black/60 transition-all",
|
||||
"disabled:opacity-0 disabled:pointer-events-none",
|
||||
direction === "prev" ? "left-3" : "right-3",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user