"use client"; import { ArrowUpRight, Clock } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { useCallback, useId, useMemo, useState } from "react"; import { Button } from "@/components/motion/button"; import { Loader } from "@/components/motion/loader"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export interface BlogPost { id: string; title: string; excerpt: string; category: string; author: { name: string; avatar?: string }; /** ISO date; shown in `locale`, UTC, so server and client agree. */ date: string; /** Minutes. */ readTime: number; /** Cover image URL. Without one a gradient from the theme's chart tokens is drawn. */ cover?: string; href?: string; /** The first featured post in the current filter gets the wide card. */ featured?: boolean; } export interface BlogListProps { posts: BlogPost[]; /** Chip order. Default: categories in order of first appearance. */ categories?: string[]; /** Controlled category; `"all"` shows everything. */ category?: string; defaultCategory?: string; onCategoryChange?: (category: string) => void; /** Posts revealed per "Load more". Default 6. */ pageSize?: number; /** * Fetch more posts when the local ones run out. Append them to `posts`; * the button shows a spinner until the promise settles. */ onLoadMore?: () => Promise | void; /** Whether `onLoadMore` can return more. Default false. */ hasMore?: boolean; /** Called for posts without `href`. */ onPostClick?: (post: BlogPost) => void; /** Label of the everything chip. Default "All". */ allLabel?: string; /** Default "en-US". */ locale?: string; className?: string; } /** Deterministic token pair for a cover, from the category name. */ function coverStyle(seed: string) { let hash = 0; for (const ch of seed) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; const a = (hash % 5) + 1; // Unsigned shift: `>>` goes negative once the hash passes 2^31. const second = ((hash >>> 3) % 5) + 1; const b = second === a ? (a % 5) + 1 : second; return { backgroundImage: `radial-gradient(circle at 80% 15%, color-mix(in oklab, var(--chart-${b}) 80%, transparent), transparent 55%), linear-gradient(135deg, var(--chart-${a}), color-mix(in oklab, var(--chart-${a}) 40%, var(--card)))`, }; } /** * Blog index block: category chips with a gliding pill, a wide featured post, * and a card grid (gradient or image cover, title, excerpt, author, read time, * en-US date) where cards lift on hover. Filtering reflows the grid with * shared layout; "Load more" appends the next page with a stagger, then falls * back to `onLoadMore` for server pagination. */ export function BlogList({ posts, categories: categoriesProp, category: categoryProp, defaultCategory = "all", onCategoryChange, pageSize = 6, onLoadMore, hasMore = false, onPostClick, allLabel = "All", locale = "en-US", className, }: BlogListProps) { const reduce = useReducedMotion(); const baseId = useId(); const [internal, setInternal] = useState(defaultCategory); const category = categoryProp ?? internal; const [count, setCount] = useState(pageSize); // Index from which cards were just appended; they stagger in from there. const [appendFrom, setAppendFrom] = useState(Number.POSITIVE_INFINITY); const [loading, setLoading] = useState(false); const setCategory = useCallback( (next: string) => { if (categoryProp === undefined) setInternal(next); onCategoryChange?.(next); setCount(pageSize); setAppendFrom(Number.POSITIVE_INFINITY); }, [categoryProp, onCategoryChange, pageSize], ); const categories = useMemo( () => categoriesProp ?? Array.from(new Set(posts.map((p) => p.category))), [categoriesProp, posts], ); const dateFormat = useMemo( () => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }), [locale], ); const filtered = category === "all" ? posts : posts.filter((p) => p.category === category); const featured = filtered.find((p) => p.featured); const rest = filtered.filter((p) => p !== featured); const shown = rest.slice(0, count); const canReveal = count < rest.length; const canFetch = !canReveal && Boolean(onLoadMore) && hasMore; const loadMore = async () => { setAppendFrom(shown.length); if (canReveal) { setCount(count + pageSize); return; } if (!onLoadMore) return; setLoading(true); try { await onLoadMore(); setCount(count + pageSize); } finally { setLoading(false); } }; // Entrance/exit/reflow for one grid slot; `delay` staggers appended cards. const appear = (delay: number) => ({ layout: reduce ? false : "position", initial: { opacity: 0, y: reduce ? 0 : 14 }, animate: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT, delay } }, exit: { opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.16 } }, transition: SPRING_LAYOUT, }) as const; const chips = [{ id: "all", label: allLabel },...categories.map((c) => ({ id: c, label: c }))]; return (
Filter by category {chips.map((chip) => { const active = chip.id === category; return ( ); })}
{featured ? ( ) : null}
    {shown.map((post, i) => ( = appendFrom ? (i - appendFrom) * 0.06 : 0)} className="flex" > ))}
{filtered.length === 0 ? (

No posts in this category yet.

) : null} {canReveal || canFetch ? (
) : null}

{`Showing ${shown.length + (featured ? 1 : 0)} of ${filtered.length} posts`}

); } function PostCard({ post, featured = false, date, onPostClick, }: { post: BlogPost; featured?: boolean; date: string; onPostClick?: (post: BlogPost) => void; }) { const reduce = useReducedMotion(); const canHover = useHoverCapable(); const lift = canHover && !reduce; const titleId = useId(); const initials = post.author.name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((p) => p[0]?.toUpperCase()) .join(""); // Stretched target: the title is the one link/button, its ::after covers the card. const stretched = "outline-none after:absolute after:inset-0 after:rounded-2xl after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-primary"; return (
{post.cover ? ( // biome-ignore lint/performance/noImgElement: library component, framework-agnostic. ) : null} {post.category} {featured ? ( Featured ) : null}

{post.href ? ( {post.title} ) : ( )}

{post.excerpt}

{post.author.avatar ? ( // biome-ignore lint/performance/noImgElement: library component, framework-agnostic. ) : ( {initials} )} {post.author.name} · {post.readTime} min {featured ? ( ) : null}
); }