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