From f5eb765398007bed8dd54bd05e28db6d0e769121 Mon Sep 17 00:00:00 2001 From: ghlim00 Date: Fri, 30 Jan 2026 16:40:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94,=20=EC=8B=AB=EC=96=B4=EC=9A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Reactions/PostReaction.jsx | 52 ++++++ .../pages/Community/CommunityDetailPage.jsx | 151 ++++++++++-------- .../src/pages/Community/CommunityListPage.jsx | 33 +++- 3 files changed, 165 insertions(+), 71 deletions(-) create mode 100644 Frontend/src/components/Reactions/PostReaction.jsx diff --git a/Frontend/src/components/Reactions/PostReaction.jsx b/Frontend/src/components/Reactions/PostReaction.jsx new file mode 100644 index 0000000..521b245 --- /dev/null +++ b/Frontend/src/components/Reactions/PostReaction.jsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import api from "@api/api.js"; + +export default function usePostReactions({ postId }) { + const [loading, setLoading] = useState(false); + + const request = async (type) => { + if (!postId) return { ok: false, error: { message: "no postId" } }; + + setLoading(true); + try { + const url = + type === "like" + ? `/api/posts/${postId}/likes` + : `/api/posts/${postId}/dislikes`; + + const res = await api.post(url); + + // ✅ 응답이 비어있을 수도 있음(204 or empty body) + const data = res?.data?.data ?? res?.data ?? null; + + const nextLike = data?.likeCount ?? data?.likes ?? data?.like_count ?? null; + const nextDislike = + data?.dislikeCount ?? data?.dislikes ?? data?.dislike_count ?? null; + + return { ok: true, nextLike, nextDislike, raw: res.data }; + } catch (e) { + const status = e?.response?.status; + const body = e?.response?.data; + return { + ok: false, + error: { + status, + body, + message: + body?.message || + body?.error || + JSON.stringify(body || {}, null, 2) || + e.message, + }, + }; + } finally { + setLoading(false); + } + }; + + return { + loading, + like: () => request("like"), + dislike: () => request("dislike"), + }; +} \ No newline at end of file diff --git a/Frontend/src/pages/Community/CommunityDetailPage.jsx b/Frontend/src/pages/Community/CommunityDetailPage.jsx index 2d96110..ba926e5 100644 --- a/Frontend/src/pages/Community/CommunityDetailPage.jsx +++ b/Frontend/src/pages/Community/CommunityDetailPage.jsx @@ -1,6 +1,9 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import api from "@api/api.js"; +import PostReaction from "@components/Reactions/PostReaction"; +import CommentsSection from "@components/PostComments/CommentsSection"; + const ghostBtn = "bg-transparent flex items-center gap-1 p-0 border-0 outline-none focus:outline-none text-gray-500 hover:text-black"; @@ -53,6 +56,8 @@ export default function CommunityDetailPage() { const [me, setMe] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const { loading: reactionLoading, like, dislike } = PostReaction({ postId }); + // "내 정보 조회" - 작성자 본인인지 판단하기 위해 필요 useEffect(() => { @@ -141,12 +146,10 @@ export default function CommunityDetailPage() { }; }, [postId]); - const comments = useMemo(() => post?.comments ?? [], [post]); - const commentCount = useMemo(() => { - if (typeof post?.commentCount === "number") return post.commentCount; - return comments.length; - }, [post, comments]); + const commentCount = useMemo(() => (post?.comments?.length ?? 0), [post]); + + /** ========================= * (권한) "내가 작성자인지" 판별 @@ -186,6 +189,54 @@ export default function CommunityDetailPage() { } }; + /** ========================= + * 좋아요, 싫어요 클릭 + * ========================= */ + const fetchPost = async () => { + const res = await api.get(`/api/posts/${postId}`); + setPost(res.data ?? null); + }; + + const applyReactionResult = async(result) => { + if (!result?.ok) { + alert( + `반응 실패 (status=${result?.error?.status ?? "?"})\n` + + (result?.error?.message ?? "unknown error") + ); + return; + } + + // ✅ 1) 서버가 카운트를 내려줬다면 그걸로 즉시 반영 + const hasCounts = result.nextLike != null || result.nextDislike != null; + + if (hasCounts) { + setPost((prev) => { + if (!prev) return prev; + return { + ...prev, + likeCount: result.nextLike != null ? result.nextLike : prev.likeCount, + dislikeCount: + result.nextDislike != null ? result.nextDislike : prev.dislikeCount, + }; + }); + return; + } + + // ✅ 2) 서버가 카운트를 안 주면(빈 바디/204) → GET로 다시 동기화 (정답) + await fetchPost(); + }; + + const onClickLikePost = async () => { + const result = await like(); + applyReactionResult(result); + }; + + const onClickDislikePost = async () => { + const result = await dislike(); + applyReactionResult(result); + }; + + /** ========================= * 에러 화면 * - 상세 조회 실패 시 보여줌 @@ -234,10 +285,6 @@ export default function CommunityDetailPage() { - const onClickAddComment = () => alert("아직 안됨"); - const onClickLikeComment = () => alert("아직 안됨"); - const onClickDislikeComment = () => alert("아직 안됨"); - const authorNickname = post?.author?.nickname ?? "(author 없음)"; const createdAtText = formatKST(post?.createdAt); const updatedAtText = formatKST(post?.updatedAt); @@ -264,14 +311,28 @@ export default function CommunityDetailPage() {
-
+
-
+ + +
+ +
comment {commentCount} @@ -310,66 +371,20 @@ export default function CommunityDetailPage() { {post.content ?? "(content 없음)"}
-
- setCommentText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") onClickAddComment(); - }} - /> - -
+ + -
- {comments.length === 0 ? ( -
댓글이 없습니다.
- ) : ( - comments.map((c) => { - const cAuthor = - c?.author?.nickname ?? c?.authorNickname ?? c?.author ?? "익명"; - const cCreatedAt = formatKST(c?.createdAt); - - return ( -
-
-
- face - {cAuthor} - {cCreatedAt} -
- -
- - -
-
- -

{c.content ?? ""}

-
- ); - }) - )} -
- {/* 디버깅용 + + {/* 디버깅용 */}
DEBUG: {debug}
             {JSON.stringify(raw, null, 2)}
-          
*/} +
diff --git a/Frontend/src/pages/Community/CommunityListPage.jsx b/Frontend/src/pages/Community/CommunityListPage.jsx index aa51ac8..adeae3a 100644 --- a/Frontend/src/pages/Community/CommunityListPage.jsx +++ b/Frontend/src/pages/Community/CommunityListPage.jsx @@ -101,6 +101,8 @@ import React, { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import api from "@api/api.js"; +import PostReaction from "@components/Reactions/PostReaction"; + /** ========================= * 날짜 포맷 유틸 (예: 2026-01-28T02:06:52 -> Jan.28.2026) @@ -148,8 +150,10 @@ export default function CommunityListPage() { const [errorMsg, setErrorMsg] = useState(""); const [debug, setDebug] = useState("INIT"); - // 4) 원본 응답 확인용 (문제 생기면 구조 파악하려고) + // 원본 응답 확인용 (문제 생기면 구조 파악하려고) //const [raw, setRaw] = useState(null); + // 좋아요/싫어요 요청 중 버튼 연타 방지용 (postId별로 막음) + const [busyMap, setBusyMap] = useState({}); // 5) 페이지 진입 시 게시글 목록 조회 useEffect(() => { @@ -237,10 +241,34 @@ export default function CommunityListPage() { // ========================= // 8) 추천(좋아요) 버튼 클릭 핸들러 // ========================= + const syncOnePost = async (postId) => { + const res = await api.get(`/api/posts/${postId}`); + const p = res.data?.data ?? res.data ?? null; + + const nextLike = p?.likeCount ?? p?.likes ?? p?.like_count; + + setPosts((prev) => + prev.map((row) => { + if (String(row.id) !== String(postId)) return row; + return { + ...row, + likeCount: nextLike ?? row.likeCount, + }; + }) + ); + }; + + const onLike = async (postId) => { - alert("추천 API 연결 전입니다."); + try { + await api.post(`/api/posts/${postId}/likes`); + await syncOnePost(postId); // ✅ 응답 바디가 없으니 GET으로 동기화 + } catch (e) { + alert("추천 실패"); + } }; + return (
@@ -287,7 +315,6 @@ export default function CommunityListPage() { {/* ========================= (선택) 디버그 영역: 서버 응답 구조 확인용 - 안정화되면 지워도 됨 ========================= */} {/*
DEBUG: {debug}
*/} {/*

From 98c833c54bdfda3ea783023c37a4da8a38b9eecb Mon Sep 17 00:00:00 2001
From: ghlim00 
Date: Fri, 30 Jan 2026 16:40:43 +0900
Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?=
 =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../components/PostComments/CommentForm.jsx   | 49 +++++++++++
 .../components/PostComments/CommentItem.jsx   | 83 +++++++++++++++++++
 .../components/PostComments/CommentList.jsx   | 21 +++++
 .../PostComments/CommentsSection.jsx          | 17 ++++
 .../src/components/PostComments/ReplyForm.jsx |  0
 .../src/components/PostComments/ReplyList.jsx |  0
 6 files changed, 170 insertions(+)
 create mode 100644 Frontend/src/components/PostComments/CommentForm.jsx
 create mode 100644 Frontend/src/components/PostComments/CommentItem.jsx
 create mode 100644 Frontend/src/components/PostComments/CommentList.jsx
 create mode 100644 Frontend/src/components/PostComments/CommentsSection.jsx
 create mode 100644 Frontend/src/components/PostComments/ReplyForm.jsx
 create mode 100644 Frontend/src/components/PostComments/ReplyList.jsx

diff --git a/Frontend/src/components/PostComments/CommentForm.jsx b/Frontend/src/components/PostComments/CommentForm.jsx
new file mode 100644
index 0000000..0cb386d
--- /dev/null
+++ b/Frontend/src/components/PostComments/CommentForm.jsx
@@ -0,0 +1,49 @@
+import React, { useState } from "react";
+import api from "@api/api.js";
+
+export default function CommentForm({ postId, onSuccess }) {
+  const [content, setContent] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [errMsg, setErrMsg] = useState("");
+
+  const submit = async (e) => {
+    e?.preventDefault?.();
+    const text = content.trim();
+    if (!text) return;
+
+    setIsSubmitting(true);
+    setErrMsg("");
+
+    try {
+      await api.post(`/api/posts/${postId}/comments`, { content: text });
+      setContent("");
+      onSuccess?.();
+    } catch (err) {
+      console.error("댓글 작성 실패", err);
+      setErrMsg("댓글 작성에 실패했습니다.");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    
+ setContent(e.target.value)} + className="flex-1 border rounded p-2" + placeholder="댓글을 입력하세요" + disabled={isSubmitting} + /> + + + {errMsg &&
{errMsg}
} +
+ ); +} diff --git a/Frontend/src/components/PostComments/CommentItem.jsx b/Frontend/src/components/PostComments/CommentItem.jsx new file mode 100644 index 0000000..1a6f7e9 --- /dev/null +++ b/Frontend/src/components/PostComments/CommentItem.jsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import api from "@api/api.js"; + +export default function CommentItem({ comment, postId, onRefresh }) { + const commentId = comment?.commentId ?? comment?.id; + const nickname = + comment?.author?.nickname ?? + comment?.writer?.nickname ?? + comment?.writer ?? + "익명"; + const [isDeleting, setIsDeleting] = useState(false); + const [errMsg, setErrMsg] = useState(""); + + const deleteComment = async () => { + if (!commentId) return; + setIsDeleting(true); + setErrMsg(""); + + try { + await api.delete(`/api/posts/${postId}/comments/${commentId}`); + onRefresh?.(); + } catch (e) { + console.error("댓글 삭제 실패", e); + setErrMsg("삭제에 실패했습니다."); + } finally { + setIsDeleting(false); + } + }; + + const like = async () => { + if (!commentId) return; + try { + await api.post(`/api/posts/comments/${commentId}/likes`); + onRefresh?.(); + } catch (e) { + console.error("댓글 좋아요 실패", e); + } + }; + + const dislike = async () => { + if (!commentId) return; + try { + await api.post(`/api/posts/comments/${commentId}/dislikes`); + onRefresh?.(); + } catch (e) { + console.error("댓글 싫어요 실패", e); + } + }; + + return ( +
+
+ {nickname} + + +
+ +

+ {comment?.content ?? ""} +

+ +
+ + +
+ + {errMsg &&
{errMsg}
} +
+ ); +} diff --git a/Frontend/src/components/PostComments/CommentList.jsx b/Frontend/src/components/PostComments/CommentList.jsx new file mode 100644 index 0000000..3d78fe6 --- /dev/null +++ b/Frontend/src/components/PostComments/CommentList.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import CommentItem from "./CommentItem"; + +export default function CommentList({ comments, postId, onRefresh }) { + if (!comments || comments.length === 0) { + return
첫 댓글을 남겨보세요.
; + } + + return ( +
+ {comments.map((c) => ( + + ))} +
+ ); +} diff --git a/Frontend/src/components/PostComments/CommentsSection.jsx b/Frontend/src/components/PostComments/CommentsSection.jsx new file mode 100644 index 0000000..ac6ee52 --- /dev/null +++ b/Frontend/src/components/PostComments/CommentsSection.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import CommentForm from "./CommentForm"; +import CommentList from "./CommentList"; + +export default function CommentsSection({ postId, comments, onRefreshPost }) { + return ( +
+ + + +
+ ); +} diff --git a/Frontend/src/components/PostComments/ReplyForm.jsx b/Frontend/src/components/PostComments/ReplyForm.jsx new file mode 100644 index 0000000..e69de29 diff --git a/Frontend/src/components/PostComments/ReplyList.jsx b/Frontend/src/components/PostComments/ReplyList.jsx new file mode 100644 index 0000000..e69de29 From c755bd98ab4a9e38a36f65ce9ae53850f6805a42 Mon Sep 17 00:00:00 2001 From: ghlim00 Date: Fri, 30 Jan 2026 16:49:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8C=80=EB=8C=93=EA=B8=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PostComments/CommentForm.jsx | 23 ++++-- .../components/PostComments/CommentItem.jsx | 74 +++++++++++++++---- .../src/components/PostComments/ReplyList.jsx | 21 ++++++ .../pages/Community/CommunityDetailPage.jsx | 4 +- 4 files changed, 98 insertions(+), 24 deletions(-) diff --git a/Frontend/src/components/PostComments/CommentForm.jsx b/Frontend/src/components/PostComments/CommentForm.jsx index 0cb386d..b750974 100644 --- a/Frontend/src/components/PostComments/CommentForm.jsx +++ b/Frontend/src/components/PostComments/CommentForm.jsx @@ -1,7 +1,14 @@ import React, { useState } from "react"; import api from "@api/api.js"; -export default function CommentForm({ postId, onSuccess }) { +export default function CommentForm({ + postId, + parentId = null, + onSuccess, + placeholder, + autoFocus = false, + compact = false, +}) { const [content, setContent] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [errMsg, setErrMsg] = useState(""); @@ -15,25 +22,29 @@ export default function CommentForm({ postId, onSuccess }) { setErrMsg(""); try { - await api.post(`/api/posts/${postId}/comments`, { content: text }); + await api.post(`/api/posts/${postId}/comments`, { + content: text, + parentId, // ✅ null이면 댓글, 숫자면 대댓글 + }); setContent(""); onSuccess?.(); } catch (err) { - console.error("댓글 작성 실패", err); - setErrMsg("댓글 작성에 실패했습니다."); + console.error("댓글/대댓글 작성 실패", err); + setErrMsg("작성에 실패했습니다."); } finally { setIsSubmitting(false); } }; return ( -
+ setContent(e.target.value)} className="flex-1 border rounded p-2" - placeholder="댓글을 입력하세요" + placeholder={placeholder ?? (parentId ? "대댓글을 입력하세요" : "댓글을 입력하세요")} disabled={isSubmitting} + autoFocus={autoFocus} />
-

- {comment?.content ?? ""} -

+

{comment?.content ?? ""}

+ + {/* 댓글(1차)일 때만 답글 버튼 */} + {depth === 1 && ( + + )}
+ {/* 대댓글 작성 폼 */} + {depth === 1 && replyOpen && ( +
+ { + setReplyOpen(false); + onRefresh?.(); + }} + placeholder="대댓글을 입력하세요" + compact + autoFocus + /> +
+ )} + + {/* 대댓글 렌더링 */} + {depth === 1 && ( + + )} + {errMsg &&
{errMsg}
} ); diff --git a/Frontend/src/components/PostComments/ReplyList.jsx b/Frontend/src/components/PostComments/ReplyList.jsx index e69de29..17f2137 100644 --- a/Frontend/src/components/PostComments/ReplyList.jsx +++ b/Frontend/src/components/PostComments/ReplyList.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import CommentItem from "./CommentItem"; + +export default function ReplyList({ replies, postId, onRefresh, parentId }) { + if (!replies || replies.length === 0) return null; + + return ( +
+ {replies.map((r) => ( + + ))} +
+ ); +} diff --git a/Frontend/src/pages/Community/CommunityDetailPage.jsx b/Frontend/src/pages/Community/CommunityDetailPage.jsx index ba926e5..e559705 100644 --- a/Frontend/src/pages/Community/CommunityDetailPage.jsx +++ b/Frontend/src/pages/Community/CommunityDetailPage.jsx @@ -381,10 +381,10 @@ export default function CommunityDetailPage() { {/* 디버깅용 */} -
DEBUG: {debug}
+ {/*
DEBUG: {debug}
             {JSON.stringify(raw, null, 2)}
-          
+ */}