Ssul's Blog
[Django, tailwind] AI가 상담글에 자동으로 댓글 달아주기 #2 (react, tailwind) 본문
#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 컴포넌트로 한개씩 출력합니다.
- 글쓰기 버튼을 클릭하면, 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함수를 호출하는 구조
'dev > 기능구현' 카테고리의 다른 글
ios push알림 기능 설정 (0) | 2025.02.25 |
---|---|
[ChatGPT, DALLE2] 인공지능 카카오챗봇 만들기 (2) | 2024.02.07 |
[Django, tailwind] AI가 상담글에 자동으로 댓글 달아주기 #1(signal, threading사용) (0) | 2024.01.18 |
Youtube 영상 정보/자막 정보 추출방법 (2) | 2024.01.12 |
[Django] username대신, email 사용하기, jwt로그인 2 (1) | 2023.12.19 |