diff --git a/package-lock.json b/package-lock.json index 136530ae..6c5d085b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,8 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.17.19", - "@tanstack/react-query-devtools": "^5.17.19", "axios": "^1.5.1", - "concept-be-design-system": "^0.4.1", + "concept-be-design-system": "^0.4.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", @@ -22,6 +21,7 @@ }, "devDependencies": { "@emotion/babel-plugin": "^11.11.0", + "@tanstack/react-query-devtools": "^5.17.19", "@types/node": "^20.10.6", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -1381,6 +1381,7 @@ "version": "5.17.21", "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.17.21.tgz", "integrity": "sha512-WWfcnNjTEqcuAS5GyKkVGkseuES6yd197MJWGImBu+MoCjWPqxSXKCCfm+utSXJauJUGm7xoMmhqCphiQdjf8w==", + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -1405,6 +1406,7 @@ "version": "5.17.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.17.21.tgz", "integrity": "sha512-Ri1AuWpN67eyPdMTlPxx1TMGNUaxTHrGv0ll0S20ZObz/Xms5wfANV3c6OX0HZTY0igudP1k5jpRLXNkd249mg==", + "dev": true, "dependencies": { "@tanstack/query-devtools": "5.17.21" }, @@ -2468,9 +2470,9 @@ "license": "MIT" }, "node_modules/concept-be-design-system": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/concept-be-design-system/-/concept-be-design-system-0.4.1.tgz", - "integrity": "sha512-194MNlf3ZNWVvLsqclpwYOiFV7q2kIBiwpE1rXxqA75VmnCXBZTPafDsnAR9SNFGx5cdZXkcpJOz1/ZZuIXVEQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/concept-be-design-system/-/concept-be-design-system-0.4.3.tgz", + "integrity": "sha512-33G/XWi2dQUCJwRqXfjilCOV1HDqXZCMMbgEuJ1W7+yS26ttiUd9+x0CnLunEeEf09TNmLnTUL9tKT5m7GJRwg==", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", diff --git a/package.json b/package.json index fb9783a5..48f3abcd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.17.19", "axios": "^1.5.1", - "concept-be-design-system": "^0.4.1", + "concept-be-design-system": "^0.4.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", diff --git a/src/components/ProfileInfo.tsx b/src/components/ProfileInfo.tsx index fa08a1e4..78f305de 100644 --- a/src/components/ProfileInfo.tsx +++ b/src/components/ProfileInfo.tsx @@ -1,5 +1,8 @@ import styled from '@emotion/styled'; import { Text, TextDivider, Box, Flex } from 'concept-be-design-system'; +import { Fragment } from 'react'; + +import { DEFAULT_IMAGE_URL } from '../constants'; interface Props { imageUrl: string; @@ -8,7 +11,6 @@ interface Props { } // TODO: 프로필 이미지 사진 오류 시 보여줄 기본 프로필 이미지 사진 URL -const DEFAULT_IMAGE_URL = ''; const ProfileInfo = ({ imageUrl, nickname, skillList }: Props) => { return ( @@ -22,12 +24,12 @@ const ProfileInfo = ({ imageUrl, nickname, skillList }: Props) => { {skillList.map((skill, idx) => ( - <> + {skill} {idx !== skillList.length - 1 && } - + ))} diff --git a/src/constants/index.ts b/src/constants/index.ts index 78fce2f9..53ea7dc4 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,6 @@ export const BASE_URL = 'http://conceptbe.kr:8080'; + +// 서버 작업으로 인한 임시 상수값 +export const ROOT_COMMENT_ID = '0'; + +export const DEFAULT_IMAGE_URL = ''; diff --git a/src/pages/FeedDetail/FeedDetail.page.tsx b/src/pages/FeedDetail/FeedDetail.page.tsx index 50ee6c9e..01779bf4 100644 --- a/src/pages/FeedDetail/FeedDetail.page.tsx +++ b/src/pages/FeedDetail/FeedDetail.page.tsx @@ -1,32 +1,18 @@ -import styled from '@emotion/styled'; -import { - Badge, - Divider, - Header, - Spacer, - Text, - TextDivider, - theme, - SVGTripleDots, - SVGFeedLike, - SVGFeedMessage, - SVGFeedPencil, - SVGFeedUnScrap, - SVGCancel, - Flex, - Box, -} from 'concept-be-design-system'; -import { useParams } from 'react-router-dom'; +import { Badge, Divider, Header, Spacer, Text, TextDivider, Flex, Box } from 'concept-be-design-system'; +import { useNavigate, useParams } from 'react-router-dom'; import Comments from './components/Comments'; -import useGetFeedDetail from './hooks/useGetFeedDetail'; -import useHandleClickOutside from './hooks/useHandleClickOutside'; +import ModifyDropdown from './components/ModifyDropdown'; +import ReactionBar from './components/ReactionBar'; +import { CommentFocusProvider } from './contexts/CommentFocusContext'; +import useFeedDetailQuery from './hooks/queries/useFeedDetailQuery'; import ProfileInfo from '../../components/ProfileInfo'; import Back from '../../layouts/Back'; import Logo from '../../layouts/Logo'; import { formatCommentDate } from '../Feed/utils/formatCommentDate'; const FeedDetailPage = () => { + const navigate = useNavigate(); const { id: feedId } = useParams() as { id: string }; const { imageUrl, @@ -39,47 +25,36 @@ const FeedDetailPage = () => { purposeList, cooperationWay, recruitmentPlace, - teamRecruitmentsList, + skillCategories, likesCount, commentsCount, bookmarksCount, hits, - commentParentResponses, - } = useGetFeedDetail(feedId); + owner, + ownerScrap, + ownerLike, + } = useFeedDetailQuery(feedId); - const { dropdownRef, isOpenModifyDropdown, toggleModifyDropdown } = useHandleClickOutside(); + const onModifyFeedDetail = () => { + // 게시글 수정 로직 필요 + }; + + const onDeleteFeedDetail = () => { + // 게시글 삭제 로직 필요 + navigate(-1); + }; return ( - <> +
- - - {isOpenModifyDropdown && ( - - - - 수정하기 - - - - - - - 삭제하기 - - - - - )} - +
+ - -
@@ -162,7 +137,7 @@ const FeedDetailPage = () => { - {teamRecruitmentsList.map((badge) => ( + {skillCategories.map((badge) => ( {badge} @@ -171,60 +146,23 @@ const FeedDetailPage = () => { - - - - - - 댓글 - - - {commentsCount > 999 ? '999+' : commentsCount} - - - - - - 좋아요 - - - {likesCount > 999 ? '999+' : likesCount} - - - - - - 스크랩 - - - {bookmarksCount > 999 ? '999+' : bookmarksCount} - - - + - - + + ); }; export default FeedDetailPage; - -const DropDownBox = styled.div` - position: absolute; - display: flex; - flex-direction: column; - justify-content: space-around; - background-color: ${theme.color.w1}; - width: 88px; - height: 70px; - border-radius: 6px; - padding: 10px; - top: 40px; - right: -6px; - box-shadow: 0px 6px 10px 0px rgba(0, 0, 0, 0.18); -`; diff --git a/src/pages/FeedDetail/components/Comment.tsx b/src/pages/FeedDetail/components/Comment.tsx index ecea8840..5cea8908 100644 --- a/src/pages/FeedDetail/components/Comment.tsx +++ b/src/pages/FeedDetail/components/Comment.tsx @@ -1,50 +1,138 @@ -import { Box, Flex, SVGFeedMessage, SVGFeedUnLike, Spacer, Text } from 'concept-be-design-system'; +import { Box, Flex, SVGFeedLike, SVGFeedMessage, SVGFeedUnLike, Spacer, Text } from 'concept-be-design-system'; +import { useState } from 'react'; +import CommentProfileInfo from './CommentProfileInfo'; +import EditComment from './EditComment'; +import ModifyDropdown from './ModifyDropdown'; import Recomment from './Recomment'; -import ProfileInfo from '../../../components/ProfileInfo'; +import WriteRecomment from './WriteRecomment'; +import { get999PlusCount } from '../../utils'; +import useDeleteCommentMutation from '../hooks/mutations/useDeleteComment'; +import useFocusEditComment from '../hooks/useFocusEditComment'; +import useFocusRecomment from '../hooks/useFocusRecomment'; +import useToggleLikeComment from '../hooks/useToggleLikeComment'; import { CommentParentResponse } from '../types'; interface Props { + feedId: string; + myImageUrl: string; + myNickname: string; + mySkillList: string[]; comment: CommentParentResponse; } const Comment = ({ - comment: { nickname, memberSkills, content, likesCount, commentCount, commentChildResponses }, + feedId, + myImageUrl, + myNickname, + comment: { + parentCommentId, + nickname, + profileImageUrl, + createdAt, + memberSkills, + content, + likesCount, + commentCount, + commentChildResponses, + owner, + deleted, + likes, + }, }: Props) => { + const [isEditComment, setIsEditComment] = useState(false); + const [isOpenRecommentTextarea, setIsOpenRecommentTextarea] = useState(false); + const { deleteComment } = useDeleteCommentMutation({ feedId }); + const toggleLikeComment = useToggleLikeComment({ feedId, commentId: parentCommentId, isLike: likes }); + + useFocusEditComment({ focusCondition: isEditComment }); + useFocusRecomment({ focusCondition: isOpenRecommentTextarea }); + + const onOpenRecommentTextarea = () => { + if (isOpenRecommentTextarea) return; + setIsOpenRecommentTextarea(true); + }; + + const onCloseRecommentTextarea = () => { + setIsOpenRecommentTextarea(false); + }; + + const onCloseEditCommentTextarea = () => { + setIsEditComment(false); + }; + + const onEditComment = () => { + setIsEditComment(true); + }; + + const onDeleteComment = () => { + //TODO: #54 머지 후 Confirm 컴포넌트로 대체 + if (confirm('댓글을 삭제하시겠습니까?')) deleteComment(parentCommentId); + }; + return ( <> - - - - - {content} - - - - - - - 댓글 - - - {commentCount > 999 ? '999+' : commentCount} - + {isEditComment ? ( + + ) : ( + + + + {!deleted && } - - - - - 좋아요 - - - {likesCount > 999 ? '999+' : likesCount} - + + + {deleted ? '삭제된 댓글입니다.' : content} + + + + + + + {commentCount > 0 ? get999PlusCount(commentCount) : '댓글작성'} + + + + + {likes ? : } + + {get999PlusCount(likesCount)} + + - - - {commentChildResponses.map((recomment, idx) => ( - + + )} + + {commentChildResponses.map((recomment) => ( + ))} + {isOpenRecommentTextarea && ( + + )} ); }; diff --git a/src/pages/FeedDetail/components/CommentProfileInfo.tsx b/src/pages/FeedDetail/components/CommentProfileInfo.tsx new file mode 100644 index 00000000..50a7d56a --- /dev/null +++ b/src/pages/FeedDetail/components/CommentProfileInfo.tsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; +import { Box, Flex, Text, TextDivider } from 'concept-be-design-system'; +import { Fragment } from 'react'; + +import { DEFAULT_IMAGE_URL } from '../../../constants'; +import { formatCommentDate } from '../../Feed/utils/formatCommentDate'; + +interface Props { + imageUrl: string; + nickname: string; + skillList: string[]; + createdAt: string; +} + +const CommentProfileInfo = ({ imageUrl, nickname, skillList, createdAt }: Props) => { + return ( + + + + + + + {nickname} + + + {skillList.map((skill) => ( + + + {skill} + + + + ))} + + {formatCommentDate(createdAt)} + + + + + ); +}; + +const Img = styled.img` + width: 100%; + height: 100%; +`; + +export default CommentProfileInfo; diff --git a/src/pages/FeedDetail/components/Comments.tsx b/src/pages/FeedDetail/components/Comments.tsx index 8ab5f2dd..7c11edae 100644 --- a/src/pages/FeedDetail/components/Comments.tsx +++ b/src/pages/FeedDetail/components/Comments.tsx @@ -1,48 +1,41 @@ -import styled from '@emotion/styled'; -import { Spacer, theme } from 'concept-be-design-system'; +import { Box, Divider } from 'concept-be-design-system'; +import { Fragment, useRef } from 'react'; import Comment from './Comment'; -import { CommentParentResponse } from '../types'; +import WriteComment from './WriteComment'; +import { useMemberInfoQuery } from '../../Profile/hooks/queries/useMemberInfoQuery'; +import useCommentsQuery from '../hooks/queries/useCommentsQuery'; +import useCommentInfiniteFetch from '../hooks/useCommentInfiniteFetch'; interface Props { - comments: CommentParentResponse[]; + feedId: string; } -const Comments = ({ comments }: Props) => ( - - - - - - {comments.map((comment, idx) => ( - - ))} - -); +const Comments = ({ feedId }: Props) => { + const { comments, fetchNextPage } = useCommentsQuery(feedId); + const { profileImageUrl: myImageUrl, nickname: myNickname, skills: mySkillList } = useMemberInfoQuery(); -export default Comments; - -const CommentWrapper = styled.div` - padding: 20px 22px 20px 22px; -`; + const intersectionRef = useRef(null); + useCommentInfiniteFetch(intersectionRef, fetchNextPage); -const InputBox = styled.div` - position: relative; -`; + return ( + + + {comments.map((comment, idx) => ( + + + {idx !== comments.length - 1 ? : <>} + + ))} +
+
+ ); +}; -const Input = styled.input` - border-radius: 6px; - width: 100%; - padding: 10px 20px; - box-sizing: border-box; - border: none; - background-color: ${theme.color.bg1}; - color: ${theme.color.t}; - font-style: normal; - font-family: SUIT; - font-weight: 400; - line-height: normal; - ::placeholder { - color: ${theme.color.ba}; - } -`; +export default Comments; diff --git a/src/pages/FeedDetail/components/EditComment.tsx b/src/pages/FeedDetail/components/EditComment.tsx new file mode 100644 index 00000000..70d7c298 --- /dev/null +++ b/src/pages/FeedDetail/components/EditComment.tsx @@ -0,0 +1,129 @@ +import styled from '@emotion/styled'; +import { Box, Button, Divider, Flex, theme, Text } from 'concept-be-design-system'; +import { ChangeEvent, useState } from 'react'; + +import WriteCommentProfileInfo from './WriteCommentProfileInfo'; +import { useFocusEditCommentTextareaContext } from '../contexts/CommentFocusContext'; +import usePatchComment from '../hooks/mutations/usePatchComment'; + +interface Props { + isRecomment?: boolean; + content: string; + commentId: string; + feedId: string; + myImageUrl: string; + myNickname: string; + onCloseEditCommentTextarea: () => void; +} + +const EditComment = ({ + isRecomment, + content, + feedId, + commentId, + myImageUrl, + myNickname, + onCloseEditCommentTextarea, +}: Props) => { + const [commentInput, setCommentInput] = useState(content); + const { editCommentTextareaRef, initEditCommentTextarea } = useFocusEditCommentTextareaContext(); + const { editComment } = usePatchComment({ + feedId, + commentId, + onSuccess: () => { + initEditCommentTextarea(); + onCloseEditCommentTextarea(); + }, + }); + + const onChangeTextarea = (e: ChangeEvent) => { + setCommentInput(e.target.value.substring(0, 500)); + }; + + const onCancelEditComment = () => { + setCommentInput(''); + onCloseEditCommentTextarea(); + initEditCommentTextarea(); + }; + + const onSubmitComment = () => { + editComment({ content: commentInput }); + }; + + return ( + + + + + +