diff --git a/Frontend/src/components/PostComments/CommentForm.jsx b/Frontend/src/components/PostComments/CommentForm.jsx
new file mode 100644
index 0000000..b750974
--- /dev/null
+++ b/Frontend/src/components/PostComments/CommentForm.jsx
@@ -0,0 +1,60 @@
+import React, { useState } from "react";
+import api from "@api/api.js";
+
+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("");
+
+ 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,
+ parentId, // ✅ null이면 댓글, 숫자면 대댓글
+ });
+ setContent("");
+ onSuccess?.();
+ } catch (err) {
+ console.error("댓글/대댓글 작성 실패", err);
+ setErrMsg("작성에 실패했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/Frontend/src/components/PostComments/CommentItem.jsx b/Frontend/src/components/PostComments/CommentItem.jsx
new file mode 100644
index 0000000..1aa34b0
--- /dev/null
+++ b/Frontend/src/components/PostComments/CommentItem.jsx
@@ -0,0 +1,125 @@
+import React, { useState } from "react";
+import api from "@api/api.js";
+import CommentForm from "./CommentForm";
+import ReplyList from "./ReplyList";
+
+export default function CommentItem({ comment, postId, onRefresh, depth = 1 }) {
+ const commentId = comment?.id;
+
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [errMsg, setErrMsg] = useState("");
+
+ const [replyOpen, setReplyOpen] = useState(false);
+
+ const nickname =
+ comment?.author?.nickname ??
+ comment?.writer?.nickname ??
+ comment?.writer ??
+ "익명";
+
+ 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 ?? ""}
+
+
+
+
+
+ {/* 댓글(1차)일 때만 답글 버튼 */}
+ {depth === 1 && (
+
+ )}
+
+
+ {/* 대댓글 작성 폼 */}
+ {depth === 1 && replyOpen && (
+
+ {
+ setReplyOpen(false);
+ onRefresh?.();
+ }}
+ placeholder="대댓글을 입력하세요"
+ compact
+ autoFocus
+ />
+
+ )}
+
+ {/* 대댓글 렌더링 */}
+ {depth === 1 && (
+
+ )}
+
+ {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..17f2137
--- /dev/null
+++ 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/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..e559705 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,63 +371,17 @@ 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}
+ {/* 디버깅용 */}
+ {/*
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}
*/}
{/*