diff --git a/frontend/src/components/react/PostList.tsx b/frontend/src/components/react/PostList.tsx index 100491b..cf134d2 100644 --- a/frontend/src/components/react/PostList.tsx +++ b/frontend/src/components/react/PostList.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { deletePost } from '../../lib/api'; +const PAGE_SIZE = 9; + interface CoverImage { url: string; alt: string; @@ -70,6 +72,8 @@ const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [ export default function PostList({ posts: initialPosts, isAdmin = false }: Props) { const [posts, setPosts] = useState(initialPosts); const [deleting, setDeleting] = useState(null); + const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length)); + const sentinelRef = useRef(null); async function handleDelete(slug: string, title: string) { if (deleting) return; @@ -85,13 +89,37 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props } } + useEffect(() => { + setVisible(v => Math.min(Math.max(v, PAGE_SIZE), posts.length)); + }, [posts.length]); + + useEffect(() => { + if (visible >= posts.length) return; + const el = sentinelRef.current; + if (!el) return; + const io = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setVisible(v => Math.min(v + PAGE_SIZE, posts.length)); + } + }, + { rootMargin: '600px 0px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [visible, posts.length]); + if (posts.length === 0) { return null; } + const shown = posts.slice(0, visible); + const hasMore = visible < posts.length; + return ( + <>
- {posts.map((post, idx) => { + {shown.map((post, idx) => { const displayTitle = post.title || formatSlug(post.slug); const isDeleting = deleting === post.slug; const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length]; @@ -205,5 +233,15 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props ); })}
+ {hasMore && ( + + )} + ); }