Skip to content

Commit 8ca94b4

Browse files
committed
feat: 무한 스크롤 기능 구현
1 parent ac7905b commit 8ca94b4

File tree

4 files changed

+158
-12
lines changed

4 files changed

+158
-12
lines changed

apps/web/app/page.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
1-
export default function Page() {
1+
import Link from 'next/link';
2+
3+
import { getInfiniteScrollData } from '@/features/post/post-list/api/post-infinite-scroll';
4+
import { InfiniteScroll } from '@/features/post/post-list/ui/infinite-scroll';
5+
import { Button } from '@/shared/ui/button';
6+
7+
export default async function Home() {
8+
const { data, nextCursor, hasMore } = await getInfiniteScrollData('', 10);
9+
210
return (
3-
<div className="min-h-svh flex items-center justify-center">
4-
<div className="flex flex-col items-center justify-center gap-4">
5-
<h1 className="flex flex-col justify-center text-2xl font-bold">
6-
Hello World
7-
</h1>
8-
{/* <Button size="sm">Button</Button> */}
9-
<div className="flex items-center bg-blue-500 p-4 hover:bg-blue-700">
10-
Test
11-
</div>
12-
</div>
13-
</div>
11+
<>
12+
<Button
13+
divClassName="text-right mr-8"
14+
buttonClassName="bg-black text-white font-semibold text-base p-2 rounded"
15+
>
16+
<Link href="/posts/write">새 글 작성</Link>
17+
</Button>
18+
19+
<InfiniteScroll
20+
postList={data}
21+
lastPostId={nextCursor}
22+
hasMore={hasMore}
23+
/>
24+
</>
1425
);
1526
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { BASE_URL } from '@/shared/api/http-client';
2+
import { GetPostsCursor } from '@/shared/types/post-types';
3+
4+
const commonHeaders = new Headers();
5+
commonHeaders.append('Content-Type', 'application/json');
6+
7+
export async function getInfiniteScrollData(
8+
cursor?: string,
9+
limit?: number
10+
): Promise<GetPostsCursor> {
11+
const followingURL = `/posts/infinite?${new URLSearchParams({
12+
...(cursor && { cursor }), // cursor가 있으면 추가, undefined or null이면 추가 안 함
13+
...(limit && { limit: limit.toString() }), // limit가 있으면 추가
14+
})}`;
15+
16+
const response = await fetch(`${BASE_URL}${followingURL}`, {
17+
method: 'GET',
18+
headers: commonHeaders,
19+
});
20+
21+
if (!response.ok) {
22+
throw new Error(`${response.status} ${response.statusText}`);
23+
}
24+
25+
return response.json();
26+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
5+
import { getInfiniteScrollData } from '@/features/post/post-list/api/post-infinite-scroll';
6+
import { PostItem } from '@/features/post/post-list/ui/post-item';
7+
import { formatToLocaleDate } from '@/shared/lib/format-date';
8+
import { Post } from '@/shared/types/post-types';
9+
10+
type InfiniteScrollProps = {
11+
postList: Post[];
12+
lastPostId: string | null;
13+
hasMore: boolean;
14+
};
15+
16+
export function InfiniteScroll({
17+
postList,
18+
lastPostId,
19+
hasMore,
20+
}: InfiniteScrollProps) {
21+
const [loading, setLoading] = useState(false);
22+
const [posts, setPosts] = useState<Post[]>(postList);
23+
const [cursor, setCursor] = useState(lastPostId ? lastPostId : null);
24+
25+
const loadMorePosts = useCallback(async () => {
26+
if (!hasMore || loading || !cursor) return;
27+
28+
setLoading(true);
29+
30+
try {
31+
const { data } = await getInfiniteScrollData(cursor, 10);
32+
setPosts((prevPosts) => [...prevPosts, ...data]);
33+
const lastItem = data.at(-1);
34+
setCursor(lastItem ? lastItem.id : null);
35+
} catch (error) {
36+
console.error('Failed to fetch more posts:', error);
37+
} finally {
38+
setLoading(false);
39+
}
40+
}, [cursor, hasMore, loading]);
41+
42+
const target = useRef<HTMLHeadingElement>(null);
43+
44+
useEffect(() => {
45+
const observer = new IntersectionObserver((entries) => {
46+
const firstEntry = entries[0];
47+
if (firstEntry?.isIntersecting) loadMorePosts();
48+
});
49+
50+
const currentTarget = target.current;
51+
if (currentTarget) observer.observe(currentTarget);
52+
53+
return () => {
54+
if (currentTarget) observer.unobserve(currentTarget);
55+
};
56+
}, [loadMorePosts]);
57+
58+
return (
59+
<div>
60+
{posts.map(({ id, title, content, author, createdAt }) => {
61+
const localeCreatedAt = formatToLocaleDate(createdAt);
62+
return (
63+
<PostItem
64+
key={id}
65+
linkPostId={id}
66+
title={title}
67+
content={content}
68+
author={author}
69+
localeCreatedAt={localeCreatedAt}
70+
/>
71+
);
72+
})}
73+
<h3
74+
ref={target}
75+
className="mx-8 mb-4 mt-8 text-center text-9xl font-semibold"
76+
>
77+
{posts.at(-1)?.id === cursor
78+
? '*************더 많은 게시글 로딩 중****************'
79+
: ''}
80+
</h3>
81+
</div>
82+
);
83+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Link from 'next/link';
2+
3+
export function PostItem({
4+
linkPostId = '',
5+
title = '',
6+
content = '',
7+
author = '',
8+
localeCreatedAt = '',
9+
}) {
10+
return (
11+
<div className="mx-8 my-4 rounded border border-gray-400 p-4">
12+
<Link href={`/posts/${linkPostId}`}>
13+
<h3 className="pb-4 font-semibold">{title}</h3>
14+
<hr />
15+
<p className="pt-4">{content}</p>
16+
<br></br>
17+
<div className="flex flex-col gap-2">
18+
<span className="rounded border border-gray-300 bg-gray-300 px-2 py-0.5 italic">
19+
{author}
20+
</span>
21+
<time>{localeCreatedAt}</time>
22+
</div>
23+
</Link>
24+
</div>
25+
);
26+
}

0 commit comments

Comments
 (0)