Skip to content

Commit a4ef1ac

Browse files
committed
feat: 댓글 기능 ui 수정 및 더보기 기능 구현
1 parent b159236 commit a4ef1ac

File tree

2 files changed

+149
-73
lines changed

2 files changed

+149
-73
lines changed

src/api/postings.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,18 +139,27 @@ interface CommentItem {
139139
description: string;
140140
memberId: number | null;
141141
memberName: string | null;
142-
memberProfileImageUrl: string | null;
142+
memberImageUrl: string | null;
143143
createdAt: string;
144144
isDeleted: boolean;
145145
}
146146

147147
interface CommentsResponse {
148-
comments: CommentItem[];
148+
content: CommentItem[];
149+
page: {
150+
size: number;
151+
number: number;
152+
totalElements: number;
153+
totalPages: number;
154+
};
149155
}
150156

151-
export const getArticleComments = async (articleId: number) => {
157+
export const getArticleComments = async (
158+
articleId: number,
159+
page = 0
160+
) => {
152161
const res = await apiInstance.get<ServerResponse<CommentsResponse>>(
153-
`/v1/articles/${articleId}/comments`
162+
`/v1/articles/${articleId}/comments?page=${page}`
154163
);
155164
return res.data;
156165
};

src/components/features/Comment/CommentSection.tsx

Lines changed: 136 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
1517
import { colors } from '@/constants/colors';
1618
import { isLoggedInAtom, profileAtom } from '@/store/auth';
1719
import 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';
1925
import { useAtomValue } from 'jotai';
2026

2127
interface CommentSectionProps {
2228
articleId: number;
2329
}
2430

31+
const MAX_COMMENT_LENGTH = 300;
32+
2533
export 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

330378
const 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

388442
const 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

394450
const 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+
426493
const 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

Comments
 (0)