참고
- 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;
'개발 > React' 카테고리의 다른 글
리액트 - Toast UI Editor로 메일 보내기 with nodemailer (0) | 2023.12.10 |
---|---|
리액트 - IP 변환하기 (IP Converter) (0) | 2023.12.09 |
리액트 - 쿠키로 GitHub OAuth 로그인 인증 관리하기 with react-cookie (0) | 2023.11.15 |
리액트 - GitHub RESTful API로 댓글 저장하기 with react-comments-section (0) | 2023.11.15 |
리액트 - 로그인한 사용자만 댓글 기능 사용하기 with react-comments-section (GitHub OAuth Login) (0) | 2023.11.15 |
댓글