Ssul's Blog

[Django, tailwind] AI가 상담글에 자동으로 댓글 달아주기 #2 (react, tailwind) 본문

dev/기능구현

[Django, tailwind] AI가 상담글에 자동으로 댓글 달아주기 #2 (react, tailwind)

Ssul 2024. 1. 24. 14:27

#0. 프론트 글쓰기, 댓글 작업

- 백앤드에서 익숙한 CRUD

- 프론트만 오면 왜 이렇게 헤깔리는지... 이번에 정리해보자!!!

 

 

#1. api 정리

- 글쓰기/글목록: api/community/cp/ + post, get

- 글상세/수정: api/community/cp/cp_id/ + post, get

- 댓글쓰기/댓글목록: api/community/cp/cp_id/cpc/ + post, get

- 댓글상세/수정: api/community/cp/cp_id/cpc/cpc_id

 

 

#2. 글쓰기/글목록 작업

2-1. src/app/(route)/counseling/page.tsx

- nextjs특성상 '도메인주소/counseling'으로 src/app/(route)/counseling/layout.tsx 접근

export default function Layout(props) {
  return (
    <>
      <h2>Layout counseling</h2>
      {props.children}
    </>
  );
}

 

- children이 src/app/(route)/counseling/page.tsx임

"use client";

import CounselingPostList from "@/app/_components/Community/CounselingPostList";
import useAuth from "@/app/hooks/useAuth";

export default function Counseling() {
  const user = useAuth();
  return (
    <>
      <div>List</div>
      <CounselingPostList />
    </>
  );
}

- PostList를 출력하는 컴포넌트를 호출합니다.

 

2-2. scr/app/_components/Community/CounselingPostList/index.tsx

import CounselingPostCard from "../CounselingPostCard";
import { useEffect, useState } from "react";
import axios from "axios";
import { useSelector } from "react-redux";
import PostModal from "../../Common/PostModal";

export default function CounselingPostList() {
  // const user = useAuth();
  const user = useSelector((state) => state.user);
  const [counselingPosts, setCounselingPosts] = useState([]);
  const [isModalOpen, setModalOpen] = useState(false);

  const fetchCounselingPosts = async () => {
    try {
      const token = user?.accessToken || "";
      const response = await axios.get(
        `${process.env.NEXT_PUBLIC_API_URL}/community/cp/`,
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      );
      setCounselingPosts(response.data);
    } catch (error) {
      console.error("Error fetching posts", error);
    }
  };

  useEffect(() => {
    fetchCounselingPosts();
  }, [user]);

  return (
    <>
      <div className="bg-[#F7F7F7] pb-10 pt-14">
        <div className="mx-20 font-bold">교육상담 게시판</div>
        <div className="mx-20">
          {counselingPosts.map((counselingPost) => (
            <CounselingPostCard
              key={counselingPost.cp_index}
              counselingPost={counselingPost}
            />
          ))}
        </div>
        <PostModal
          isOpen={isModalOpen}
          onClose={() => setModalOpen(!isModalOpen)}
          fetchPosts={fetchCounselingPosts}
        />
      </div>
      {/* 글쓰기 버튼 */}
      <div
        className="fixed bottom-4 right-4 cursor-pointer rounded-full bg-blue-500 p-3 text-white"
        onClick={() => setModalOpen(!isModalOpen)}
      >
        글쓰기
      </div>
    </>
  );
}

- const user = useSelector((state) => state.user): 리덕스에서 로그인시 저장된 user정보를 가져옵니다.

- fetchCounselingPosts: get호출을 통해서, 글 리스트를 받아옵니다.

- 받아온 글 리스트를 map으로 돌면서 CounselingPostCard 컴포넌트로 한개씩 출력합니다.

PostCard로 출력되는 글

- 글쓰기 버튼을 클릭하면, isModalOpen이 true가 되면서 PostModal컴포넌트가 호출됩니다.

 

 

2-3. scr/app/_components/Community/Common/PostModal.tsx

import { useSelector } from "react-redux";
import PostForm, { usePostForm } from "./PostForm";
import axios from "axios";
import { useEffect } from "react";

interface PostModalProps {
  isOpen: boolean;
  onClose: () => void;
  fetchPosts: () => void;
}

export default function PostModal({
  isOpen,
  onClose,
  fetchPosts,
}: PostModalProps) {
  const user = useSelector((state) => state.user);
  const postForm = usePostForm();

  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.keyCode === 27 && isOpen) {
        onClose(); // ESC 키를 누르면 onClose 함수 호출
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [onClose]);

  const handleBackdropClick = (e) => {
    if (e.target === e.currentTarget) {
      onClose(); // 배경 클릭 시 onClose 함수 호출
    }
  };

  if (!isOpen) return null;

  const addPost = async (e) => {
    e.preventDefault();
    // user 객체에서 토큰을 얻습니다.
    const token = user?.accessToken || "";
    const { content, setContent } = postForm;
    if (content) {
      try {
        const response = await axios.post(
          `${process.env.NEXT_PUBLIC_API_URL}/community/cp/`,
          {
            cp_u_index: user.userInfo.id,
            cp_content: content,
          },
          {
            headers: {
              Authorization: `Bearer ${token}`,
            },
          },
        );
        setContent("");
        onClose(); // 새글 입력 후 돌아가기
        fetchPosts();
      } catch (error) {
        console.log("Failed to add Post");
      }
    }
  };

  return (
    <div
      className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
      onClick={handleBackdropClick}
    >
      <div className="modal-content mx-4 w-full max-w-3xl rounded-lg bg-white p-4 shadow-lg">
        <PostForm {...postForm} onSubmit={addPost} />
        <button className="mx-4" onClick={onClose}>
          취소
        </button>
      </div>
    </div>
  );
}

- PostForm을 통해서 글자를 입력받고,

- 버튼이 클릭되면 addPost함수를 호출해서, 글쓰기 실행

- 여기서 PostForm은 앞으로 폼을 입력받을때 자주 사용되는 구조이니, 잘 익혀두면 유용(댓글쓰기할때도 사용)

- src/app/_components/Common/PostForm.tsx 선언

import { Dispatch, SetStateAction, useState } from "react";

type UsePostFormReturn = {
  content: string;
  setContent: Dispatch<SetStateAction<string>>;
};

export function usePostForm() {
  const [content, setContent] = useState("");
  return {
    content,
    setContent,
  };
}

type Props = UsePostFormReturn & {
  onSubmit(): (e: React.FormEvent) => Promise<void>;
};

export default function PostForm({ content, setContent, onSubmit }: Props) {
  return (
    <div className="flex w-full px-4 py-2">
      <textarea
        className="flex-grow resize-none border border-[#333333] bg-[#FBECB9] focus:border-[#333333]"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button
        className="ml-2 rounded-lg border border-[#333333] bg-[#3372F1] px-2 text-white"
        onClick={onSubmit}
      >
        등록
      </button>
    </div>
  );
}

- usePostForm 훅을 선언하여, 입력될 데이터에 대해서 언제든 사용할 수 있도록 함

- PostFrom컴포넌트는 usePostForm의 내용과 백앤드 api호출하는 함수를 받아서 실행

 

PostModal에서....

- const postForm = usePostForm(): 폼에 입력된 데이터를 가져오고, 당연히 처음에는 아무것도 없음. 하지만 입력이 있으면 ...postForm으로 가져올수 있음

- <PostForm {...postForm} onSubmit={addPost} /> 이렇게 가져온 데이터와 백엔드 호출함수를 넘겨서 PostForm 컴포넌트를 호출합니다.

완성된 글쓰기

 

3. 댓글쓰기/댓글목록 작업

- 댓글쓰기는 어떻게 하면 될까?

- 2번에서 작업했던 글쓰기를 그대로 한개의 게시글당 진행하면 됨

 

3-1. scr/app/_components/Community/CounselingPostCard

- PostList는 글 목록이 출력되고,

- 글 한개는 CounselingPostCard

- 이 컴포넌트 안에 댓글쓰기, 댓글출력을 작업하면 됨

import axios from "axios";
import { formatDistanceToNow } from "date-fns";
import * as Locale from "date-fns/locale";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import CommentModal from "../../Common/CommentModal";
import { useCommentForm } from "../../Common/CommentForm";

interface CounselingPostProps {
  counselingPost: {
    cp_index: number;
    cp_u_index: number;
    cp_content: string;
    cp_comments_count: number;
    cp_created_at: string;
    cp_updated_at: string;
  };
}

export default function CounselingPostCard({
  counselingPost,
}: CounselingPostProps) {
  const user = useSelector((state) => state.user);
  const [isModalOpen, setModalOpen] = useState(false);
  const [comments, setComments] = useState([]);
  const commentForm = useCommentForm();

  const addComment = async (e) => {
    e.preventDefault();
    // user 객체에서 토큰을 얻습니다.
    const token = user?.accessToken || "";
    const { content, setContent } = commentForm;

    if (content) {
      try {
        const response = await axios.post(
          `${process.env.NEXT_PUBLIC_API_URL}/community/cp/${counselingPost.cp_index}/cpc/`,
          {
            cpc_u_index: user.userInfo.id,
            cpc_cp_index: counselingPost.cp_index,
            cpc_content: content,
            cpc_is_ai: false,
          },
          {
            headers: {
              Authorization: `Bearer ${token}`,
            },
          },
        );
        setContent("");
        fetchComments(); // 새 댓글 추가 후 댓글 목록 새로고침
      } catch (error) {
        console.log("Failed to add Comment");
      }
    }
  };

  const fetchComments = async () => {
    try {
      // user 객체에서 토큰을 얻습니다.
      const token = user?.accessToken || "";

      console.log(token);
      console.log(counselingPost.cp_index);

      const response = await axios.get(
        `${process.env.NEXT_PUBLIC_API_URL}/community/cp/${counselingPost.cp_index}/cpc`,
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      );
      console.log(response);
      setComments(response.data);
    } catch (error) {
      console.error("Error fetching comments:", error);
    }
  };

  useEffect(() => {
    if (isModalOpen) {
      fetchComments();
    }
  }, [isModalOpen]);

  return (
    <div className="rounded-xl border-2 border-gray-100 bg-white">
      <div className="flex items-start gap-4 p-4 sm:p-6 lg:p-8">
        <a href="#" className="block shrink-0">
          <img
            alt="Profile"
            src="https://api.dicebear.com/7.x/lorelei/svg"
            className="h-14 w-14 rounded-lg object-cover"
          />
        </a>

        <div>
          <div>
            <p className="line-clamp-2 text-sm text-gray-700">
              {formatDistanceToNow(new Date(counselingPost.cp_created_at), {
                locale: Locale.ko,
              })}
              전
            </p>
          </div>

          <p className="line-clamp-2 text-sm text-gray-700">
            {counselingPost.cp_content}
          </p>

          <div
            className="mt-2 sm:flex sm:items-center sm:gap-2"
            onClick={() => setModalOpen(!isModalOpen)}
          >
            <div className="flex items-center gap-1 text-gray-500">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                className="h-4 w-4"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                strokeWidth="2"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
                />
              </svg>

              <p className="text-xs">
                {counselingPost.cp_comments_count} comments
              </p>
            </div>
          </div>
        </div>
      </div>
      <CommentModal
        isOpen={isModalOpen}
        comments={comments}
        addComment={addComment}
        commentForm={commentForm}
      />
    </div>
  );
}

- fetchComments함수를 통해서, 해당 글에 달려있는 댓글 목록을 가져온다.

- 댓글을 처음에는 보여주지 않다가, 댓글말풍선을 클릭했을때 보여준다.

- 댓글을 클릭하면 댓글모달(CommentModal)이 보여주는 형태

- 해당 모달에는 댓글리스트, 댓글을 추가하는 함수, 댓글 입력Form을 전달한다

 

3-2. scr/app/_components/Community/Common/CommentModal.tsx

- 받은 댓글목록을 map을 돌면서 CommentCard로 댓글 한개씩 출력

- CommentForm으로 입력데이터와 입력함수를 입력받아, 댓글을 작성한다

import { TComment } from "@/models/types";
import CommentCard from "./CommentCard";
import CommentForm, { useCommentForm } from "./CommentForm";
import axios from "axios";
import { useSelector } from "react-redux";

interface CommentModalProps {
  isOpen: boolean;
  comments: TComment[];
  addComment: (e: React.FormEvent) => Promise<void>;
  commentForm: ReturnType<typeof useCommentForm>;
}

export default function CommentModal({
  isOpen,
  comments,
  addComment,
  commentForm,
}: CommentModalProps) {
  if (!isOpen) return null;

  return (
    <div className="modal-background">
      <div className="modal-content">
        {comments.map((comment) => (
          <CommentCard key={comment.cpc_index} comment={comment} />
        ))}
      </div>
      <div>
        <CommentForm {...commentForm} onSubmit={addComment} />
      </div>
    </div>
  );
}

 

3-3. scr/app/_components/Community/Common/CommentCard.tsx

import { TComment } from "@/models/types";
import { formatDistanceToNow } from "date-fns";
import * as Locale from "date-fns/locale";

export default function CommentCard({ comment }: TComment) {
  return (
    <div className="flex px-4 py-2">
      <img
        className="mr-4 h-8 w-8 rounded-full"
        // src={comment.writer.image}
        src="https://api.dicebear.com/7.x/lorelei/svg"
        alt="Avatar"
      />
      <div>
        <div>
          <span className="text-sm font-bold">{comment.cpc_u_index}</span>
          <span className="ml-2 text-sm text-gray-400">
            {formatDistanceToNow(new Date(comment.cpc_updated_at), {
              locale: Locale.ko,
            })}
            전
          </span>
        </div>
        <p className="text-sm">{comment.cpc_content}</p>
      </div>
    </div>
  );
}

- comment데이터 한개씩 받아서 댓글 내용 출력

 

3-4. scr/app/_components/Community/Common/CommentForm.tsx

import { Dispatch, SetStateAction, useState } from "react";

type UseCommentFormReturn = {
  content: string;
  setContent: Dispatch<SetStateAction<string>>;
};

export function useCommentForm() {
  const [content, setContent] = useState("");
  return {
    content,
    setContent,
  };
}

type Props = UseCommentFormReturn & {
  onSubmit(): (e: React.FormEvent) => Promise<void>;
};

export default function CommentForm({ content, setContent, onSubmit }: Props) {
  return (
    <div className="flex w-full px-4 py-2">
      <textarea
        className="flex-grow resize-none border border-[#333333] bg-[#FBECB9] focus:border-[#333333]"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button
        className="ml-2 rounded-lg border border-[#333333] bg-[#3372F1] px-2 text-white"
        onClick={onSubmit}
      >
        등록
      </button>
    </div>
  );
}

- 글작성과 동일하게 useCommentForm 훅을 선언

- 입력받은 데이터와 입력함수(백엔드 코멘트 post)를 사용하여 댓글 생성

 

댓글도 잘 작동

 

4. 정리

- 이렇게 하여, 익명게시판에 게시글이 올라오면, AI가 바로 응답을 해주고, 다른 구성원도 댓글을 달수 있는 익명게시판 완성

- 백엔드에서는 일반적인 글/댓글 CRUD와 signal을 활용해서, 글이 post_save가 되었을때 gpt api를 호출하는 구조

- 프론트앤드에서는 데이터를 입력받아, 백엔드에 post/get함수를 호출하는 구조