Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현하기 (by Next.js)
배포 전 마지막으로 댓글 기능 구현 및 검색, 페이지네이션을 추가하여 Jungle-Board 구현을 완료했습니다. 페이지네이션과 검색의 경우 react-paginate 라이브러리를 이용해 구현했습니다.
이후 AWS EC2를 통해 작업한 Jungle-Board 프로젝트를 빌드 후 배포했습니다. [사이트 바로가기]
그리고 지금까지의 전체 작업 내용은 [Jungle-Board Github]에서 확인하실 수 있습니다.
댓글 기능
Reple.js (전체 댓글 영역) 코드
'use client'
import { useEffect, useState } from 'react';
import styles from './page.module.css';
import { useSession } from 'next-auth/react';
import RepleItem from './RepleItem';
export default function Reple({ id }) {
let [reple, setReple] = useState('');
let [repleList, setRepleList] = useState(null);
const { data: session, status } = useSession();
useEffect(() => {
async function fetchData() {
let resp = await fetch(`/api/reple/${id}`);
let result = await resp.json();
setRepleList(result);
}
fetchData();
}, [id]);
const handleDelete = (idToDelete) => {
setRepleList(prev => prev.filter(c => c._id !== idToDelete));
};
const updateComment = (id, newContent) => {
setRepleList(prev =>
prev.map(c => (c._id === id ? { ...c, content: newContent } : c))
)
}
return (
<div style={{ marginBottom: '50px' }}>
<h4 style={{ marginTop: '40px' }}>댓글</h4>
{session &&
<div className={styles.repleInputContainer}>
<textarea
className={styles.repleInput}
placeholder="댓글을 입력해주세요"
onChange={(e) => { setReple(e.target.value) }}
value={reple} />
<button type='button' className={styles.submitButton} onClick={async () => {
let resp = await fetch('/api/reple', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
parent: id,
writer: session.user.userId,
content: reple,
})
});
let result = await resp.json();
if (result.success) {
setRepleList(prev => [...prev, result.reple]);
setReple('');
} else {
alert('등록 실패!');
}
}}>등록</button>
</div>
}
<div className={styles.repleList}>
{repleList?.map((reple, index) => (
<RepleItem reple={reple} key={index} onDelete={handleDelete} onUpdate={updateComment} />
))}
</div>
</div>
)
}
RepleItem.js (단일 댓글) 코드
'use client'
import formatDate from '@/util/util';
import styles from './page.module.css';
import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState } from 'react';
export default function RepleItem({ reple, onDelete, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(reple.content)
const { data: session, status } = useSession();
const textareaRef = useRef(null);
useEffect(() => {
if (isEditing && textareaRef.current) {
const len = textareaRef.current.value.length;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(len, len);
}
}, [isEditing]);
const handleSave = async () => {
const resp = await fetch(`/api/reple/${reple._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editContent
})
});
const result = await resp.json();
if (result.success) {
onUpdate(reple._id, editContent);
setIsEditing(false);
} else {
alert('수정 실패!');
}
}
return (
<div className={styles.repleBox}>
<div style={{ fontWeight: 'bold', marginBottom: '15px' }}>{reple.writer}</div>
{isEditing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className={styles.repleEditInput}
ref={textareaRef}
/>
) : (
<div style={{ marginBottom: '30px' }}>{reple.content}</div>
)}
<div style={{ fontSize: '14px', color: 'gray' }}>{formatDate(reple.date)}</div>
{session && (session.user.userId === reple.writer || session.user.role === 'admin') && (
<div className={styles.buttonGroup}>
{isEditing ? (
<>
<button onClick={handleSave} className={styles.repleCorBtn}>저장</button>
<button onClick={() => {
let isCancel = confirm('댓글 수정을 취소하시겠습니까?');
if (isCancel) {
setIsEditing(false);
}
}} className={styles.repleDelBtn}>취소</button>
</>
) : (
<>
<button onClick={() => setIsEditing(true)} className={styles.repleCorBtn}>수정</button>
<button
onClick={async () => {
const isDelReple = confirm('정말 이 댓글을 삭제하시겠습니까?');
if (!isDelReple) return;
const resp = await fetch(`/api/reple/${reple._id}`, { method: 'DELETE' });
const result = await resp.json();
if (result.success) {
onDelete(reple._id);
} else {
alert('삭제 실패!');
}
}}
className={styles.repleDelBtn}
>
삭제
</button>
</>
)}
</div>
)}
</div>
);
}
page.module.css (댓글 전체 스타일)
.repleList {
padding-top: 10px;
border-top: 1px solid #e0e2ec;
}
.repleInputContainer {
display: flex;
border: 1px solid #eee;
background-color: #f9f9fb;
padding: 10px;
border-radius: 4px;
align-items: stretch;
min-height: 150px;
max-width: 100%;
margin-top: 20px;
}
.repleInput {
flex: 1;
resize: none;
border: none;
padding: 15px;
font-size: 14px;
color: #999;
background-color: white;
min-height: 130px;
outline: none;
}
.submitButton {
width: 80px;
background-color: #3f4557;
color: white;
border: none;
cursor: pointer;
text-orientation: mixed;
font-weight: bold;
font-size: 16px;
padding: 10px 0;
}
.submitButton:hover {
background-color: #2f3344;
}
.repleBox {
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px;
background-color: #f9f9f9;
position: relative;
margin-bottom: 10px;
}
.buttonGroup {
position: absolute;
bottom: 10px;
right: 20px;
display: flex;
gap: 8px;
}
.repleEditInput {
width: 100%;
border: 0;
resize: none;
background: white;
box-sizing: border-box;
}
.repleEditInput:focus {
outline: none;
}
.repleDelBtn {
background: none;
border: none;
cursor: pointer;
text-decoration: none;
padding: 0;
color: red;
}
.repleCorBtn {
background: none;
border: none;
cursor: pointer;
text-decoration: none;
padding: 0;
color: blue;
margin-right: "5px";
}
.signupButton {
color: #0070f3;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: 0.9rem;
}
페이지네이션 및 검색 기능
Pagination.js (하단 페이지네이션 부분) 코드
'use client'
import ReactPaginate from 'react-paginate'
import { useRouter } from 'next/navigation'
import styles from './page.module.css';
export default function Pagination({ totalPage, currentPage, keyword }) {
const router = useRouter()
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '20px' }} className={styles.pagination}>
<ReactPaginate
pageCount={totalPage}
forcePage={currentPage - 1}
marginPagesDisplayed={1}
pageRangeDisplayed={3}
previousLabel={'<'}
nextLabel={'>'}
breakLabel={'...'}
containerClassName={'pagination'}
activeClassName={'active'}
onPageChange={(e) => {
const page = e.selected + 1
const query = new URLSearchParams()
if (keyword) query.set('keyword', keyword)
query.set('page', page)
router.push(`?${query.toString()}`)
}}
/>
</div>
)
}
page.js (검색, 페이지네이션이 추가된 글 목록 페이지) 코드
import Link from 'next/link'
import styles from './page.module.css'
import formatDate from '@/util/util'
import { headers } from 'next/headers'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { redirect } from 'next/navigation'
import Pagination from './Pagination'
export const dynamic = 'force-dynamic';
export default async function Board(params) {
const headerList = await headers();
const host = headerList.get('host');
const protocol = 'http';
const searchParams = await params.searchParams;
const keyword = searchParams.keyword || '';
const page = parseInt(searchParams.page || '1');
const result = await fetch(`${protocol}://${host}/api/post?keyword=${keyword}&page=${page}`, { cache: 'no-store' });
const { posts, totalCount } = await result.json();
const totalPage = Math.ceil(totalCount / 10);
const session = await getServerSession(authOptions);
if (!session) redirect("/api/auth/signin");
return (
<div className={styles.content}>
<h3 style={{ paddingLeft: '20px' }}>그냥게시판</h3>
<div className={styles.community_header}>
<form action="" className={styles.searchForm}>
<select name="searchType" className={styles.searchSelect}>
<option value="title">제목</option>
</select>
<input type="text" name="keyword" className={styles.searchInput} defaultValue={keyword} autoComplete="off" />
<button className={styles.searchBtn}>
<i className="fa-solid fa-magnifying-glass" style={{ color: '#111' }}></i>
</button>
</form>
</div>
<div className={styles.board_list}>
{posts.map((post, i) => (
<div className={styles.board_item} key={i}>
<Link href={`/detail/${post._id}`} className={styles.title}>
{post.title}
</Link>
<div className={styles.writer}>{post.writer}</div>
<div className={styles.date}>{formatDate(post.date)}</div>
<div className={styles.view}>
<i className="fa-regular fa-eye" style={{ color: "#000000" }}></i> {post.view}
</div>
</div>
))}
</div>
<div className={styles.buttonBox}>
<Link href={'/write'}>
<button className={styles.writeBtn}>글쓰기</button>
</Link>
</div>
<Pagination totalPage={totalPage} currentPage={page} keyword={keyword} />
</div>
);
}
사용자가 검색하거나 페이지를 이동할 때마다, 쿼리 파라미터가 URL에 반영되고, 그에 따라 DB에서 새로운 게시글 목록을 가져와 페이지를 다시 렌더링하는 구조를 가지고 있습니다.
변경된 글 목록 가져오기 API
if (request.method == 'GET') {
const keyword = request.query.keyword || ''
const page = parseInt(request.query.page || '1')
const limit = 10
const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {}
const totalCount = await db.collection('post').countDocuments(query)
const posts = await db.collection('post')
.find(query)
.sort({ _id: -1 })
.skip((page - 1) * limit)
.limit(limit)
.toArray()
response.status(200).json({ posts, totalCount })
}
'크래프톤 정글 > Equipped in 정글(나만무)' 카테고리의 다른 글
[나만무] 250704 일지 (1) | 2025.07.04 |
---|---|
[나만무] 타입스크립트(TS) 필수 지식 학습하기 by 코딩애플 (1) | 2025.06.27 |
[나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기 (0) | 2025.06.18 |
[나만무] 06.17 TIL - Next.js 학습 by 코딩애플 (0) | 2025.06.17 |
[나만무] Jungle-Board 기본 CRUD 구현하기 (0) | 2025.06.17 |