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 ( +
+ setContent(e.target.value)} + className="flex-1 border rounded p-2" + placeholder={placeholder ?? (parentId ? "대댓글을 입력하세요" : "댓글을 입력하세요")} + disabled={isSubmitting} + autoFocus={autoFocus} + /> + + + {errMsg &&
{errMsg}
} +
+ ); +} 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}
*/} {/*