Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Frontend/src/components/PostComments/CommentForm.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={submit} className={`flex gap-2 ${compact ? "" : "mb-4"}`}>
<input
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1 border rounded p-2"
placeholder={placeholder ?? (parentId ? "대댓글을 입력하세요" : "댓글을 입력하세요")}
disabled={isSubmitting}
autoFocus={autoFocus}
/>
<button
type="submit"
className="px-3 py-2 bg-black text-white rounded disabled:opacity-50"
disabled={isSubmitting}
>
{isSubmitting ? "작성 중…" : "작성"}
</button>

{errMsg && <div className="w-full text-sm text-red-500 mt-2">{errMsg}</div>}
</form>
);
}
125 changes: 125 additions & 0 deletions Frontend/src/components/PostComments/CommentItem.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`rounded p-3 ${depth === 2 ? "bg-gray-50" : ""}`}>
<div className="flex justify-between items-center">
<span className="font-semibold">{nickname}</span>

<button
onClick={deleteComment}
className="text-red-500 text-sm disabled:opacity-50"
disabled={isDeleting}
type="button"
>
{isDeleting ? "삭제 중…" : "삭제"}
</button>
</div>

<p className="my-2 whitespace-pre-wrap break-words">{comment?.content ?? ""}</p>

<div className="flex gap-3 text-sm text-gray-600">
<button type="button" onClick={like} className="flex items-center gap-1">
<span className="material-symbols-outlined text-base leading-none">thumb_up</span>
{comment?.likeCount ?? 0}
</button>
<button type="button" onClick={dislike} className="flex items-center gap-1">
<span className="material-symbols-outlined text-base leading-none">thumb_down</span>
{comment?.dislikeCount ?? 0}
</button>

{/* 댓글(1차)일 때만 답글 버튼 */}
{depth === 1 && (
<button
type="button"
onClick={() => setReplyOpen((v) => !v)}
className="ml-2 text-gray-500 hover:text-black"
>
{replyOpen ? "답글 닫기" : "답글"}
</button>
)}
</div>

{/* 대댓글 작성 폼 */}
{depth === 1 && replyOpen && (
<div className="mt-3 pl-6">
<CommentForm
postId={postId}
parentId={commentId} // ✅ 여기!
onSuccess={() => {
setReplyOpen(false);
onRefresh?.();
}}
placeholder="대댓글을 입력하세요"
compact
autoFocus
/>
</div>
)}

{/* 대댓글 렌더링 */}
{depth === 1 && (
<ReplyList
replies={comment?.replies ?? []}
postId={postId}
onRefresh={onRefresh}
parentId={commentId}
/>
)}

{errMsg && <div className="text-sm text-red-500 mt-2">{errMsg}</div>}
</div>
);
}
21 changes: 21 additions & 0 deletions Frontend/src/components/PostComments/CommentList.jsx
Original file line number Diff line number Diff line change
@@ -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 <div className="text-sm text-gray-500">첫 댓글을 남겨보세요.</div>;
}

return (
<div className="space-y-3">
{comments.map((c) => (
<CommentItem
key={c.commentId ?? c.id}
comment={c}
postId={postId}
onRefresh={onRefresh}
/>
))}
</div>
);
}
17 changes: 17 additions & 0 deletions Frontend/src/components/PostComments/CommentsSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from "react";
import CommentForm from "./CommentForm";
import CommentList from "./CommentList";

export default function CommentsSection({ postId, comments, onRefreshPost }) {
return (
<div className="mt-6">
<CommentForm postId={postId} onSuccess={onRefreshPost} />

<CommentList
comments={comments}
postId={postId}
onRefresh={onRefreshPost}
/>
</div>
);
}
Empty file.
21 changes: 21 additions & 0 deletions Frontend/src/components/PostComments/ReplyList.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-2 space-y-2 pl-6 border-l">
{replies.map((r) => (
<CommentItem
key={r.id}
comment={r}
postId={postId}
onRefresh={onRefresh}
depth={2}
parentId={parentId}
/>
))}
</div>
);
}
52 changes: 52 additions & 0 deletions Frontend/src/components/Reactions/PostReaction.jsx
Original file line number Diff line number Diff line change
@@ -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"),
};
}
Loading