Skip to content

Commit c25b7d9

Browse files
authored
✨ 댓글 기능능 (#31)
1 parent f1ca938 commit c25b7d9

10 files changed

Lines changed: 920 additions & 3 deletions

File tree

springAPI.md

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.

src/apis/commentApi.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { apiClientWithAuth, apiClientNoAuth } from './apiClient'
2+
import { Comment, PageResponse } from '@/utils/types'
3+
4+
// 특정 게시물의 루트 댓글 목록 조회
5+
export const getComments = (postId: string) => {
6+
return apiClientNoAuth<Comment[]>(`/api/posts/${postId}/comments`)
7+
}
8+
9+
// 게시글 전체 댓글 수 조회
10+
export const getCommentsCount = (postId: string) => {
11+
return apiClientNoAuth<number>(`/api/posts/${postId}/comments/count`)
12+
}
13+
14+
// 대댓글 목록 조회
15+
export const getChildComments = (commentId: number) => {
16+
return apiClientNoAuth<PageResponse<Comment>>(
17+
`/api/comments/${commentId}/children`,
18+
)
19+
}
20+
21+
// 댓글 생성
22+
export const createComment = ({
23+
postId,
24+
content,
25+
parentId,
26+
}: {
27+
postId: string
28+
content: string
29+
parentId?: number | null
30+
}) => {
31+
return apiClientWithAuth<Comment>('/api/posts/' + postId + '/comments', {
32+
method: 'POST',
33+
body: JSON.stringify({ content, parentId }),
34+
})
35+
}
36+
37+
// 댓글 수정
38+
export const updateComment = ({
39+
commentId,
40+
content,
41+
}: {
42+
commentId: number
43+
content: string
44+
}) => {
45+
return apiClientWithAuth<Comment>(`/api/comments/${commentId}`, {
46+
method: 'PUT',
47+
body: JSON.stringify({ content }),
48+
})
49+
}
50+
51+
// 댓글 삭제
52+
export const deleteComment = (commentId: number) => {
53+
return apiClientWithAuth<void>(`/api/comments/${commentId}`, {
54+
method: 'DELETE',
55+
})
56+
}
57+
58+
// 댓글 좋아요 토글
59+
export const toggleCommentLike = (commentId: number) => {
60+
return apiClientWithAuth<void>(`/api/comments/${commentId}/likes`, {
61+
method: 'POST',
62+
})
63+
}

src/app/post/[postId]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import PostDetailBodyServer from "@/app/post/[postId]/PostDetailBodyServer";
55
import { getBlogById } from '@/apis/blogApi';
66
import { truncate, toAbsoluteUrl } from '@/utils/seo';
77
import PostStructuredData from '@/app/post/[postId]/PostStructuredData';
8+
import CommentList from '@/components/Comment/CommentList';
89

910
export async function generateMetadata(
1011
{ params }: { params: Promise<{ postId: string }> }
@@ -51,6 +52,9 @@ export default async function PostDetailPage({ params }: { params: Promise<{ pos
5152
{/* SSR 본문 */}
5253
<PostDetailBodyServer postId={postId} />
5354

55+
{/* CSR 댓글 */}
56+
<CommentList postId={postId} />
57+
5458
{/* JSON-LD (Article) */}
5559
<PostStructuredData postId={postId} />
5660
</article>

src/components/Blog/UserProfileHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function UserProfileHeader({ profile, isMyBlog }: Props) {
1313
<div className="flex items-center gap-4">
1414
{profile.profileImageUrl ? (
1515
<Image
16-
src={profile.profileImageUrl}
16+
src={profile.profileImageUrl || '/logo.jpeg'}
1717
alt={profile.nickname}
1818
width={50}
1919
height={50}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { createComment } from '@/apis/commentApi';
5+
import { Comment } from '@/utils/types';
6+
import { Button } from '@/components/Common/Button';
7+
8+
interface CommentFormProps {
9+
postId: string;
10+
parentId?: number | null;
11+
onSuccess: (newComment: Comment) => void;
12+
placeholder?: string;
13+
buttonText?: string;
14+
}
15+
16+
export default function CommentForm({
17+
postId,
18+
parentId = null,
19+
onSuccess,
20+
placeholder = '댓글을 입력하세요...',
21+
buttonText = '등록',
22+
}: CommentFormProps) {
23+
const [content, setContent] = useState('');
24+
25+
const handleSubmit = async (e: React.FormEvent) => {
26+
e.preventDefault();
27+
if (!content.trim()) return;
28+
29+
try {
30+
const response = await createComment({ postId, content, parentId });
31+
if (response.success && response.data) {
32+
onSuccess(response.data);
33+
setContent('');
34+
}
35+
} catch (error) {
36+
console.error('Error creating comment:', error);
37+
}
38+
};
39+
40+
return (
41+
<form onSubmit={handleSubmit} className="my-6">
42+
<textarea
43+
className="w-full p-3 border rounded-md focus:ring-2 focus:ring-gray-900 focus:border-transparent transition resize-none"
44+
rows={3}
45+
value={content}
46+
onChange={(e) => setContent(e.target.value)}
47+
placeholder={placeholder}
48+
/>
49+
<div className="flex justify-end">
50+
<Button
51+
type="submit"
52+
variant="solid"
53+
className="mt-2"
54+
>
55+
{buttonText}
56+
</Button>
57+
</div>
58+
</form>
59+
);
60+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
'use client';
2+
3+
import { useState } from 'react'
4+
import Image from 'next/image'
5+
import { Comment } from '@/utils/types'
6+
import { useProfileStore } from '@/store/ProfileStore'
7+
import { updateComment, deleteComment, toggleCommentLike, getChildComments } from '@/apis/commentApi'
8+
import CommentForm from './CommentForm'
9+
10+
// Helper function for relative time
11+
function timeAgo(date: string): string {
12+
const now = new Date();
13+
const seconds = Math.floor((now.getTime() - new Date(date).getTime()) / 1000);
14+
15+
let interval = seconds / 31536000;
16+
if (interval > 1) return Math.floor(interval) + "년 전";
17+
interval = seconds / 2592000;
18+
if (interval > 1) return Math.floor(interval) + "달 전";
19+
interval = seconds / 86400;
20+
if (interval > 1) return Math.floor(interval) + "일 전";
21+
interval = seconds / 3600;
22+
if (interval > 1) return Math.floor(interval) + "시간 전";
23+
interval = seconds / 60;
24+
if (interval > 1) return Math.floor(interval) + "분 전";
25+
return "방금 전";
26+
}
27+
28+
interface CommentItemProps {
29+
postId: string
30+
comment: Comment
31+
onCommentUpdate: (updatedComment: Comment) => void
32+
onCommentDelete: (commentId: number, replyCount: number) => void
33+
onReplyCreated: () => void
34+
isReply?: boolean // 대댓글 여부를 나타내는 prop
35+
}
36+
37+
export default function CommentItem({
38+
postId,
39+
comment,
40+
onCommentUpdate,
41+
onCommentDelete,
42+
onReplyCreated,
43+
isReply = false, // 기본값은 false (루트 댓글)
44+
}: CommentItemProps) {
45+
const { nickname, isLoading } = useProfileStore();
46+
const [isEditing, setIsEditing] = useState(false)
47+
const [editedContent, setEditedContent] = useState(comment.content)
48+
49+
const [likeCount, setLikeCount] = useState(comment.likeCount)
50+
// useState의 lazy initializer를 사용하여 클라이언트에서만 localStorage에 접근
51+
const [isLiked, setIsLiked] = useState(() => {
52+
if (typeof window === 'undefined') {
53+
return false;
54+
}
55+
return localStorage.getItem(`liked-comment-${comment.id}`) === 'true';
56+
});
57+
58+
const [showReplyForm, setShowReplyForm] = useState(false);
59+
const [childComments, setChildComments] = useState<Comment[]>([]);
60+
const [showChildren, setShowChildren] = useState(false);
61+
62+
const isAuthor = !isLoading && nickname === comment.writerNickname;
63+
64+
const handleUpdate = async () => {
65+
if (!editedContent.trim()) return
66+
try {
67+
const response = await updateComment({ commentId: comment.id, content: editedContent })
68+
if (response.success && response.data) {
69+
onCommentUpdate(response.data)
70+
setIsEditing(false)
71+
}
72+
} catch (error) {
73+
console.error('Error updating comment:', error)
74+
}
75+
}
76+
77+
const handleDelete = async () => {
78+
if (window.confirm('정말로 이 댓글을 삭제하시겠습니까?')) {
79+
try {
80+
await deleteComment(comment.id);
81+
onCommentDelete(comment.id, comment.replyCount || 0);
82+
} catch (error) {
83+
console.error('Error deleting comment:', error);
84+
}
85+
}
86+
};
87+
88+
const handleLike = async () => {
89+
if (!nickname) { // isLogin 대신 nickname 존재 여부로 확인
90+
alert('로그인이 필요합니다.');
91+
return;
92+
}
93+
94+
const newIsLiked = !isLiked;
95+
const newLikeCount = newIsLiked ? likeCount + 1 : likeCount - 1;
96+
97+
// 1. Optimistic UI 업데이트
98+
setIsLiked(newIsLiked);
99+
setLikeCount(newLikeCount);
100+
localStorage.setItem(`liked-comment-${comment.id}`, String(newIsLiked));
101+
102+
try {
103+
// 2. API 호출
104+
await toggleCommentLike(comment.id);
105+
// 3. 부모 상태 업데이트 (선택적)
106+
onCommentUpdate({ ...comment, likeCount: newLikeCount });
107+
} catch (error) {
108+
console.error('Error toggling like:', error);
109+
// 4. 에러 발생 시 롤백
110+
setIsLiked(!newIsLiked);
111+
setLikeCount(likeCount);
112+
localStorage.setItem(`liked-comment-${comment.id}`, String(!newIsLiked));
113+
}
114+
}
115+
116+
const toggleShowChildren = async () => {
117+
const newShowChildren = !showChildren;
118+
setShowChildren(newShowChildren);
119+
if (newShowChildren && childComments.length === 0 && comment.replyCount > 0) {
120+
const response = await getChildComments(comment.id);
121+
if (response.success) {
122+
setChildComments(response.data?.content || []);
123+
}
124+
}
125+
}
126+
127+
const handleReplySuccess = (newReply: Comment) => {
128+
setChildComments([...childComments, newReply]);
129+
setShowReplyForm(false);
130+
onReplyCreated(); // 부모에게 답글 생성 알림
131+
};
132+
133+
return (
134+
<div className="flex space-x-4">
135+
<Image
136+
src={comment.writerProfileImage || '/default-profile.png'}
137+
alt={comment.writerNickname}
138+
width={40}
139+
height={40}
140+
className="rounded-full"
141+
/>
142+
<div className="flex-1">
143+
<div className="flex items-center space-x-2">
144+
<span className="font-bold">{comment.writerNickname}</span>
145+
<span className="text-sm text-gray-500">
146+
{timeAgo(comment.createdAt)}
147+
</span>
148+
</div>
149+
{isEditing ? (
150+
<div>
151+
<textarea
152+
className="w-full p-2 border rounded-md mt-2"
153+
value={editedContent}
154+
onChange={(e) => setEditedContent(e.target.value)}
155+
/>
156+
<div className="flex space-x-2 mt-2">
157+
<button onClick={handleUpdate} className="px-3 py-1 bg-blue-500 text-white rounded-md">저장</button>
158+
<button onClick={() => setIsEditing(false)} className="px-3 py-1 bg-gray-300 rounded-md">취소</button>
159+
</div>
160+
</div>
161+
) : (
162+
<div>
163+
<p className="mt-1">{comment.content}</p>
164+
<div className="flex items-center space-x-4 mt-2 text-sm">
165+
<button onClick={handleLike} className={`flex items-center space-x-1 ${isLiked ? 'text-red-500' : 'text-gray-500'} hover:text-red-500`}>
166+
<span></span>
167+
<span>{likeCount}</span>
168+
</button>
169+
{!isReply && (
170+
<button onClick={() => setShowReplyForm(!showReplyForm)} className="text-gray-500 hover:text-gray-700">답글 달기</button>
171+
)}
172+
{isAuthor && (
173+
<div className="flex space-x-2">
174+
<button onClick={() => setIsEditing(true)} className="text-gray-500 hover:text-gray-700">수정</button>
175+
<button onClick={handleDelete} className="text-gray-500 hover:text-gray-700">삭제</button>
176+
</div>
177+
)}
178+
</div>
179+
180+
{/* 대댓글(답글) 관련 UI는 루트 댓글에만 표시 */}
181+
{!isReply && (
182+
<>
183+
{showReplyForm && (
184+
<div className="mt-4 pl-8">
185+
<CommentForm
186+
postId={postId}
187+
parentId={comment.id}
188+
onSuccess={handleReplySuccess}
189+
placeholder={`${comment.writerNickname}님에게 답글 남기기`}
190+
buttonText='답글 등록'
191+
/>
192+
</div>
193+
)}
194+
{comment.replyCount > 0 && (
195+
<button onClick={toggleShowChildren} className="text-sm text-blue-500 mt-2">
196+
{showChildren ? '답글 숨기기' : `답글 ${comment.replyCount}개 보기`}
197+
</button>
198+
)}
199+
{showChildren && (
200+
<div className="mt-4 pl-8 border-l-2">
201+
{childComments.map(child => (
202+
<CommentItem key={child.id} postId={postId} comment={child} onCommentUpdate={onCommentUpdate} onCommentDelete={onCommentDelete} onReplyCreated={onReplyCreated} isReply={true} />
203+
))}
204+
</div>
205+
)}
206+
</>
207+
)}
208+
</div>
209+
)}
210+
</div>
211+
</div>
212+
)
213+
}

0 commit comments

Comments
 (0)