diff --git a/package.json b/package.json index 099fc075..fb2b0cb8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "start:json-server": "json-server --watch db.json --port 5000" }, "dependencies": { + "@tanstack/react-query": "^5.70.0", + "@tanstack/react-query-devtools": "^5.70.0", "@types/styled-components": "^5.1.34", "axios": "^1.7.2", "dayjs": "^1.11.12", diff --git a/src/apis/post/index.ts b/src/apis/post/index.ts index 18614464..0b88452d 100644 --- a/src/apis/post/index.ts +++ b/src/apis/post/index.ts @@ -1,3 +1,5 @@ +import { useQuery } from '@tanstack/react-query'; + import { newRequest } from '@apis/core'; import type { EmptySuccessResponse } from '@apis/core/dto'; @@ -36,3 +38,11 @@ export const deletePostApi = (postId: number) => newRequest.delete newRequest.patch(`/post/${postId}/is-representative`); + +export const usePostDetail = (postId: number) => { + return useQuery({ + queryKey: ['postDetail', postId], + queryFn: () => getPostDetailApi(postId), + enabled: !!postId, // postId가 존재할 때만 요청 수행 + }); +}; diff --git a/src/main.tsx b/src/main.tsx index 223211e3..f5a1814c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createRoot } from 'react-dom/client'; import { RecoilRoot } from 'recoil'; import { ThemeProvider } from 'styled-components'; @@ -10,13 +12,18 @@ import { SocketProvider } from '@context/SocketProvider'; import App from './App'; +const queryClient = new QueryClient(); + createRoot(document.getElementById('root')!).render( - - - - - - + + + + + + + + + , ); diff --git a/src/pages/Post/PostBase/index.tsx b/src/pages/Post/PostBase/index.tsx index 990daa75..a68a9835 100644 --- a/src/pages/Post/PostBase/index.tsx +++ b/src/pages/Post/PostBase/index.tsx @@ -1,15 +1,14 @@ import { useEffect, useState, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import dayjs from 'dayjs'; -import { useRecoilState } from 'recoil'; -import 'dayjs/locale/ko'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import dayjs, { extend } from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; import theme from '@styles/theme'; -import { getPostDetailApi } from '@apis/post'; +import { usePostDetail } from '@apis/post'; import { togglePostLikeStatusApi } from '@apis/post-like'; -import { postIdAtom, userAtom, isPostRepresentativeAtom } from '@recoil/Post/PostAtom'; import Left from '@assets/arrow/left.svg'; import Message from '@assets/default/message.svg'; @@ -21,6 +20,7 @@ import BottomSheet from '@components/BottomSheet'; import ClothingInfoItem from '@components/ClothingInfoItem'; import { OODDFrame } from '@components/Frame/Frame'; import NavBar from '@components/NavBar'; +import Skeleton from '@components/Skeleton'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar'; @@ -34,28 +34,23 @@ import LikeCommentBottomSheetContent from './LikeCommentBottomSheetContent/index import { PostLayout, - PostContainer, PostInfoContainer, UserProfile, UserName, MenuBtn, PostContentContainer, - ContentSkeleton, Content, ShowMoreButton, - ImageSkeleton, IconRow, IconWrapper, Icon, ClothingInfoList, + UserNameWrapper, + PostWrapper, } from './styles'; const PostBase: React.FC = ({ onClickMenu }) => { - const [, setPostId] = useRecoilState(postIdAtom); - const [post, setPost] = useState(); - const [user, setUser] = useRecoilState(userAtom); - const [, setIsPostRepresentative] = useRecoilState(isPostRepresentativeAtom); - const [timeAgo, setTimeAgo] = useState(); + extend(relativeTime); const [isTextOverflowing, setIsTextOverflowing] = useState(false); const [showFullText, setShowFullText] = useState(false); const [isLikeCommentBottomSheetOpen, setIsLikeCommentBottomSheetOpen] = useState(false); @@ -64,6 +59,12 @@ const PostBase: React.FC = ({ onClickMenu }) => { const { postId } = useParams<{ postId: string }>(); const contentRef = useRef(null); + const { data, isLoading } = usePostDetail(Number(postId)); + const queryClient = useQueryClient(); + const post = data?.data; + const user = post?.user; + const timeAgo = dayjs(post?.createdAt).locale('ko').fromNow(); + const nav = useNavigate(); const handleLikeCommentOpen = (tab: 'likes' | 'comments') => { @@ -80,48 +81,26 @@ const PostBase: React.FC = ({ onClickMenu }) => { }; // 게시글 좋아요 누르기/취소하기 api - const togglePostLikeStatus = async () => { - if (!post || !postId) return; - - const prevPost = { ...post }; // 현재 상태 저장 - setPost({ - ...post, - isPostLike: !post.isPostLike, - postLikesCount: post.isPostLike ? post.postLikesCount - 1 : post.postLikesCount + 1, - }); //사용자가 좋아요를 누르면 먼저 클라이언트에서 post 상태를 변경(낙관적 업데이트) - - try { - const response = await togglePostLikeStatusApi(Number(postId)); - setPost({ - ...post, - isPostLike: response.data.isPostLike, - postLikesCount: response.data.postLikesCount, - }); // 서버로 요청 후 성공하면 그대로 유지 - } catch (error) { - console.error('Error toggling like status:', error); - setPost(prevPost); // 실패하면 원래 상태로 롤백 - } - }; - - useEffect(() => { - setPostId(Number(postId)); - - // 게시글 정보 가져오기 - const getPost = async () => { - try { - const response = await getPostDetailApi(Number(postId)); - const data = response.data; - setPost(data); - setUser(data.user); - setIsPostRepresentative(data.isRepresentative); - setTimeAgo(dayjs(data.createdAt).locale('ko').fromNow()); - } catch (error) { - console.error('Error fetching post data:', error); - } - }; - - getPost(); - }, [postId]); + const { mutate: togglePostLikeStatus } = useMutation({ + mutationFn: () => togglePostLikeStatusApi(Number(postId)), + onSuccess: () => { + queryClient.setQueryData(['postDetail', Number(postId)], (oldData: GetPostDetailResponse | undefined) => { + if (!oldData) return oldData; + + const newData = { + ...oldData, + data: { + ...oldData.data, + postLikesCount: oldData.data.postLikesCount + (oldData.data.isPostLike ? -1 : 1), // 기존 좋아요 개수를 토대로 증가/감소 + isPostLike: !oldData.data.isPostLike, // 좋아요 상태 변경 + }, + }; + console.log('newData', newData); + + return newData; + }); + }, + }); useEffect(() => { if (contentRef.current) { @@ -145,83 +124,96 @@ const PostBase: React.FC = ({ onClickMenu }) => { }, }; - return ( - - - - - + if (isLoading) { + return ( + + nav(-1)} /> + - - {post?.user && profileImg} + + - - {user.nickname} - - - {timeAgo} - - - more - + + + + + + + + + ); + } - {!post ? : image.url)} />} + return ( + + - {post?.postClothings && ( - - {post.postClothings.map((clothingObj, index) => ( - - ))} - + + + + {user && profileImg} + + + {user?.nickname ?? '알수없음'} + + + {timeAgo} + + + more + + + + {post && ( + + image.url)} /> + + )} + + {post?.postClothings ? ( + + {post.postClothings.map((clothingObj, index) => ( + + ))} + + ) : null} + + + + togglePostLikeStatus()}> + {post?.isPostLike ? : } + + handleLikeCommentOpen('likes')}>{post?.postLikesCount ?? 0} + + handleLikeCommentOpen('comments')}> + + message + + {post?.postCommentsCount ?? 0} + + + + + {post && ( +
+ + {post.content} + + {isTextOverflowing && ( + + {showFullText ? '간략히 보기' : '더 보기'} + + )} +
)} - - - - - {post?.isPostLike ? : } - - handleLikeCommentOpen('likes')}>{post?.postLikesCount ?? 0} - - handleLikeCommentOpen('comments')}> - - message - - {post?.postCommentsCount ?? 0} - - - - - {!post ? ( - - ) : ( -
- - {post.content} - - {isTextOverflowing && ( - - {showFullText ? '간략히 보기' : '더 보기'} - - )} -
- )} -
-
+
diff --git a/src/pages/Post/PostBase/styles.tsx b/src/pages/Post/PostBase/styles.tsx index 8ec77731..edce88cb 100644 --- a/src/pages/Post/PostBase/styles.tsx +++ b/src/pages/Post/PostBase/styles.tsx @@ -1,53 +1,18 @@ -import { styled, keyframes } from 'styled-components'; +import { styled } from 'styled-components'; import { StyledText } from '@components/Text/StyledText'; -// 그라데이션 애니메이션 정의 -const shimmer = keyframes` - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -`; - -// 공통된 로딩 스타일 -const LoadingSkeleton = styled.div` - background: linear-gradient( - 90deg, - ${({ theme }) => theme.colors.gray[200]} 25%, - ${({ theme }) => theme.colors.gray[300]} 50%, - ${({ theme }) => theme.colors.gray[200]} 75% - ); - background-size: 200% 100%; - animation: ${shimmer} 2s infinite; -`; - export const PostLayout = styled.div` display: flex; flex-direction: column; align-items: center; - width: 100%; - height: 100%; - height: calc(100vh - 2.75rem); - overflow-y: scroll; - - scrollbar-width: none; // Firefox - -ms-overflow-style: none; // IE 10+ - &::-webkit-scrollbar { - display: none; // Safari & Chrome - } -`; + gap: 16px; -export const PostContainer = styled.div` - display: flex; - flex-direction: column; width: 100%; - max-width: 450px; - height: 100%; + height: calc(100vh - 2.75rem); + padding: 0 20px; + padding-bottom: 6.5rem; overflow-y: scroll; - gap: 16px; scrollbar-width: none; // Firefox -ms-overflow-style: none; // IE 10+ @@ -64,7 +29,6 @@ export const PostInfoContainer = styled.div` display: flex; align-items: center; margin-top: 8px; - padding: 0 20px; gap: 8px; align-self: stretch; @@ -73,7 +37,7 @@ export const PostInfoContainer = styled.div` } `; -export const UserProfile = styled(LoadingSkeleton)` +export const UserProfile = styled.button` cursor: pointer; width: 32px; height: 32px; @@ -99,13 +63,6 @@ export const MenuBtn = styled.button` export const PostContentContainer = styled.div` width: 100%; - padding: 0 20px; -`; - -export const ContentSkeleton = styled(LoadingSkeleton)` - width: 100%; - height: 16px; - border-radius: 4px; `; export const Content = styled(StyledText)<{ $showFullText: boolean }>` @@ -125,16 +82,11 @@ export const ShowMoreButton = styled(StyledText)` color: ${({ theme }) => theme.colors.text.tertiary}; `; -export const ImageSkeleton = styled(LoadingSkeleton)` - width: 100%; - aspect-ratio: 4 / 5; -`; - export const IconRow = styled.div` display: flex; height: 20px; align-items: center; - padding: 0 20px; + margin-right: auto; gap: 16px; `; @@ -160,8 +112,8 @@ export const Icon = styled.div` `; export const ClothingInfoList = styled.div` - padding: 0 20px; display: flex; + margin-right: auto; flex-shrink: 0; overflow-x: auto; white-space: nowrap; @@ -201,3 +153,12 @@ export const InputLayout = styled.div` resize: none; } `; + +export const UserNameWrapper = styled.div` + margin-left: 10px; + margin-top: 10px; +`; + +export const PostWrapper = styled.div` + width: 100%; +`; diff --git a/src/pages/Post/index.tsx b/src/pages/Post/index.tsx index dfae0030..ad69bbfd 100644 --- a/src/pages/Post/index.tsx +++ b/src/pages/Post/index.tsx @@ -7,7 +7,6 @@ import { modifyPostRepresentativeStatusApi, deletePostApi } from '@apis/post'; import { isPostRepresentativeAtom, postIdAtom, userAtom } from '@recoil/Post/PostAtom'; import { getCurrentUserId } from '@utils/getCurrentUserId'; -import back from '@assets/arrow/left.svg'; import Delete from '@assets/default/delete.svg'; import Edit from '@assets/default/edit.svg'; import Pin from '@assets/default/pin.svg'; @@ -15,10 +14,7 @@ import Pin from '@assets/default/pin.svg'; import BottomSheet from '@components/BottomSheet'; import BottomSheetMenu from '@components/BottomSheet/BottomSheetMenu'; import OptionsBottomSheet from '@components/BottomSheet/OptionsBottomSheet'; -import { OODDFrame } from '@components/Frame/Frame'; import Modal from '@components/Modal'; -import Skeleton from '@components/Skeleton'; -import TopBar from '@components/TopBar/index'; import type { BottomSheetMenuProps } from '@components/BottomSheet/BottomSheetMenu/dto'; import type { BottomSheetProps } from '@components/BottomSheet/dto'; @@ -27,8 +23,6 @@ import type { ModalProps } from '@components/Modal/dto'; import PostBase from './PostBase/index'; -import { PicWrapper, NameWrapper, InfoWrapper, PostWrapper } from './styles'; - const Post: React.FC = () => { const user = useRecoilValue(userAtom); const postId = useRecoilValue(postIdAtom); @@ -43,8 +37,6 @@ const Post: React.FC = () => { const [modalContent, setModalContent] = useState(''); const [postPinStatus, setPostPinStatus] = useState<'지정' | '해제'>('지정'); - const [isLoading, setIsLoading] = useState(true); - const navigate = useNavigate(); const currentUserId = getCurrentUserId(); @@ -95,10 +87,6 @@ const Post: React.FC = () => { }; useEffect(() => { - setTimeout(() => { - setIsLoading(false); - }, 1000); - // 현재 게시글이 내 게시글인지 확인 if (user?.id && postId) { setIsMyPost(currentUserId === user.id); @@ -175,25 +163,6 @@ const Post: React.FC = () => { content: modalContent, }; - if (isLoading) { - return ( - - navigate(-1)} /> - - - - - - - - - - - - - ); - } - return ( <> diff --git a/src/pages/Post/styles.tsx b/src/pages/Post/styles.tsx index 0c610fe8..40a02ee2 100644 --- a/src/pages/Post/styles.tsx +++ b/src/pages/Post/styles.tsx @@ -9,13 +9,3 @@ export const InfoWrapper = styled.div` export const PicWrapper = styled.div` margin-left: 47px; `; - -export const NameWrapper = styled.div` - margin-left: 10px; - margin-top: 10px; -`; - -export const PostWrapper = styled.div` - margin-top: 10px; - padding-inline: 30px; -`; diff --git a/yarn.lock b/yarn.lock index e7af7353..d627b042 100644 --- a/yarn.lock +++ b/yarn.lock @@ -995,6 +995,30 @@ resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== +"@tanstack/query-core@5.70.0": + version "5.70.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.70.0.tgz#fb092c657440f38e5f5af570679ee1d7273ab5f4" + integrity sha512-ZkkjQAZjI6nS5OyAmaSQafQXK180Xvp0lZYk4BzrnskkTV8On3zSJUxOIXnh0h/8EgqRkCA9i879DiJovA1kGw== + +"@tanstack/query-devtools@5.67.2": + version "5.67.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.67.2.tgz#890ae9913bd21d3969c7fd85c68b1bd1500cfc57" + integrity sha512-O4QXFFd7xqp6EX7sdvc9tsVO8nm4lpWBqwpgjpVLW5g7IeOY6VnS/xvs/YzbRhBVkKTMaJMOUGU7NhSX+YGoNg== + +"@tanstack/react-query-devtools@^5.70.0": + version "5.70.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.70.0.tgz#a9b2067ed2b6ec9668552a8d29804f574d48183f" + integrity sha512-jFtpA3mnUoVn/ic1EVxmA6qG7z8S19nchsHciMCWOvC1Z2Mt8f0wbl1p8hNvrBpzWywZa+Hl0AxMVs48psUvhg== + dependencies: + "@tanstack/query-devtools" "5.67.2" + +"@tanstack/react-query@^5.70.0": + version "5.70.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.70.0.tgz#5130b38b7f2354b97de6b256c008b24a50885743" + integrity sha512-z0tx1zz2CQ6nTm+fCaOp93FqsFjNgXtOy+4mC5ifQ4B+rJiMD0AGfJrYSGh/OuefhrzTYDAbkGUAGw6JzkWy8g== + dependencies: + "@tanstack/query-core" "5.70.0" + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"