Skip to content

Commit c864fb8

Browse files
authored
Merge pull request #45 from nettee-space/feature/post-list
오프셋 기반 게시글 목록 조회 및 페이지네이션 추가
2 parents d8756dd + 5775e76 commit c864fb8

File tree

8 files changed

+157
-3
lines changed

8 files changed

+157
-3
lines changed

apps/web/app/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Link from 'next/link';
33

44
import { getInfiniteScrollData } from '@/features/post/post-list';
55
import { PostInfiniteScroll } from '@/features/post/post-list';
6+
import { PostListPagination } from '@/features/post/post-list/ui/post-list-pagination';
67

78
export default async function Home() {
89
const { data, nextCursor, hasMore } = await getInfiniteScrollData('', 10);
@@ -19,7 +20,7 @@ export default async function Home() {
1920
</Button>
2021
</div>
2122
</div>
22-
23+
<PostListPagination />
2324
<PostInfiniteScroll
2425
postList={data}
2526
lastPostId={nextCursor}

apps/web/entities/post/api/post-api.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { CreatePostDTO, Post, UpdatePostDTO } from '@/entities/post';
1+
import type {
2+
CreatePostDTO,
3+
GetPostsOffset,
4+
Post,
5+
UpdatePostDTO,
6+
} from '@/entities/post';
27
import { httpClient } from '@/shared/api';
38

49
// 게시글 생성 (POST /posts)
@@ -38,3 +43,13 @@ export const deletePost = async (
3843
method: 'DELETE',
3944
});
4045
};
46+
47+
// 오프셋 기반 게시글 목록 조회 (현재 페이지와 전체 페이지 정보 포함)
48+
export const getPostsByPage = async (
49+
page: number = 1,
50+
limit: number = 10
51+
): Promise<GetPostsOffset> => {
52+
return httpClient<GetPostsOffset>(
53+
`/posts/paginated?page=${page}&limit=${limit}`
54+
);
55+
};

apps/web/entities/post/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
export { createPost, getPostById, getPosts, updatePost } from './api/post-api';
1+
export {
2+
createPost,
3+
getPostById,
4+
getPosts,
5+
getPostsByPage,
6+
updatePost,
7+
} from './api/post-api';
28
export { ERROR_MESSAGES } from './lib/error-messages';
39
export {
410
extractFormData,
@@ -9,7 +15,9 @@ export {
915
export type {
1016
CreatePostDTO,
1117
GetPostsCursor,
18+
GetPostsOffset,
1219
Post,
1320
UpdatePostDTO,
1421
} from './model/post-types';
22+
export type { PostViewModel } from './model/post-view-model';
1523
export { mapPostToViewModel } from './model/post-view-model';

apps/web/entities/post/lib/error-messages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const ERROR_MESSAGES = {
2+
FETCH_FAILED: '게시글을 불러오는 데 실패했습니다',
23
INVALID_TYPE: '유효하지 않은 입력 형식입니다.',
34
INVALID_ID: '유효하지 않은 게시글 입니다.',
45
REQUIRED_TITLE: '제목을 입력해주세요',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { getInfiniteScrollData } from './api/post-infinite-scroll';
22
export { PostInfiniteScroll } from './ui/post-infinite-scroll';
33
export { PostItem } from './ui/post-item';
4+
export { PostListPagination } from './ui/post-list-pagination';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
5+
import {
6+
ERROR_MESSAGES,
7+
getPostsByPage,
8+
mapPostToViewModel,
9+
PostViewModel,
10+
} from '@/entities/post';
11+
import { PostItem } from '@/features/post/post-list';
12+
import { usePagination } from '@/shared/hooks/usePagination';
13+
14+
export function PostListPagination() {
15+
const [posts, setPosts] = useState<PostViewModel[]>([]);
16+
const [currentPage, setCurrentPage] = useState(1);
17+
const [totalPages, setTotalPages] = useState(1);
18+
const [isLoading, setIsLoading] = useState(false);
19+
const postsPerPage = 10;
20+
21+
const { paginationButtons } = usePagination(
22+
currentPage,
23+
totalPages,
24+
setCurrentPage
25+
);
26+
27+
useEffect(() => {
28+
const fetchPosts = async () => {
29+
setIsLoading(true);
30+
try {
31+
const response = await getPostsByPage(currentPage, postsPerPage);
32+
setPosts(response.data.map(mapPostToViewModel));
33+
setTotalPages(response.totalPages);
34+
} catch (error) {
35+
console.error(`❌ ${ERROR_MESSAGES.FETCH_FAILED}:`, error);
36+
}
37+
setIsLoading(false);
38+
};
39+
40+
fetchPosts();
41+
}, [currentPage]);
42+
43+
return (
44+
<div className="flex flex-col items-center">
45+
<ul className="w-full">
46+
{isLoading ? (
47+
<p className="text-center text-gray-500">로딩 중...</p>
48+
) : (
49+
posts.map((post) => (
50+
<PostItem
51+
key={post.id}
52+
linkPostId={post.id}
53+
title={post.title}
54+
content={post.content}
55+
author={post.author}
56+
localeCreatedAt={post.localeCreatedAt}
57+
/>
58+
))
59+
)}
60+
</ul>
61+
<div className="mt-6 flex items-center gap-2">{paginationButtons}</div>
62+
</div>
63+
);
64+
}

apps/web/shared/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { usePagination } from './usePagination';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useMemo } from 'react';
2+
3+
export function usePagination(
4+
currentPage: number,
5+
totalPages: number,
6+
setPage: (page: number) => void
7+
) {
8+
const maxPageButtons = 5;
9+
const currentGroup = Math.floor((currentPage - 1) / maxPageButtons);
10+
const startPage = currentGroup * maxPageButtons + 1;
11+
const endPage = Math.min(startPage + maxPageButtons - 1, totalPages);
12+
13+
const paginationButtons = useMemo(
14+
() =>
15+
[
16+
{ label: '<<', page: 1, isNav: true, disabled: currentPage === 1 },
17+
{
18+
label: '<',
19+
page: Math.max(startPage - 1, 1),
20+
isNav: true,
21+
disabled: currentPage === 1,
22+
},
23+
...Array.from({ length: endPage - startPage + 1 }).map((_, idx) => ({
24+
label: (startPage + idx).toString(),
25+
page: startPage + idx,
26+
isNav: false,
27+
disabled: currentPage === startPage + idx,
28+
})),
29+
{
30+
label: '>',
31+
page: Math.min(startPage + maxPageButtons, totalPages),
32+
isNav: true,
33+
disabled: currentPage === totalPages,
34+
},
35+
{
36+
label: '>>',
37+
page: totalPages,
38+
isNav: true,
39+
disabled: currentPage === totalPages,
40+
},
41+
].map(({ label, page, isNav, disabled }) => (
42+
<button
43+
key={label}
44+
onClick={() => !disabled && setPage(page)}
45+
disabled={disabled}
46+
className={`rounded-md border px-3 py-1 transition ${
47+
disabled
48+
? isNav
49+
? 'text-gray-400' // <<, <, >, >> 버튼이 비활성화되면 글씨만 회색
50+
: 'bg-gray-900 text-white underline' // 페이지 번호 버튼이 비활성화되면 배경 변경
51+
: currentPage === page
52+
? 'bg-black font-bold text-white underline' // 현재 페이지 버튼 스타일
53+
: 'bg-white hover:bg-gray-100' // 기본 버튼 스타일
54+
}`}
55+
>
56+
{label}
57+
</button>
58+
)),
59+
[currentPage, endPage, setPage, startPage, totalPages]
60+
);
61+
62+
return { paginationButtons, goToPage: setPage };
63+
}

0 commit comments

Comments
 (0)