@@ -12,16 +12,24 @@ import {
1212 getArticleComments ,
1313 mutateArticleComment ,
1414} from '@/api/postings' ;
15+ import DeleteIcon from '@/assets/TeamDashboard/deleteIcon.png' ;
16+ import ModifyIcon from '@/assets/TeamDashboard/modifyIcon.png' ;
1517import { colors } from '@/constants/colors' ;
1618import { isLoggedInAtom , profileAtom } from '@/store/auth' ;
1719import styled from '@emotion/styled' ;
18- import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query' ;
20+ import {
21+ useInfiniteQuery ,
22+ useMutation ,
23+ useQueryClient ,
24+ } from '@tanstack/react-query' ;
1925import { useAtomValue } from 'jotai' ;
2026
2127interface CommentSectionProps {
2228 articleId : number ;
2329}
2430
31+ const MAX_COMMENT_LENGTH = 300 ;
32+
2533export const CommentSection = ( { articleId } : CommentSectionProps ) => {
2634 const queryClient = useQueryClient ( ) ;
2735 const isLoggedIn = useAtomValue ( isLoggedInAtom ) ;
@@ -34,17 +42,27 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
3442 const [ editingText , setEditingText ] = useState ( '' ) ;
3543 const [ deleteTargetId , setDeleteTargetId ] = useState < number | null > ( null ) ;
3644
37- const { data : commentsResp } = useQuery ( {
45+ const {
46+ data : commentsData ,
47+ fetchNextPage,
48+ hasNextPage,
49+ isFetchingNextPage,
50+ } = useInfiniteQuery ( {
3851 queryKey : [ 'article-comments' , articleId ] ,
39- queryFn : ( ) => getArticleComments ( articleId ) ,
52+ queryFn : ( { pageParam = 0 } ) => getArticleComments ( articleId , pageParam ) ,
53+ initialPageParam : 0 ,
54+ getNextPageParam : ( lastPage ) => {
55+ const { number, totalPages } = lastPage . data . page ;
56+ return number + 1 < totalPages ? number + 1 : undefined ;
57+ } ,
4058 } ) ;
4159
42- const commentItems = commentsResp ?. data . comments ?? [ ] ;
60+ const commentItems =
61+ commentsData ?. pages . flatMap ( ( page ) => page . data . content ) ?? [ ] ;
62+ const totalElements = commentsData ?. pages [ 0 ] ?. data . page . totalElements ?? 0 ;
4363
4464 const sortedComments =
45- sortOrder === 'latest' ? [ ...commentItems ] . slice ( ) . reverse ( ) : commentItems ;
46-
47- console . log ( 'articleId' , articleId ) ;
65+ sortOrder === 'latest' ? [ ...commentItems ] . reverse ( ) : commentItems ;
4866
4967 const createCommentMutation = useMutation ( {
5068 mutationFn : ( description : string ) =>
@@ -116,7 +134,7 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
116134 < CommentSectionWrapper >
117135 < SectionHeader >
118136 < SText fontSize = "14px" fontWeight = { 600 } color = "#6E74FA" >
119- 댓글 { commentItems . length }
137+ 댓글 { totalElements }
120138 </ SText >
121139 < SortGroup >
122140 < SortButton
@@ -135,40 +153,6 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
135153 </ SortButton >
136154 </ SortGroup >
137155 </ SectionHeader >
138-
139- < EditorWrapper
140- isFocused = { isEditorFocused }
141- disabled = { ! isLoggedIn }
142- onClick = { ( ) => {
143- if ( ! isLoggedIn ) return ;
144- } }
145- >
146- < CommentTextArea
147- value = { newComment }
148- onChange = { ( e ) => setNewComment ( e . target . value ) }
149- onFocus = { ( ) => setIsEditorFocused ( true ) }
150- onBlur = { ( ) => setIsEditorFocused ( false ) }
151- readOnly = { ! isLoggedIn }
152- placeholder = {
153- isLoggedIn
154- ? '더 좋은 풀이 방법이나 응원의 메시지를 남겨주세요!'
155- : '댓글을 작성하려면 로그인해 주세요.'
156- }
157- />
158- < Spacer h = { 12 } />
159- < Flex align = "center" justify = "flex-end" >
160- < Button
161- backgroundColor = { isCreateDisabled ? '#E4E5F7' : colors . buttonPurple }
162- cursor = { isCreateDisabled ? 'not-allowed' : 'pointer' }
163- onClick = { isCreateDisabled ? undefined : handleCreateComment }
164- >
165- 댓글 달기
166- </ Button >
167- </ Flex >
168- </ EditorWrapper >
169-
170- < Spacer h = { 24 } />
171-
172156 < CommentList >
173157 { sortedComments . map ( ( comment ) => {
174158 const isOwner =
@@ -177,37 +161,43 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
177161
178162 return (
179163 < CommentCard key = { comment . commentId } >
180- < Flex justify = "space-between" align = "flex-start " >
164+ < Flex justify = "space-between" align = "center " >
181165 < Flex align = "center" gap = "10px" flex = { 1 } >
182166 < AvatarCircle >
183- { comment . memberProfileImageUrl ?? comment . memberName ?. [ 0 ] }
167+ { comment . memberImageUrl ? (
168+ < AvatarImage src = { comment . memberImageUrl } alt = "" />
169+ ) : (
170+ comment . memberName ?. [ 0 ]
171+ ) }
184172 </ AvatarCircle >
185173 < Flex flex = { 1 } align = "center" >
186174 < SText fontSize = "13px" fontWeight = { 600 } color = "#111" >
187175 { comment . memberName }
188176 </ SText >
189177 </ Flex >
190178 < SText fontSize = "11px" color = "#999" >
191- { formatDateTime ( comment . createdAt ) }
179+ { ! comment . isDeleted && formatDateTime ( comment . createdAt ) }
192180 </ SText >
193181 </ Flex >
194182
195183 { isOwner && ! isEditing && ! comment . isDeleted && (
196184 < CommentActions >
197- < TextButton
198- type = "button"
185+ < img
186+ src = { ModifyIcon }
187+ alt = "수정"
188+ width = { 12 }
189+ height = { 12 }
199190 onClick = { ( ) =>
200191 handleStartEdit ( comment . commentId , comment . description )
201192 }
202- >
203- 수정
204- </ TextButton >
205- < TextButton
206- type = "button"
193+ />
194+ < img
195+ src = { DeleteIcon }
196+ alt = "삭제"
197+ width = { 12 }
198+ height = { 12 }
207199 onClick = { ( ) => setDeleteTargetId ( comment . commentId ) }
208- >
209- 삭제
210- </ TextButton >
200+ />
211201 </ CommentActions >
212202 ) }
213203 </ Flex >
@@ -241,7 +231,9 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
241231 : ( ) => handleConfirmEdit ( comment . commentId )
242232 }
243233 >
244- 완료
234+ < SText fontSize = "12px" fontWeight = { 700 } color = "#fff" >
235+ 완료
236+ </ SText >
245237 </ Button >
246238 </ EditButtons >
247239 </ >
@@ -266,8 +258,64 @@ export const CommentSection = ({ articleId }: CommentSectionProps) => {
266258 </ SText >
267259 </ EmptyState >
268260 ) }
261+
262+ { hasNextPage && (
263+ < MoreButton
264+ type = "button"
265+ onClick = { ( ) => fetchNextPage ( ) }
266+ disabled = { isFetchingNextPage }
267+ >
268+ { isFetchingNextPage ? '불러오는 중...' : '댓글 더보기' }
269+ </ MoreButton >
270+ ) }
269271 </ CommentList >
270272
273+ < Spacer h = { 32 } />
274+
275+ < SText fontSize = "16px" fontWeight = { 600 } color = "#6E74FA" >
276+ 작성하기
277+ </ SText >
278+
279+ < EditorWrapper
280+ isFocused = { isEditorFocused }
281+ disabled = { ! isLoggedIn }
282+ onClick = { ( ) => {
283+ if ( ! isLoggedIn ) return ;
284+ } }
285+ >
286+ < CommentTextArea
287+ value = { newComment }
288+ onChange = { ( e ) => {
289+ if ( e . target . value . length <= MAX_COMMENT_LENGTH ) {
290+ setNewComment ( e . target . value . slice ( 0 , MAX_COMMENT_LENGTH ) ) ;
291+ }
292+ } }
293+ onFocus = { ( ) => setIsEditorFocused ( true ) }
294+ onBlur = { ( ) => setIsEditorFocused ( false ) }
295+ readOnly = { ! isLoggedIn }
296+ placeholder = {
297+ isLoggedIn
298+ ? '더 좋은 풀이 방법이나 응원의 메시지를 남겨주세요!'
299+ : '댓글을 작성하려면 로그인해 주세요.'
300+ }
301+ />
302+ < Spacer h = { 12 } />
303+ < Flex align = "center" justify = "flex-end" >
304+ < Button
305+ padding = "5px 11px"
306+ backgroundColor = { isCreateDisabled ? '#E4E5F7' : colors . buttonPurple }
307+ cursor = { isCreateDisabled ? 'not-allowed' : 'pointer' }
308+ onClick = { isCreateDisabled ? undefined : handleCreateComment }
309+ >
310+ < SText fontSize = "12px" fontWeight = { 700 } color = "#fff" >
311+ 댓글 달기
312+ </ SText >
313+ </ Button >
314+ </ Flex >
315+ </ EditorWrapper >
316+
317+ < Spacer h = { 24 } />
318+
271319 < Modal
272320 open = { deleteTargetId !== null }
273321 onClose = { ( ) => setDeleteTargetId ( null ) }
@@ -328,12 +376,11 @@ const SortButton = styled.button<{ active: boolean }>`
328376` ;
329377
330378const EditorWrapper = styled . div < { isFocused : boolean ; disabled : boolean } > `
331- margin-top: 4px ;
332- padding: 16px 18px ;
379+ margin-top: 10px ;
380+ padding: 20px 35px ;
333381 border-radius: 8px;
334382 border: none;
335- outline: ${ ( props ) =>
336- props . isFocused ? `1px solid ${ colors . buttonPurple } ` : 'none' } ;
383+ outline: none;
337384 background-color: #ffffff;
338385 opacity: ${ ( props ) => ( props . disabled ? 0.7 : 1 ) } ;
339386 box-sizing: border-box;
@@ -344,7 +391,7 @@ const CommentTextArea = styled.textarea`
344391 min-height: 80px;
345392 border: none;
346393 border-radius: 8px;
347- resize: vertical ;
394+ resize: none ;
348395 outline: none;
349396 background: transparent;
350397 font-size: 13px;
@@ -383,12 +430,21 @@ const AvatarCircle = styled.div`
383430 font-size: 8px;
384431 font-weight: 600;
385432 color: #666;
433+ overflow: hidden;
434+ ` ;
435+
436+ const AvatarImage = styled . img `
437+ width: 100%;
438+ height: 100%;
439+ object-fit: cover;
386440` ;
387441
388442const CommentActions = styled . div `
389443 display: flex;
390444 align-items: center;
391- gap: 8px;
445+ justify-content: center;
446+ gap: 4px;
447+ margin-left: 20px;
392448` ;
393449
394450const TextButton = styled . button `
@@ -423,6 +479,17 @@ const EditButtons = styled.div`
423479 gap: 10px;
424480` ;
425481
482+ const MoreButton = styled . button `
483+ border: none;
484+ border-radius: 8px;
485+ background-color: #f8f8ff;
486+ font-size: 14px;
487+ text-decoration: underline;
488+ font-weight: 500;
489+ color: #b2b5fb;
490+ cursor: pointer;
491+ ` ;
492+
426493const EmptyState = styled . div `
427494 padding: 24px 18px;
428495 border-radius: 16px;
@@ -480,11 +547,11 @@ const formatDateTime = (isoString: string) => {
480547
481548 if ( Number . isNaN ( date . getTime ( ) ) ) return isoString ;
482549
483- return date . toLocaleString ( 'ko-KR' , {
484- year : 'numeric' ,
485- month : '2-digit' ,
486- day : '2-digit' ,
487- hour : '2-digit' ,
488- minute : '2-digit' ,
489- } ) ;
550+ const y = date . getFullYear ( ) ;
551+ const m = String ( date . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
552+ const d = String ( date . getDate ( ) ) . padStart ( 2 , '0' ) ;
553+ const h = String ( date . getHours ( ) ) . padStart ( 2 , '0' ) ;
554+ const min = String ( date . getMinutes ( ) ) . padStart ( 2 , '0' ) ;
555+
556+ return ` ${ y } . ${ m } . ${ d } ${ h } : ${ min } ` ;
490557} ;
0 commit comments