Skip to content

Commit 212482b

Browse files
authored
Merge pull request #86 from six-goguma/Feat/issue-#45
issue#45 게시글 상세페이지 기능 구현
2 parents 579e21e + 68bc401 commit 212482b

File tree

19 files changed

+491
-147
lines changed

19 files changed

+491
-147
lines changed
Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,47 @@
11
import { fetchInstance } from '@shared/service';
2-
import { RequestPostParams, RequestPosts } from '@shared/types';
2+
import { RequestPosts } from '@shared/types';
33

44
import { RequestPostComments, ResponsePostComments } from './post-comments.type';
55

6-
export const PostsCommentsPath = ({ postId }: RequestPostParams, params?: RequestPosts) => {
7-
if (params) {
8-
return `/posts/${postId}/comments?page=${params.page}&size=${params.size}&sort=${params.sort.key},${params.sort.order}`;
9-
}
10-
return `/posts/${postId}/comments`;
11-
};
12-
136
export const getPostsComments = async ({
147
postId,
158
page,
169
size,
1710
sort,
18-
}: RequestPostParams & RequestPosts): Promise<ResponsePostComments> => {
11+
}: { postId: number } & RequestPosts): Promise<ResponsePostComments> => {
1912
const response = await fetchInstance.get<ResponsePostComments>(
20-
PostsCommentsPath({ postId }, { page, size, sort }),
13+
`/posts/${postId}/comments?page=${page}&size=${size}&sort=${sort.key},${sort.order}`,
2114
);
2215
return response.data;
2316
};
2417

2518
export const editPostsComments = async ({
26-
postId,
27-
content,
28-
}: RequestPostParams & RequestPostComments) => {
29-
const response = await fetchInstance.put(PostsCommentsPath({ postId }), { body: content });
19+
commentId,
20+
contents,
21+
}: { commentId: number } & RequestPostComments) => {
22+
const response = await fetchInstance.put(`/posts/comments/${commentId}`, {
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
26+
body: JSON.stringify({ contents }),
27+
});
3028
return response.data;
3129
};
3230

3331
export const savePostsComments = async ({
3432
postId,
35-
content,
36-
}: RequestPostParams & RequestPostComments) => {
37-
const response = await fetchInstance.post(PostsCommentsPath({ postId }), { body: content });
33+
contents,
34+
}: { postId: number } & RequestPostComments) => {
35+
const response = await fetchInstance.post(`/posts/${postId}/comments`, {
36+
headers: {
37+
'Content-Type': 'application/json',
38+
},
39+
body: JSON.stringify({ contents }),
40+
});
3841
return response.data;
3942
};
4043

41-
export const deletePostsComments = async ({ postId }: RequestPostParams) => {
42-
const response = await fetchInstance.delete(PostsCommentsPath({ postId }));
44+
export const deletePostsComments = async ({ commentId }: { commentId: number }) => {
45+
const response = await fetchInstance.delete(`/posts/comments/${commentId}`);
4346
return response.data;
4447
};

src/pages/post-detail/apis/post-comments.type.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { PostResponse } from '@shared/types';
22

33
export type PostCommentsContent = {
44
id: number;
5-
content: string;
5+
contents: string;
66
createdAt: string;
77
updatedAt: number;
88
author: string;
@@ -11,5 +11,5 @@ export type PostCommentsContent = {
1111
export type ResponsePostComments = PostResponse<PostCommentsContent>;
1212

1313
export type RequestPostComments = {
14-
content: string;
14+
contents: string;
1515
};

src/pages/post-detail/apis/post-detail.api.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ import { RequestPostParams } from '@shared/types';
33

44
import { ResponsePostDetail, ResponsePostTags } from './';
55

6-
export const PostsDetailPath = ({ postId }: RequestPostParams) => `/posts/${postId}`;
6+
export const getPostDetail = async ({ postId }: RequestPostParams) => {
7+
const response = await fetchInstance.get<ResponsePostDetail>(`/posts/${postId}`);
8+
return response.data;
9+
};
710

8-
export const getPostDetail = async ({ postId }: RequestPostParams): Promise<ResponsePostDetail> => {
9-
const response = await fetchInstance.get<ResponsePostDetail>(PostsDetailPath({ postId }));
11+
export const getPostTags = async ({ postId }: RequestPostParams) => {
12+
const response = await fetchInstance.get<ResponsePostTags>(`/posts/${postId}/tags`);
1013
return response.data;
1114
};
1215

13-
export const PostsTagsPath = ({ postId }: RequestPostParams) => `/posts/${postId}/tags`;
16+
export const getLikeStatus = async ({ postId }: RequestPostParams) => {
17+
const response = await fetchInstance.get<{ liked: boolean }>(`/posts/${postId}/likes/status`);
18+
return response.data.liked;
19+
};
1420

15-
export const getPostTags = async ({ postId }: RequestPostParams): Promise<ResponsePostTags> => {
16-
const response = await fetchInstance.get<ResponsePostTags>(PostsTagsPath({ postId }));
17-
return response.data;
21+
export const putLikeStatus = async ({ postId }: RequestPostParams) => {
22+
const response = await fetchInstance.put<{ liked: boolean }>(`/posts/${postId}/likes/status`);
23+
return response.data.liked;
1824
};

src/pages/post-detail/components/post-comments/CommentList.tsx

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,132 @@
1-
import { Avatar, Flex, HStack, Text, VStack } from '@chakra-ui/react';
1+
import { useState } from 'react';
2+
3+
import { Avatar, Flex, HStack, Text, VStack, Textarea, Button } from '@chakra-ui/react';
4+
5+
import { useCustomToast } from '@shared/hooks';
6+
7+
import { deletePostsComments, editPostsComments } from '../../apis';
8+
import { PostCommentsContent } from '../../apis';
9+
import { useMutation } from '@tanstack/react-query';
210

311
type CommentListProps = {
12+
comment: PostCommentsContent;
413
isLast?: boolean;
14+
onRefetch: () => void;
15+
currentUser: string | null;
516
};
617

7-
export const CommentList = ({ isLast }: CommentListProps) => {
18+
export const CommentList = ({ comment, isLast, onRefetch, currentUser }: CommentListProps) => {
19+
const [isEditing, setIsEditing] = useState(false);
20+
const [editContent, setEditContent] = useState(comment.contents);
21+
const customToast = useCustomToast();
22+
const isAuthor = currentUser === comment.author;
23+
24+
const { mutate: deleteComment, isPending: isDeleting } = useMutation({
25+
mutationFn: () => deletePostsComments({ commentId: comment.id }),
26+
onSuccess: () => {
27+
customToast({
28+
toastStatus: 'success',
29+
toastTitle: '댓글 삭제',
30+
toastDescription: '댓글이 삭제되었습니다.',
31+
});
32+
onRefetch();
33+
},
34+
onError: () => {
35+
customToast({
36+
toastStatus: 'error',
37+
toastTitle: '댓글 삭제 오류',
38+
toastDescription: '댓글 삭제 중 오류가 발생했습니다.',
39+
});
40+
},
41+
});
42+
43+
const { mutate: editComment, isPending: isEditingPending } = useMutation({
44+
mutationFn: () => editPostsComments({ commentId: comment.id, contents: editContent }),
45+
onSuccess: () => {
46+
customToast({
47+
toastStatus: 'success',
48+
toastTitle: '댓글 수정',
49+
toastDescription: '댓글이 수정되었습니다.',
50+
});
51+
setIsEditing(false);
52+
onRefetch();
53+
},
54+
onError: () => {
55+
customToast({
56+
toastStatus: 'error',
57+
toastTitle: '댓글 수정 오류',
58+
toastDescription: '댓글 수정 중 오류가 발생했습니다.',
59+
});
60+
},
61+
});
62+
863
return (
964
<Flex w='full' py={3} borderBottom={isLast ? 'none' : '1px solid'} borderColor='customGray.300'>
10-
<VStack w='full'>
65+
<VStack w='full' align='start' textAlign='start'>
1166
<HStack w='full' justify='space-between'>
1267
<HStack spacing={4}>
1368
<Avatar size='md' />
1469
<Flex w='full' flexDir='column' align='flex-start'>
15-
<Text as='b'>Kiyoung</Text>
70+
<Text as='b'>{comment.author}</Text>
1671
<Text fontSize='sm' color='customGray.400'>
17-
2025년 1월 14일
72+
{new Date(comment.createdAt).toLocaleDateString()}
1873
</Text>
1974
</Flex>
2075
</HStack>
21-
<Text as='button' fontWeight='700' color='customGray.400'>
22-
삭제
23-
</Text>
76+
{isAuthor && (
77+
<HStack spacing={2}>
78+
<Text
79+
as='button'
80+
fontWeight='700'
81+
color='blue.500'
82+
onClick={() => setIsEditing(true)}
83+
style={{
84+
cursor: isDeleting ? 'not-allowed' : 'pointer',
85+
opacity: isDeleting ? 0.5 : 1,
86+
}}
87+
>
88+
수정
89+
</Text>
90+
<Text
91+
as='button'
92+
fontWeight='700'
93+
color='red.500'
94+
onClick={() => deleteComment()}
95+
style={{
96+
cursor: isDeleting ? 'not-allowed' : 'pointer',
97+
opacity: isDeleting ? 0.5 : 1,
98+
}}
99+
>
100+
{isDeleting ? '삭제 중...' : '삭제'}
101+
</Text>
102+
</HStack>
103+
)}
24104
</HStack>
25-
<Flex w='full' my={3} textAlign='start'>
26-
<Text>
27-
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum
28-
has been the industry&apos;s standard dummy text ever since the 1500s...
29-
</Text>
30-
</Flex>
105+
106+
{isEditing ? (
107+
<>
108+
<Textarea
109+
resize='none'
110+
value={editContent}
111+
onChange={(e) => setEditContent(e.target.value)}
112+
isDisabled={isEditingPending}
113+
/>
114+
<Flex w='full' justify='flex-end'>
115+
<Button
116+
onClick={() => editComment()}
117+
isDisabled={isEditingPending}
118+
style={{
119+
cursor: isEditingPending ? 'not-allowed' : 'pointer',
120+
opacity: isEditingPending ? 0.5 : 1,
121+
}}
122+
>
123+
{isEditingPending ? '수정 중...' : '수정 완료'}
124+
</Button>
125+
</Flex>
126+
</>
127+
) : (
128+
<Text>{comment.contents}</Text>
129+
)}
31130
</VStack>
32131
</Flex>
33132
);
Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,98 @@
1-
import { Button, Flex, Text, Textarea, VStack } from '@chakra-ui/react';
1+
import { useState } from 'react';
22

3+
import { Button, Flex, Text, Textarea, VStack, HStack } from '@chakra-ui/react';
4+
5+
import { useCustomToast } from '@shared/hooks';
6+
import { authStorage } from '@shared/utils';
7+
8+
import { ResponsePostComments } from '../../apis';
9+
import { useSubmitComment } from '../../hooks';
310
import { CommentList } from './CommentList';
411

5-
export const PostComments = () => {
6-
const comments = [1, 2];
12+
type PostCommentsProps = {
13+
postId: number;
14+
comments: ResponsePostComments;
15+
refetch: () => void;
16+
};
17+
18+
export const PostComments = ({ postId, comments, refetch }: PostCommentsProps) => {
19+
const [commentContent, setCommentContent] = useState('');
20+
const [page, setPage] = useState(0);
21+
const customToast = useCustomToast();
22+
23+
const nickName = authStorage.nickName.get();
24+
const isLogin = !!nickName;
25+
26+
const { mutate: submitComment, isPending } = useSubmitComment(postId);
27+
28+
const onSubmit = async () => {
29+
if (!postId || !commentContent.trim()) return;
30+
31+
submitComment(commentContent, {
32+
onSuccess: () => {
33+
setCommentContent('');
34+
refetch();
35+
},
36+
onError: () => {
37+
customToast({
38+
toastStatus: 'error',
39+
toastTitle: '게시글 상세 페이지',
40+
toastDescription: '댓글 작성 과정에서 오류가 발생했습니다.',
41+
});
42+
},
43+
});
44+
};
45+
746
return (
847
<VStack w='full' mb={20}>
948
<Flex w='full'>
10-
<Text as='b'>{comments.length}개의 댓글</Text>
49+
<Text as='b'>{comments?.totalElements || 0}개의 댓글</Text>
1150
</Flex>
51+
1252
<Flex w='full'>
1353
<Textarea
1454
bg='white'
1555
w='full'
1656
h='100px'
1757
size='sm'
18-
placeholder='댓글을 입력해주세요.'
58+
placeholder={isLogin ? '댓글을 입력해주세요.' : '로그인하여 댓글을 입력해보세요'}
1959
resize='none'
60+
value={commentContent}
61+
onChange={(e) => setCommentContent(e.target.value)}
62+
isDisabled={!isLogin || isPending}
2063
/>
2164
</Flex>
2265
<Flex w='full' justify='right'>
23-
<Button borderRadius='3px'>댓글 작성</Button>
66+
<Button borderRadius='3px' onClick={onSubmit} isDisabled={!isLogin || isPending}>
67+
{isPending ? '등록 중...' : '댓글 작성'}
68+
</Button>
2469
</Flex>
25-
<Flex w='full' flexDir='column' mt={20} gap={5}>
26-
{comments.map((_, index) => (
27-
<CommentList key={index} isLast={index === comments.length - 1} />
28-
))}
70+
71+
<Flex w='full' flexDir='column' mt={5} gap={5}>
72+
{comments?.content?.length ? (
73+
comments.content.map((comment, index) => (
74+
<CommentList
75+
key={comment.id}
76+
comment={comment}
77+
isLast={index === comments.content.length - 1}
78+
onRefetch={refetch}
79+
currentUser={nickName}
80+
/>
81+
))
82+
) : (
83+
<Text color='gray.500'>댓글이 없습니다.</Text>
84+
)}
2985
</Flex>
86+
87+
<HStack spacing={3} mt={5}>
88+
<Button onClick={() => setPage((prev) => Math.max(prev - 1, 0))} isDisabled={page === 0}>
89+
이전
90+
</Button>
91+
<Text>{page + 1}</Text>
92+
<Button onClick={() => setPage((prev) => prev + 1)} isDisabled={comments?.last}>
93+
다음
94+
</Button>
95+
</HStack>
3096
</VStack>
3197
);
3298
};

0 commit comments

Comments
 (0)