[나만무] Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현 및 배포하기

2025. 6. 20. 09:41·크래프톤 정글/Equipped in 정글(나만무)

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
'크래프톤 정글/Equipped in 정글(나만무)' 카테고리의 다른 글
  • [나만무] 250704 일지
  • [나만무] 타입스크립트(TS) 필수 지식 학습하기 by 코딩애플
  • [나만무] Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기
  • [나만무] 06.17 TIL - Next.js 학습 by 코딩애플
그냥사람_
그냥사람_
IT 관련 포스팅을 합니다. 크래프톤 정글 8기 정경호
  • 그냥사람_
    그냥코딩
    그냥사람_
  • 전체
    오늘
    어제
    • 글 전체보기 N
      • 크래프톤 정글 N
        • 로드 투 정글(입학시험)
        • CS기초(키워드, 개념정리)
        • 컴퓨터구조(CSAPP)
        • Code 정글(C언어)
        • Equipped in 정글(나만무) N
        • 마이 정글(WIL, 에세이) N
      • 자료구조&알고리즘
        • 자료구조
        • 알고리즘
      • 일상
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • hELLO· Designed By정상우.v4.10.3
그냥사람_
[나만무] Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현 및 배포하기
상단으로

티스토리툴바