대댓글 기능 만들기 ver.2 [React]

자몽·2021년 10월 4일
1

Team-Project

목록 보기
9/13
post-thumbnail

코드: https://github.com/OseungKwon/Project-features/tree/main/reply

최근에 만들었던 대댓글 기능 만들기에 새로운 기능들을 추가했다.

우선, 완성된 이미지의 before, after이다.

before

after

새로 추가된 기능

  • toast editor 라이브러리를 사용해 댓글을 조금 더 다양하게 작성할 수 있도록 만들었다.
    (특히 코드를 넣을 수 있는 기능이 필요해 해당 라이브러리를 사용하였다.)

  • 댓글 개수에 따라 달라지는 버튼
    기존에는 '댓글' 버튼 한 종류만 존재했다면,
    '댓글 달기', '1개의 댓글 달기', '댓글 숨기기' 등 조금 더 상태에 맞는 버튼이 위치하도록 만들었다.

  • 'n 시간 전'이라는 문구가 유저 닉네임 옆에 위치하도록 기능 추가

  • 댓글에 대한 수정, 삭제 기능 구현

코드

자세한 설명은 대댓글 기능 만들기 에서 이미 했기에, 새로 추가된 내용에 관한 설명만 덧붙이려 한다.

Comment.js

댓글 기능을 담당하는 컴포넌트

import React, { useState, useEffect, useRef } from "react";
import { useSelector, useDispatch } from "react-redux";
import uuid from "react-uuid";

import { addComment, editComment, removeComment } from "../redux/comment";
import ReplyComment from "./ReplyComment";

// dot icon
//import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { Stack, Button, Divider, Paper } from "@mui/material";

import { Box } from "@mui/system";

// markdown, toast editor
import "@toast-ui/editor/dist/toastui-editor.css";
import { Editor } from "@toast-ui/react-editor";

import Markdown from "../component/Markdown";

import {
  check_kor,
  timeForToday,
  Item,
  ProfileIcon,
} from "../component/CommentTool";

const Comment = ({ user }) => {
  const [local, setLocal] = useState([]);
  const dispatch = useDispatch();
  const comments = useSelector((state) => state.comment);
  const [display, setDisplay] = useState(false);
  const editorRef = useRef();

  // open editor to edit comment
  const [openEditor, setOpenEditor] = useState("");

  const onSubmit = (e) => {
    e.preventDefault();

    // 마크다운 변환
    const editorInstance = editorRef.current.getInstance();
    const getContent = editorInstance.getMarkdown();
    setDisplay(!display);
    const date = new Date();

    // 데이터 저장
    // setCommentValule(text);
    let data = {
      content: getContent,
      writer: user,
      postId: "123123",
      responseTo: "root",
      commentId: uuid(),
      created_at: `${date}`,
    };
    dispatch(addComment(data));
  };

  // Edit comment
  const onEdit = (commentId) => {
    // console.log(commentId);
    const editorInstance = editorRef.current.getInstance();
    const getContent = editorInstance.getMarkdown();
    console.log(getContent);

    let data = { commentId: commentId, content: getContent };
    dispatch(editComment(data));
  };

  // Remove comment
  const onRemove = (commentId) => {
    dispatch(removeComment(commentId));
  };

  useEffect(() => {
    localStorage.setItem("reply", JSON.stringify(comments));
    setLocal(comments.filter((comment) => comment.responseTo === "root"));
  }, [comments]);

  return (
    <Paper sx={{ m: 15, p: 3 }}>
      <Button
        onClick={() => {
          setDisplay(!display);
        }}
        sx={{ width: "10rem" }}
      >
        답변 달기
      </Button>

      {display && (
        <>
          <Editor ref={editorRef} />
          <div>
            <Button onClick={onSubmit}>저장</Button>
          </div>
        </>
      )}

      {local.map((comment, index) => (
        <Box sx={{ m: 2 }} key={comment.commentId}>
          {/* writer 정보, 작성 시간 */}
          <Stack direction="row" spacing={2}>
            <ProfileIcon>
              {check_kor.test(comment.writer)
                ? comment.writer.slice(0, 1)
                : comment.writer.slice(0, 2)}
            </ProfileIcon>
            <Item>{comment.writer}</Item>

            <Item>{timeForToday(comment.created_at)}</Item>
          </Stack>

          {/* comment 글 내용 */}
          <Box
            key={index}
            sx={{ padding: "0px 20px", color: comment.exist || "grey" }}
          >
            <Markdown comment={comment} />
          </Box>

          {/* comment 수정 */}
          {comment.exist && user === comment.writer && (
            <>
              {openEditor === comment.commentId && (
                <Editor initialValue={comment.content} ref={editorRef} />
              )}
              <Button
                onClick={() => {
                  if (comment.commentId === openEditor) {
                    onEdit(comment.commentId);
                    setOpenEditor("");
                  } else {
                    setOpenEditor(comment.commentId);
                  }
                }}
              >
                수정
              </Button>

              {/* comment 삭제 */}
              <Button
                onClick={() => {
                  onRemove(comment.commentId);
                }}
              >
                삭제
              </Button>
            </>
          )}

          {/* 대댓글 컴포넌트 */}
          <ReplyComment responseTo={comment.commentId} user={user} />

          <Divider variant="middle" />
        </Box>
      ))}
    </Paper>
  );
};

export default Comment;

크게 달라진 부분은 다음과 같다.

  1. 지난 시간 표시 <Item>{timeForToday(comment.created_at)}</Item>
  2. 수정 삭제를 담당하는 onEdit, onRemove 이벤트
  3. 에디터에서 작성할 수 있는 기능 <Editor ref={editorRef} />
    (Editor는 import { Editor } from "@toast-ui/react-editor";를 통해 가져왔다.)

ReplyComment.jx

import React, { useState, useEffect, useRef } from "react";
import { Stack, Button, Divider } from "@mui/material";
import { Box } from "@mui/system";
import uuid from "react-uuid";

import { useSelector, useDispatch } from "react-redux";
import { addComment, editComment, removeComment } from "../redux/comment";
import Markdown from "../component/Markdown";
import { Editor } from "@toast-ui/react-editor";

import {
  check_kor,
  timeForToday,
  Item,
  ProfileIcon,
} from "../component/CommentTool";

const ReplyComment = ({ responseTo, user }) => {
  const [local, setLocal] = useState([]);
  const [display, setDisplay] = useState(false);

  const dispatch = useDispatch();
  const comments = useSelector((state) => state.comment);

  // mock user
  const editorRef = useRef();

  // open editor to edit comment
  const [openEditor, setOpenEditor] = useState("");

  const onSubmit = (e) => {
    e.preventDefault();
    const editorInstance = editorRef.current.getInstance();
    const getContent = editorInstance.getMarkdown();
    const date = new Date();

    let data = {
      content: getContent,
      writer: user,
      postId: "123123",
      responseTo: responseTo,
      commentId: uuid(),
      created_at: `${date}`,
    };
    dispatch(addComment(data));
  };

  // Edit comment
  const onEdit = (commentId) => {
    // console.log(commentId);
    const editorInstance = editorRef.current.getInstance();
    const getContent = editorInstance.getMarkdown();
    console.log(getContent);

    let data = { commentId: commentId, content: getContent };
    dispatch(editComment(data));
  };

  // Remove comment
  const onRemove = (commentId) => {
    dispatch(removeComment(commentId));
  };

  useEffect(() => {
    localStorage.setItem("reply", JSON.stringify(comments));
    setLocal(comments.filter((comment) => comment.responseTo === responseTo));
  }, [comments, responseTo]);
  return (
    <Stack sx={{ m: 1, ml: 4 }}>
      <Button
        onClick={() => {
          setDisplay(!display);
        }}
        sx={{ display: "flex", justifyContent: "flex-start", width: "10rem" }}
      >
        {display && "댓글 숨기기"}
        {!display &&
          (local.length === 0 ? "댓글 달기" : `${local.length}개의 댓글 보기`)}
      </Button>

      {display && (
        <div>
          {local.map((comment, index) => (
            <Box sx={{ m: 2 }} key={comment.commentId}>
              {/* writer 정보, 작성 시간 */}
              <Stack direction="row" spacing={2}>
                <ProfileIcon>
                  {check_kor.test(comment.writer)
                    ? comment.writer.slice(0, 1)
                    : comment.writer.slice(0, 2)}
                </ProfileIcon>
                <Item>{comment.writer}</Item>

                <Item>{timeForToday(comment.created_at)}</Item>
              </Stack>
              {/* comment 글 내용 */}
              <Box
                key={index}
                sx={{ padding: "0px 20px", color: comment.exist || "grey" }}
              >
                <Markdown comment={comment} />
              </Box>
              {/* comment 수정 */}
              {user === comment.writer && (
                <>
                  {openEditor === comment.commentId && (
                    <Editor initialValue={comment.content} ref={editorRef} />
                  )}
                  <Button
                    onClick={() => {
                      if (comment.commentId === openEditor) {
                        onEdit(comment.commentId);
                        setOpenEditor("");
                      } else {
                        setOpenEditor(comment.commentId);
                      }
                    }}
                  >
                    수정
                  </Button>

                  {/* comment 삭제 */}
                  <Button
                    onClick={() => {
                      onRemove(comment.commentId);
                    }}
                  >
                    삭제
                  </Button>
                </>
              )}
              {/* 대댓글 컴포넌트 */}
              <ReplyComment responseTo={comment.commentId} user={user} />
              <Divider variant="middle" />{" "}
            </Box>
          ))}

          <Editor
            ref={editorRef} //initialValue={"내용을 입력해주세요."}
          />

          <div>
            <Button onClick={onSubmit}>저장</Button>
          </div>
        </div>
      )}
    </Stack>
  );
};

export default ReplyComment;

아래의 코드는 대댓글의 상태에 따라 변화하는 버튼에 관한 코드이다.

{display && "댓글 숨기기"}
{!display && (local.length === 0 ? "댓글 달기" : `${local.length}개의 댓글 보기`)}

버튼을 누를때마다 display가 true, false로 토글된다.
만약 display===false(대댓글 접힌 상태)이고, local.length===0(대댓글이 0개임) 이면,
'댓글 달기'로 바꾸고, 대댓글이 1개 이상 존재한다면 능동적으로 'n개의 댓글 보기'로 바뀐다.

redux.js

addComment, editComment, removeComment의 action이 포함되어있음.

import { createSlice } from "@reduxjs/toolkit";

const initialState = localStorage.getItem("reply")
  ? [...JSON.parse(localStorage.getItem("reply"))]
  : [];
// 초기 상태를 localStorage에서 가져온다. 만약 'reply'가 존재하지 않으면 [] 사용
export const commentSlice = createSlice({
  name: "comment",
  initialState,
  reducers: {
    addComment(state, action) {
      const { content, writer, postId, responseTo, commentId, created_at } =
        action.payload;
      state.push({
        content,
        writer,
        postId,
        responseTo,
        commentId,
        created_at,
        exist: true, // 대댓글 있는 댓글 삭제 문제 때문에 임시로 넣어둠
      });
    },
    editComment(state, action) {
      // action의 payload에는 삭제될 댓글의 아이디가 담겨있음
      const { commentId, content } = action.payload;
      state.map((item) =>
        item.commentId === commentId ? (item.content = content) : item
      );
    },
    removeComment(state, action) {
      // 대댓글 존재하면, => content 내용만 바꾸기
      if (state.find((item) => item.responseTo === action.payload)) {
        state.map((item) =>
          item.commentId === action.payload
            ? (item.content = "삭제된 댓글입니다.") && (item.exist = false)
            : item
        );
        // 대댓글 존재하지 않으면, => 바로 삭제
      } else {
        if (state.find((item) => item.commentId === action.payload)) {
          return state.filter((item) => item.commentId !== action.payload);
        }
      }
    },
  },
  // 수정 기능은 해당 댓글 검색해서 마크다운 가져온 뒤(content), toast 에디터에 initialValue로 넣어주기
});
export const { addComment, editComment, removeComment } = commentSlice.actions;
export default commentSlice.reducer;

수정하는 부분을 어떻게 구현할 지 고민하다가, 작성되서 이미 저장된 content를 그대로 복사해서 에디터에 붙여넣은 후, 해당 에디터에서 수정하는 방법을 사용하기로 하였다.

수정 버튼을 누르면 다음과 같이 바뀐다.

삭제 부분은 2가지로 나눠서 처리하였다.
1. 삭제하려는 댓글에 대댓글이 존재하는 경우
content 정보를 지우고, 해당 자리에 '삭제한 댓글입니다.'를 출력
->이렇게 번거롭게 만든 이유는 댓글을 그냥 삭제시키면, 대댓글들이 붕 뜨기 때문이다.(+무수한 오류)

댓글을 삭제하였을 경우, 수정/삭제 버튼도 보이지 않게 만들었다.


  1. 삭제하려는 댓글에 대댓글이 존재하지 않는 경우
    해당 댓글의 정보를 저장소에서 아예 삭제시켜버린다.

component>CommentTool.js

CommentTool에는 Comment, ReplyComment에서 필요한 함수, 스타일들을 모듈로 만들었다.

import { styled } from "@mui/material/styles";
import { Avatar } from "@mui/material";
import { Box } from "@mui/system";

// 프로필 아이콘 글자 한글일때 구분
export const check_kor = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/;

// time
export function timeForToday(time) {
  const now = new Date();
  const created_at = new Date(time);

  const minute = Math.floor((now.getTime() - created_at.getTime()) / 1000 / 60);
  if (minute < 1) return "방금전";
  if (minute < 60) {
    return `${minute}분전`;
  }

  const hour = Math.floor(minute / 60);
  if (hour < 24) {
    return `${hour}시간전`;
  }

  const day = Math.floor(minute / 60 / 24);
  if (day < 365) {
    return `${day}일전`;
  }

  return `${Math.floor(day / 365)}년전`;
}

// style

export const Item = styled(Box)(({ theme }) => ({
  ...theme.typography.body2,
  paddingTop: theme.spacing(1),
  paddingBottom: theme.spacing(1),
  textAlign: "center",
  color: "#737373",
  fontSize: "1rem",
  lineHeight: "1rem",
}));

export const ProfileIcon = styled(Avatar)(() => ({
  backgroundColor: "orangered",
  width: "2rem",
  height: "2rem",
}));

여기에 있는 timeForToday 함수를 통해 'n 시간 전'과 같이 작성 시간-현재 시간 => 지나간 시간을 출력할 수 있게 만들었다.

component/Markdown.js

Markdown.js는 에디터를 통해 작성한 마크다운 문법을 우리가 댓글에서 시각적으로 볼 수 있게 만들어준다.
해당 부분은 다음과 같은 사이트를 참고해 만들었다.
https://www.npmjs.com/package/react-markdown

import React from "react";
import "@toast-ui/editor/dist/toastui-editor.css";

import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dark } from "react-syntax-highlighter/dist/cjs/styles/prism/atom-dark";

import ReactMarkdown from "react-markdown";

const Markdown = ({ comment }) => {
  return (
    <div>
      <ReactMarkdown
        children={comment.content}
        components={{
          code({ node, inline, className, children, ...props }) {
            const match = /language-(\w+)/.exec(className || "");
            return !inline && match ? (
              <SyntaxHighlighter
                children={String(children).replace(/\n$/, "")}
                style={dark}
                language={match[1]}
                PreTag="div"
                {...props}
              />
            ) : (
              <code className={className} {...props}>
                {comment.content}
              </code>
            );
          },
        }}
      />
    </div>
  );
};

export default Markdown;

동작 사진

우선 전체 코드를 보고싶으면 다음 링크를 클릭하면 된다.

코드: https://github.com/OseungKwon/Project-features/tree/main/reply

댓글 작성중

작성 결과

최종 화면

profile
꾸준하게 공부하기

0개의 댓글