본문 바로가기
개발/React

React Material - Mui Pagination으로 댓글 페이지로 나누기

by 피로물든딸기 2023. 11. 19.
반응형

리액트 전체 링크

 

참고

- https://mui.com/material-ui/react-pagination/

 

댓글 기능 만들기 with react-comments-section
로그인한 사용자만 댓글 기능 사용하기
GitHub RESTful API로 댓글 저장하기

리액트 쿠키로 GitHub OAuth 로그인 인증 관리하기

- Mui Pagination으로 댓글 페이지로 나누기

 

아래 그림은 24개의 Comments (댓글 13 + reply 11)이다.

...

 

댓글이 많아질수록 화면이 커져서 스크롤을 해야 하기 때문에 Material UI의 Pagination을 적용해 페이지를 나눠보자.


Mui Pagination

 

링크를 참고하여 Pagination을 임시로 추가해보자.

import Pagination from "@mui/material/Pagination";
import Box from "@mui/material/Box";

...

  <Box
    sx={{ m: 5 }}
    display="flex"
    justifyContent="center"
  >
    <Pagination
      defaultPage={1}
      count={100}
      siblingCount={2} 
      boundaryCount={10} 
      size="small"
      variant="outlined"
      shape="rounded"
      color="primary"
      showFirstButton
      showLastButton
      onChange={handleChangePage}
    />
  </Box>

 

siblingCount가 2라면 선택한 page의 양 옆에 2개의 page number가 추가된다.

그리고 boundaryCount가 10이라면 첫 페이지와 끝 페이지에 최대 10개까지 page number가 보이게 된다.


구현

 

reply는 개수에 포함하지 않고, 댓글의 개수만 포함해서 페이지를 나눈다.

comment가 13개고, 1 page에 최대 5개의 댓글까지 보여주고 싶다고 가정하자.

const maxDataCount = 5;

 

각 페이지에서 보일 comments 배열의 index는 다음과 같다.

 

page 1 : 0 ~ 4

page 2 : 5 ~ 9

page 3 : 10 ~ 12

page x는 (x - 1) * maxDataCount ~ min[ x * maxDataCount - 1, data length ]의 data를 보이게 한다.

 

여기서 총 페이지 수 3은 comments의 개수를 maxDataCount로 나눈 값이 된다.

const [maxPageCount, setMaxPageCount] = useState(0);

...

  const loadComments = async () => {
    ...
    
    let maxPage = Math.ceil(originalCommentData.length / maxDataCount);
    setMaxPageCount(maxPage);
    
    
    ...
    
    
  <Pagination
    defaultPage={1}
    count={maxPageCount}

 

전체 페이지 수가 결정된다면 setCommentsPage에 data와 현재 page를 추가해서

댓글을 필요한 만큼만 보여주는 메서드를 만든다.

(slice가 endIndex의 앞까지 자르기 때문에 x * maxDataCount - 1이 아니라 x * maxDataCount 가 된다.)

  const setCommentsPage = (data, currentPage) => {
    let dataLength = data.length;
    let startIndex = (currentPage - 1) * maxDataCount;
    let endIndex = Math.min(currentPage * maxDataCount, dataLength);
    let selectedComments = data.slice(startIndex, endIndex);

    setCurrentComments(selectedComments);
  }

 

loadComments에는 현재 page 1을 호출한다.

setCommentsPage(originalCommentData, 1);

 

참고로 initComments, setInitComments은 페이지에 따라 언제든지 변경되므로 아래와 같이 변경하였다.

const [currentComments, setCurrentComments] = useState([]);

 

page를 변경할 때를 대비해서 전체 commentsData를 관리해둔다.

const [totalComments, setTotalComments] = useState([]);

...

  const loadComments = async () => {

    ...
    
    setTotalComments(originalCommentData);

 

totalComments를 이용해 page가 변경될 때 마다 comments를 변경하면 된다.

  const handleChangePage = (event, value) => {
    //console.log(event, value);
    setCommentsPage(totalComments, value);
  };

 

아래와 같이 13개의 댓글이 3개의 page로 잘 변경되는 것을 확인해보자.

 

ReactComments.js + paginagion 코드는 다음과 같다.

import React, { useEffect, useState } from "react";
...
import Pagination from "@mui/material/Pagination";
import Box from "@mui/material/Box";
...
const maxDataCount = 5;

const ReactComments = ({ currentUser }) => {
  const [currentComments, setCurrentComments] = useState([]);
  const [totalComments, setTotalComments] = useState([]);
  const [maxPageCount, setMaxPageCount] = useState(0);

  ...
  
  const setCommentsPage = (data, currentPage) => {
    let dataLength = data.length;
    let startIndex = (currentPage - 1) * maxDataCount;
    let endIndex = Math.min(currentPage * maxDataCount, dataLength);
    let selectedComments = data.slice(startIndex, endIndex);

    setCurrentComments(selectedComments);
  }
  
  const loadComments = async () => {
    ...

    let maxPage = Math.ceil(originalCommentData.length / maxDataCount);
    setMaxPageCount(maxPage);

    setTotalComments(originalCommentData);
    setCommentsPage(originalCommentData, 1);

    ...
  };

  useEffect(() => {
    loadComments();
  }, []);


  const onSubmitAction = (data) => {
    console.log("check submit, ", data);

    data.type = "submit";
    saveCommentAction(data);
  };

  ...
  
  const handleChangePage = (event, value) => {
    //console.log(event, value);
    setCommentsPage(totalComments, value);
  };

  return (
    <div>
      <CommentSection
        ...
      />
      <Box
        sx={{ m: 5 }}
        display="flex"
        justifyContent="center"
      >
        <Pagination
          defaultPage={1}
          count={maxPageCount}
          siblingCount={2} 
          boundaryCount={10} 
          size="small"
          variant="outlined"
          shape="rounded"
          color="primary"
          showFirstButton
          showLastButton
          onChange={handleChangePage}
        />
      </Box>
    </div>
  );
};

export default ReactComments;

Action 구현

 

page를 나눈 상태에서 submit / delete / reply / edit을 구현해 보자.

현재 상태에서 submit을 하면 마지막 페이지에 댓글이 추가되는 것이 아니라, 현재 페이지에 댓글이 추가된다.

 

따라서 각각의 action에 전체 댓글 totalComments를 참고하여 댓글을 관리한다.

 

각 action에 대한 구현 사항은 GitHub RESTful API로 댓글 저장하기와 원리가 같다.

 

submit : 마지막 data에 댓글 추가 + 마지막 페이지로 이동, maxPage 갱신

submit을 하면 마지막 페이지로 이동하기 때문에 page props를 추가한다.

  const [currentPage, setCurrentPage] = useState(1);
  
  ...
  
    <Pagination
    defaultPage={1}
    page={currentPage}
    count={maxPageCount}

 

전체 Comments에서 data를 추가하고, maxPage를 갱신한다.

그리고 현재 페이지를 마지막 페이지로 이동하면 된다.

  const onSubmitAction = (data) => {
    console.log("check submit, ", data);

    data.type = "submit";
    saveCommentAction(data);

    let tmp = totalComments;
    tmp.push(data);

    let maxPage = Math.ceil(tmp.length / maxDataCount);
    setMaxPageCount(maxPage);
    setCurrentPage(maxPage);
    setCommentsPage(tmp, maxPage);
    setTotalComments(tmp);
  };

 

delete : 댓글이 한 칸씩 밀려나게 되며, maxPage 갱신

현재 댓글 페이지를 설정할 때, reply는 개수에 포함하지 않는다.

따라서 댓글만 삭제하는 경우만 고려하고, page는 1로 이동하도록 하였다.

  const onDeleteAction = (data) => {
    console.log("check Delete, ", data);
    data.type = "delete";
    saveCommentAction(data);

    let comIdToDelete = data.comIdToDelete;
    let parentOfDeleteId = data.parentOfDeleteId;

    if (parentOfDeleteId === undefined) {
      let tmp = totalComments;
      tmp = tmp.filter((data) => data.comId !== comIdToDelete);

      let maxPage = Math.ceil(tmp.length / maxDataCount);
      setMaxPageCount(maxPage);
      setCurrentPage(1);
      setCommentsPage(tmp, 1);
      setTotalComments(tmp);
    }
  };

 

edit / reply는 page의 변화가 없기 때문에 수정하지 않아도 된다.

 

page props를 추가하였으므로, handleChangePage에서도 setCurrentPage를 설정하였다.

  const handleChangePage = (event, value) => {
    //console.log(event, value);
    setCommentsPage(totalComments, value);
    setCurrentPage(value);
  };

전체 코드는 다음과 같다.

 

ReactComments.js

import React, { useEffect, useState } from "react";
import { CommentSection } from "react-comments-section";
//import 'react-comments-section/dist/index.css';
import "../css/comment.css";

import Pagination from "@mui/material/Pagination";
import Box from "@mui/material/Box";

import * as gh from "../githublibrary.js";
import * as ck from "../cookielibrary.js";

const clientID = process.env.REACT_APP_CLIENT_ID;
const callbackURL = "http://localhost:3000/callback";
const loginURL = `https://github.com/login/oauth/authorize?client_id=${clientID}&scope=repo:status read:repo_hook user:email&redirect_uri=${callbackURL}`;

const maxDataCount = 5;

const getCurrentDateTime = () => {
  const now = new Date();

  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, "0");
  const day = String(now.getDate()).padStart(2, "0");
  const hours = String(now.getHours()).padStart(2, "0");
  const minutes = String(now.getMinutes()).padStart(2, "0");
  const seconds = String(now.getSeconds()).padStart(2, "0");

  const formattedDateTime = `${year}_${month}_${day}_${hours}_${minutes}_${seconds}`;
  return formattedDateTime;
};

const ReactComments = ({ currentUser }) => {
  const [currentComments, setCurrentComments] = useState([]);
  const [totalComments, setTotalComments] = useState([]);
  const [maxPageCount, setMaxPageCount] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);

  const currentData = (data) => {
    console.log("current data", data);
  };

  const getCurrentUser = () => {
    if (currentUser === null) {
      let loginID = ck.getCookies("LOGIN_ID");
      let url = ck.getCookies("AVATAR_URL");
      let profile = `https://github.com/${loginID}`;

      if (loginID === null || loginID === undefined) return null;

      return {
        currentUserId: loginID,
        currentUserImg: url,
        currentUserProfile: profile,
        currentUserFullName: loginID,
      };
    }

    return currentUser;
  };

  const setCommentsPage = (data, currentPage) => {
    let dataLength = data.length;
    let startIndex = (currentPage - 1) * maxDataCount;
    let endIndex = Math.min(currentPage * maxDataCount, dataLength);
    let selectedComments = data.slice(startIndex, endIndex);

    setCurrentComments(selectedComments);
  };

  const loadComments = async () => {
    let commentDir = `comments`;
    let fileList = await gh.fileRead(commentDir);

    if (fileList === undefined) return; // 아무 댓글도 없는 경우

    // original_comments.js 찾기
    let originalComment = fileList.data.filter(
      (file) => file.name === "original_comments.js"
    );
    let originalCommentData = [];
    if (originalComment.length) {
      let result = await gh.fileRead(originalComment[0].path);
      if (result === undefined) return;

      originalCommentData = JSON.parse(
        decodeURIComponent(escape(window.atob(result.data.content)))
      );
    }

    let commentsAction = fileList.data.filter(
      (file) => file.name !== "original_comments.js"
    );

    for (let action of commentsAction) {
      let result = await gh.fileRead(action.path);

      if (result === undefined) {
        console.error("error data");
        continue;
      }

      //console.log(result.data.name);
      let contents = JSON.parse(
        decodeURIComponent(escape(window.atob(result.data.content)))
      );

      //console.log(contents);
      if (contents.type === "submit") {
        delete contents.type;
        originalCommentData.push(contents);
      } else if (contents.type === "delete") {
        let comIdToDelete = contents.comIdToDelete;
        let parentOfDeleteId = contents.parentOfDeleteId;

        if (parentOfDeleteId === undefined) {
          // 댓글 삭제
          originalCommentData = originalCommentData.filter(
            (data) => data.comId !== comIdToDelete
          );
        } else {
          // reply 삭제
          let parent = originalCommentData.filter(
            (data) => data.comId === parentOfDeleteId
          )[0];
          parent.replies = parent.replies.filter(
            (data) => data.comId !== comIdToDelete
          );
        }
      } else if (contents.type === "reply") {
        let repliedToCommentId =
          contents.parentOfRepliedCommentId || contents.repliedToCommentId;
        let parent = originalCommentData.filter(
          (data) => data.comId === repliedToCommentId
        )[0];
        delete contents.type;
        parent.replies.push(contents);
      } else if (contents.type === "edit") {
        let comId = contents.comId;
        let parentOfEditedCommentId = contents.parentOfEditedCommentId;

        if (parentOfEditedCommentId === undefined) {
          // 댓글을 변경한 경우
          let parentText = originalCommentData.filter(
            (data) => data.comId === comId
          )[0];
          parentText.text = contents.text;
        } else {
          // reply를 변경한 경우
          let parent = originalCommentData.filter(
            (data) => data.comId === parentOfEditedCommentId
          )[0];
          let child = parent.replies.filter((data) => data.comId === comId)[0];
          child.text = contents.text;
        }
      }
    }

    let maxPage = Math.ceil(originalCommentData.length / maxDataCount);
    setMaxPageCount(maxPage);

    setTotalComments(originalCommentData);
    setCommentsPage(originalCommentData, 1);

    if (commentsAction.length === 0) return;

    // 기존 data는 삭제
    let originalPath = `comments/original_comments.js`;
    let result = await gh.fileDelete(originalPath);

    console.log(`${originalPath} delete : ${result}`);

    // 새로 생성
    result = await gh.fileCreate(
      JSON.stringify(originalCommentData, null, 2),
      originalPath
    );

    console.log(result);

    // 불필요한 action 삭제
    for (let action of commentsAction) {
      result = await gh.fileDelete(action.path);
      console.log(`${action.path} delete : ${result}`);
    }
  };

  useEffect(() => {
    loadComments();
  }, []);

  const saveCommentAction = async (data) => {
    console.log("save Comments Action");
    
    let path = `comments/${getCurrentDateTime()}_data.json`;
    let result = await gh.fileCreate(JSON.stringify(data, null, 2), path);

    console.log(result);
  };

  const onSubmitAction = (data) => {
    console.log("check submit, ", data);

    data.type = "submit";
    saveCommentAction(data);

    let tmp = totalComments;
    tmp.push(data);

    let maxPage = Math.ceil(tmp.length / maxDataCount);
    setMaxPageCount(maxPage);
    setCurrentPage(maxPage);
    setCommentsPage(tmp, maxPage);
    setTotalComments(tmp);
  };

  const onDeleteAction = (data) => {
    console.log("check Delete, ", data);
    data.type = "delete";
    saveCommentAction(data);

    let comIdToDelete = data.comIdToDelete;
    let parentOfDeleteId = data.parentOfDeleteId;

    if (parentOfDeleteId === undefined) {
      let tmp = totalComments;
      tmp = tmp.filter((data) => data.comId !== comIdToDelete);

      let maxPage = Math.ceil(tmp.length / maxDataCount);
      setMaxPageCount(maxPage);
      setCurrentPage(1);
      setCommentsPage(tmp, 1);
      setTotalComments(tmp);
    }
  };

  const onReplyAction = (data) => {
    console.log("check Reply, ", data);
    data.type = "reply";

    saveCommentAction(data);
  };

  const onEditAction = (data) => {
    console.log("check Edit, ", data);

    data.type = "edit";
    saveCommentAction(data);
  };

  const handleChangePage = (event, value) => {
    //console.log(event, value);
    setCommentsPage(totalComments, value);
    setCurrentPage(value);
  };

  return (
    <div>
      {/* <button onClick={setCommentsPage}>test</button> */}
      <CommentSection
        currentUser={getCurrentUser}
        logIn={{
          loginLink: loginURL,
          signupLink: "https://github.com/",
        }}
        commentData={currentComments}
        currentData={currentData}
        onSubmitAction={onSubmitAction}
        onDeleteAction={onDeleteAction}
        onReplyAction={onReplyAction}
        onEditAction={onEditAction}
        hrStyle={{ border: "0.5px solid #ff0072" }}
        inputStyle={{ border: "1px solid rgb(208 208 208)" }}
        formStyle={{ backgroundColor: "white" }}
        submitBtnStyle={{
          border: "1px solid black",
          backgroundColor: "black",
          padding: "7px 15px",
        }}
        cancelBtnStyle={{
          border: "1px solid gray",
          backgroundColor: "gray",
          color: "white",
          padding: "7px 15px",
        }}
        replyInputStyle={{ borderBottom: "1px solid black", color: "black" }}
        advancedInput={true}
        removeEmoji={false}
      />
      <Box sx={{ m: 5 }} display="flex" justifyContent="center">
        <Pagination
          defaultPage={1}
          page={currentPage}
          count={maxPageCount}
          siblingCount={2}
          boundaryCount={10}
          size="small"
          variant="outlined"
          shape="rounded"
          color="primary"
          showFirstButton
          showLastButton
          onChange={handleChangePage}
        />
      </Box>
    </div>
  );
};

export default ReactComments;

 

githublibrary.js

import axios from "axios";
import { Octokit } from "@octokit/rest";

import * as ck from "./cookielibrary.js";

const myKey = process.env.REACT_APP_MY_TOKEN;
const repo = `auto-test`;

const getSHA = async (path) => {
  const octokit = new Octokit({
    auth: myKey,
  });

  try {
    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
      }
    );
  
    return result.data.sha;
  }

  catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};

export const fileDelete = async (path) => {
  let sha = await getSHA(path);
  if(sha === undefined) return undefined;
  try {
    const octokit = new Octokit({
      auth: myKey,
    });

    const result = await octokit.request(
      `DELETE /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo,
        path,
        message: "delete!!",
        sha,
      }
    );

    return result;
  } catch (e) {
    console.log("hello!!");
    console.log("error : ", e);
    return undefined;
  }
};

export const fileRead = async (path) => {
  try {
    const octokit = new Octokit({
      auth: myKey,
    });

    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        encoding: "utf-8",
        decoding: "utf-8",
      }
    );
    return result;
  } catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};

export const fileCreate = async (contents, path) => {
  const octokit = new Octokit({
    auth: myKey,
  });

  try {
    const result = await octokit.request(
      `PUT /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        message: "make comments",
        committer: {
          name: "bloodstrawberry",
          email: "bloodstrawberry@github.com",
        },
        content: `${btoa(unescape(encodeURIComponent(`${contents}`)))}`,
        headers: {
          "X-GitHub-Api-Version": "2022-11-28",
        },
      }
    );

    return result.status;
  } catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};

export const loginCheck = async (setLoginStatus) => {
  let token = ck.getCookies("GITHUB_TOKEN");
  try {
    const response = await axios.get("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    console.log(response);
    setLoginStatus(true);
  } catch (error) {
    console.error("Error fetching user data:", error);
    setLoginStatus(false);
  }
};

export const getLoginStatus = async () => {
  let token = ck.getCookies("GITHUB_TOKEN");
  try {
    const response = await axios.get("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    console.log(response);
    return true;
  } catch (error) {
    console.error("Error fetching user data:", error);
    return false;
  }
};

 

cookielibrary.js

import { Cookies } from "react-cookie";

const cookies = new Cookies();

export const setCookies = (key, value, options) => {
  return cookies.set(key, value, {...options})
}

export const getCookies = (key) => {
  return cookies.get(key)
}

export const removeCookies = (key) => {
  return cookies.remove(key);
}

 

GitHubLoginCallback.js

import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";

import axios from "axios";
import queryString from "query-string";

import * as ck from "../cookielibrary.js";

const clientID = process.env.REACT_APP_CLIENT_ID;

const GitHubLoginCallback = ({ loginStatus, setLoginStatus }) => {
  const navigate = useNavigate();

  const getAccessToken = async (code) => {
    try {
      let server = `http://192.168.55.120:3002`;
      let accessInfo = await axios.get(
        `${server}/githubLogin?code=${code}&clientID=${clientID}`
      );

      let token = accessInfo.data.token;

      const userResponse = await axios.get("https://api.github.com/user", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      console.log(userData);
      console.log(avatarUrl);
      console.log(loginID);

      let options = {
        path: "/",
        maxAge: 60 * 60 * 24 * 7, // seconds
        secure: true, // https 연결에만 전송
        // httpOnly: true, // 클라이언트에서 read 불가
      };

      ck.setCookies("GITHUB_TOKEN", token, options);
      ck.setCookies("AVATAR_URL", avatarUrl, options);
      ck.setCookies("LOGIN_ID", loginID, options);
      
      setLoginStatus(true);

      navigate('/',  { replace: true });
    } catch (e) {
      console.log(e);
      setLoginStatus(false);
    }
  };

  const getCode = () => {
    let qs = queryString.parse(window.location.search);
    let code = qs.code;
    getAccessToken(code);
  };

  useEffect(() => getCode(), []);

  return <div>{loginStatus ? "로그인 성공!" : "로그인 실패..."}</div>;
};

export default GitHubLoginCallback;

 

App.js

import React, { useEffect, useState } from "react";
import { Route, Link, Routes } from "react-router-dom";

import "./App.css";

// ...
import ReactComments from "./page/ReactComments";
import GitHubLoginCallBack from "./page/GitHubLoginCallback";

import * as gh from "./githublibrary.js";
import * as ck from "./cookielibrary.js";

const App = () => {
  const [loginStatus, setLoginStatus] = useState(false);
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    if(loginStatus === false) return;
    let loginID = ck.getCookies("LOGIN_ID");
    let url = ck.getCookies("AVATAR_URL");
    let profile = `https://github.com/${loginID}`;

    setCurrentUser({
      currentUserId: loginID,
      currentUserImg: url,
      currentUserProfile: profile,
      currentUserFullName: loginID,
    });
  }, [loginStatus]);

  useEffect(() => {
    gh.loginCheck(setLoginStatus);
  }, []);

  return (
    <div className="App">
      <div className="router">
        <span>
          <Link to="/comments">Comments</Link>
        </span>
      </div>
      <div>
        <Routes>
          <Route
            path="/comments"
            element={<ReactComments currentUser={currentUser} />}
          />
          <Route
            path="/callback"
            element={
              <GitHubLoginCallBack
                loginStatus={loginStatus}
                setLoginStatus={setLoginStatus}
              />
            }
          />
        </Routes>
      </div>
    </div>
  );
};

export default App;
반응형

댓글