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..d01a6b95 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'; @@ -17,8 +19,15 @@ export const createPostApi = (data: CreatePostRequest) => newRequest.post - newRequest.get(`/post`, { params: { page, take } }); +export const getPostListApi = async ({ pageParam = 1 }) => { + const response = await newRequest.get('/post', { + params: { page: pageParam, take: 10 }, + }); + return { + posts: response.data.post, + nextPage: response.data.post.length > 0 ? pageParam + 1 : undefined, // 다음 페이지 여부 확인 + }; +}; // 유저 게시글 리스트 export const getUserPostListApi = (page: number = 1, take: number = 10, userId: number) => newRequest.get(`/post`, { params: { page, take, userId } }); @@ -36,3 +45,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/components/Skeleton/index.tsx b/src/components/Skeleton/index.tsx new file mode 100644 index 00000000..d79b5d1b --- /dev/null +++ b/src/components/Skeleton/index.tsx @@ -0,0 +1,37 @@ +import { SkeletonContainer } from './styles'; + +interface SkeletonProps { + width?: string | number; + height?: string | number; + borderRadius?: string | number; + className?: string; + style?: React.CSSProperties; +} + +const Skeleton: React.FC = ({ + width = '100%', + height = '16px', + borderRadius = '5px', + className = '', +}) => { + // width와 height가 숫자인 경우 rem 단위를 추가 + const getSize = (size: string | number) => { + if (typeof size === 'number') { + return `${size}rem`; + } + return size; + }; + + return ( + + ); +}; + +export default Skeleton; diff --git a/src/components/Skeleton/styles.tsx b/src/components/Skeleton/styles.tsx new file mode 100644 index 00000000..14638099 --- /dev/null +++ b/src/components/Skeleton/styles.tsx @@ -0,0 +1,19 @@ +import { styled } from 'styled-components'; + +export const SkeletonContainer = styled.div` + background-color: #e0e0e0; + position: relative; + overflow: hidden; + background: linear-gradient(90deg, #e0e0e0 0%, #f0f0f0 50%, #e0e0e0 100%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +`; 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/Account/AccountCancel/index.tsx b/src/pages/Account/AccountCancel/index.tsx index 17e4dd2c..61adba0f 100644 --- a/src/pages/Account/AccountCancel/index.tsx +++ b/src/pages/Account/AccountCancel/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import theme from '@styles/theme'; @@ -11,6 +11,7 @@ import back from '@assets/arrow/left.svg'; import BottomButton from '@components/BottomButton/index'; import { OODDFrame } from '@components/Frame/Frame'; import Modal from '@components/Modal/index'; +import Skeleton from '@components/Skeleton'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar/index'; @@ -30,8 +31,15 @@ const AccountCancel: React.FC = () => { const [isChecked, setIsChecked] = useState(false); const [modalContent, setModalContent] = useState(null); const [isModalVisible, setIsModalVisible] = useState(false); + const [isLoading, setIsLoading] = useState(true); // Loading state const navigate = useNavigate(); + useEffect(() => { + setTimeout(() => { + setIsLoading(false); + }, 1000); + }, []); + const handleCheckboxChange = () => { setIsChecked(!isChecked); }; @@ -79,6 +87,23 @@ const AccountCancel: React.FC = () => { } }; + if (isLoading) { + return ( + + + navigate(-1)} /> + + + OOTD 탈퇴 전 확인하세요! + + + + + + + ); + } + return ( diff --git a/src/pages/Account/AccountSetting/index.tsx b/src/pages/Account/AccountSetting/index.tsx index 65ba1624..efadc1f0 100644 --- a/src/pages/Account/AccountSetting/index.tsx +++ b/src/pages/Account/AccountSetting/index.tsx @@ -12,8 +12,8 @@ import leave from '@assets/default/leave.svg'; import Profile_s from '@assets/default/my-page.svg'; import { OODDFrame } from '@components/Frame/Frame'; -import Loading from '@components/Loading/index'; import Modal from '@components/Modal'; +import Skeleton from '@components/Skeleton'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar/index'; @@ -44,8 +44,7 @@ const AccountSetting: React.FC = () => { setIsLoading(false); } }; - - getUserInfo(); + setTimeout(getUserInfo, 1000); }, []); const handleConfirmLogout = () => { @@ -68,7 +67,33 @@ const AccountSetting: React.FC = () => { }; if (isLoading) { - return ; + return ( + + + navigate(-1)} /> + + + + {' '} + + + + + + + + + + + + + + + + + + + ); } return ( diff --git a/src/pages/Account/AccountSetting/styles.tsx b/src/pages/Account/AccountSetting/styles.tsx index ef19ecf5..f4309b02 100644 --- a/src/pages/Account/AccountSetting/styles.tsx +++ b/src/pages/Account/AccountSetting/styles.tsx @@ -13,8 +13,6 @@ export const ProfilePicWrapper = styled.div` display: flex; flex-direction: column; align-items: center; - margin-bottom: 1.25rem; - margin-top: 1.5rem; `; export const ProfilePic = styled.div` @@ -24,7 +22,6 @@ export const ProfilePic = styled.div` border-radius: 50%; overflow: hidden; margin-top: 2.125rem; - margin-bottom: 1.375rem; img { width: 100%; @@ -42,7 +39,7 @@ export const Row = styled.div` justify-content: center; align-items: center; width: 100%; - margin-bottom: 0.625rem; + margin-top: 10px; ${Label} { width: auto; @@ -67,7 +64,8 @@ export const List = styled.ul` export const ListItem = styled.li` display: flex; align-items: center; - padding: 0.9375rem 1.25rem; + padding: 15px 10px; + border-bottom: 0px solid ${({ theme }) => theme.colors.background.divider}; cursor: pointer; diff --git a/src/pages/Home/OOTD/index.tsx b/src/pages/Home/OOTD/index.tsx index edf31e5b..c1b8da5e 100644 --- a/src/pages/Home/OOTD/index.tsx +++ b/src/pages/Home/OOTD/index.tsx @@ -1,72 +1,51 @@ -import { useState, useEffect, useRef } from 'react'; +import { useRef, useEffect } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import { getPostListApi } from '@apis/post'; -import type { PostSummary } from '@apis/post/dto'; +import Loading from '@components/Loading'; import Feed from './Feed/index'; import { OOTDContainer, FeedContainer } from './styles'; const OOTD: React.FC = () => { - const [feeds, setFeeds] = useState([]); - - const isFetchingRef = useRef(false); - const isReachedEndRef = useRef(false); - const feedPageRef = useRef(1); - - // IntersectionObserver 인스턴스를 참조하는 변수 - const observerRef = useRef(null); - // observer 콜백 함수를 트리거하는 요소를 참조하는 변수 + // 무한 스크롤을 감지할 요소 const loadMoreRef = useRef(null); - // 세션 스토리지에서 이전 스크롤 위치를 가져와 초기화 - const savedScrollPosition = sessionStorage.getItem('scrollPosition'); - const scrollPositionRef = useRef(Number(savedScrollPosition) || 0); - - // 전체 게시글(피드) 조회 API - const getPostList = async () => { - // 모든 데이터를 불러왔거나 요청 중이라면 함수 실행 중단 - if (isReachedEndRef.current || isFetchingRef.current) return; - - isFetchingRef.current = true; - - try { - const response = await getPostListApi(feedPageRef.current, 20); + // Intersection Observer 인스턴스 저장 (컴포넌트 언마운트 시 해제 위함) + const observerRef = useRef(null); - if (response.isSuccess) { - if (response.data.post.length === 0) { - isReachedEndRef.current = true; - } else { - setFeeds((prevFeeds) => [...prevFeeds, ...response.data.post]); - feedPageRef.current += 1; - } - } - } finally { - isFetchingRef.current = false; - console.log(feeds); - } - }; + // React Query를 사용한 무한 스크롤 데이터 로드 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({ + queryKey: ['posts'], // 같은 key를 가진 쿼리는 캐시됨 + queryFn: ({ pageParam }) => getPostListApi({ pageParam }), // 페이지별 데이터 가져오는 함수 + initialPageParam: 1, // 첫 번째 페이지는 1부터 시작 + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, // 다음 페이지가 존재하면 page + 1, 없으면 undefined + }); + // 디버깅 useEffect(() => { - // 데이터의 끝에 다다르면 옵저버 해제 (더이상 피드가 없으면) - if (isReachedEndRef.current && observerRef.current && loadMoreRef.current) { - observerRef.current.unobserve(loadMoreRef.current); + console.log('Query Status:', status); + console.log('Fetched Data:', data); + console.log('Fetching Next Page:', isFetchingNextPage); + console.log('Has Next Page:', hasNextPage); + }, [status, data, isFetchingNextPage, hasNextPage]); - return; - } + // Intersection Observer를 설정하여 스크롤이 마지막 요소에 닿았을 때 fetchNextPage 호출 + useEffect(() => { + if (!loadMoreRef.current || !hasNextPage) return; // 다음 페이지가 없으면 실행 X // Intersection Observer 생성 observerRef.current = new IntersectionObserver( debounce((entries) => { - const target = entries[0]; - console.log('Intersection Observer:', target.isIntersecting); - if (target.isIntersecting && !isFetchingRef.current && !isReachedEndRef.current) { - getPostList(); + // 요소가 화면에 보이면 fetchNextPage 호출 (스크롤 트리거) + if (entries[0].isIntersecting) { + fetchNextPage(); } - }, 300), + }, 300), // 디바운싱 적용 (300ms 내 반복 호출 방지) { root: null, rootMargin: '100px', @@ -74,40 +53,27 @@ const OOTD: React.FC = () => { }, ); - // 옵저버를 마지막 요소에 연결 - if (loadMoreRef.current) { - observerRef.current.observe(loadMoreRef.current); - } - return () => { - // 컴포넌트 언마운트 시 옵저버 해제 - if (observerRef.current && loadMoreRef.current) { - observerRef.current.unobserve(loadMoreRef.current); - } - }; - }, []); - - useEffect(() => { - getPostList(); - - // 세션에 저장된 이전 스크롤 위치 복원 - window.scrollTo(0, scrollPositionRef.current); + // 옵저버를 마지막 요소(loadMoreRef)에 연결 + observerRef.current.observe(loadMoreRef.current); return () => { - // 컴포넌트 언마운트 시 현재 스크롤 위치를 세션 스토리지에 저장 - sessionStorage.setItem('scrollPosition', String(window.scrollY)); + // 컴포넌트 언마운트 시 옵저버 해제 + observerRef.current?.disconnect(); }; - }, []); + }, [hasNextPage, fetchNextPage]); return ( - {feeds.map((feed) => ( -
- -
- ))} - {/* Intersection Observer가 감지할 마지막 요소 */} + {data?.pages.flatMap((page) => + page.posts.map((feed) => ( +
+ +
+ )), + )}
+ {isFetchingNextPage && } ); 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/styles.tsx b/src/pages/Post/styles.tsx index 5e347dfe..40a02ee2 100644 --- a/src/pages/Post/styles.tsx +++ b/src/pages/Post/styles.tsx @@ -1,28 +1,11 @@ import { styled } from 'styled-components'; -export const InputLayout = styled.div` +export const InfoWrapper = styled.div` display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + flex-direction: row; + align-items: left; +`; - textarea { - display: block; - width: calc(100% - 3rem); - height: 5.75rem; - border-radius: 0.125rem; - border: 0.0625rem solid ${({ theme }) => theme.colors.gray[600]}; - margin-bottom: 5.875rem; - z-index: 2; - margin-top: -3.75rem; - outline: none; - padding: 0.8125rem 0.9375rem; - font-family: 'Pretendard Variable'; - font-size: 1rem; - font-style: normal; - font-weight: 300; - line-height: 150%; - color: ${({ theme }) => theme.colors.text.primary}; - resize: none; - } +export const PicWrapper = styled.div` + margin-left: 47px; `; diff --git a/src/pages/Profile/ButtonSecondary/styles.tsx b/src/pages/Profile/ButtonSecondary/styles.tsx index 14b7e3f7..db0e8f20 100644 --- a/src/pages/Profile/ButtonSecondary/styles.tsx +++ b/src/pages/Profile/ButtonSecondary/styles.tsx @@ -2,7 +2,7 @@ import { styled } from 'styled-components'; export const Button = styled.button` width: 90%; - margin: 1rem auto; + margin: 16px auto; height: 3.1rem; text-align: center; color: ${({ theme }) => theme.colors.brand.primary}; diff --git a/src/pages/Profile/ProfileEdit/index.tsx b/src/pages/Profile/ProfileEdit/index.tsx index b9eb24a3..28676c02 100644 --- a/src/pages/Profile/ProfileEdit/index.tsx +++ b/src/pages/Profile/ProfileEdit/index.tsx @@ -15,8 +15,8 @@ import imageBasic from '@assets/default/defaultProfile.svg'; import BottomButton from '@components/BottomButton/index'; import { OODDFrame } from '@components/Frame/Frame'; -import Loading from '@components/Loading/index'; import Modal from '@components/Modal/index'; +import Skeleton from '@components/Skeleton'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar/index'; @@ -83,7 +83,7 @@ const ProfileEdit: React.FC = () => { } }; - getUserInfo(); + setTimeout(getUserInfo, 1000); }, []); const handleButtonClick = () => { @@ -155,7 +155,59 @@ const ProfileEdit: React.FC = () => { }; if (isLoading || uploading) { - return ; + return ( + + + navigate(-1)} /> + + + + + + + + + + + + 이름 + + + + + + 닉네임 + + + + + + 소개글 + + + + + + 전화번호 + + + + + + 생년월일 + + + + + + 이메일 + + + + + + + ); } return ( @@ -222,7 +274,7 @@ const ProfileEdit: React.FC = () => { setEmail(e.target.value)} /> - +
); diff --git a/src/pages/Profile/index.tsx b/src/pages/Profile/index.tsx index 5b8a41e6..95673dbe 100644 --- a/src/pages/Profile/index.tsx +++ b/src/pages/Profile/index.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -19,9 +20,9 @@ import button_plus from '@assets/default/plus.svg'; import CommentBottomSheet from '@components/BottomSheet/CommentBottomSheet'; import OptionsBottomSheet from '@components/BottomSheet/OptionsBottomSheet'; import { OODDFrame } from '@components/Frame/Frame'; -import Loading from '@components/Loading'; import Modal from '@components/Modal'; import NavBar from '@components/NavBar'; +import Skeleton from '@components/Skeleton'; import { StyledText } from '@components/Text/StyledText'; import TopBar from '@components/TopBar'; @@ -36,6 +37,7 @@ import UserProfile from './UserProfile/index'; import { ProfileContainer, Header, + ProfileDetail, StatsContainer, Stat, StatNumber, @@ -44,6 +46,7 @@ import { AddButton, NoPostWrapper, Button, + ButtonSkeleton, } from './styles'; const Profile: React.FC = () => { @@ -70,15 +73,19 @@ const Profile: React.FC = () => { try { const response = await getUserInfoApi(profileUserId); const postResponse = await getUserPostListApi(1, 10, profileUserId); - setUserInfo(response.data); - setPosts(postResponse.data.post); - setTotalPosts(postResponse.data.totalPostsCount); + // 1초 동안 스켈레톤 보여주기 확인용! 나중에 다 수정하고 삭제예정! + setTimeout(() => { + setUserInfo(response.data); + setPosts(postResponse.data.post); + setTotalPosts(postResponse.data.totalPostsCount); + setIsLoading(false); + }, 1000); } catch (error) { console.error('데이터 가져오기 실패:', error); - } finally { - setIsLoading(false); + setIsLoading(false); // 실패해도 로딩 상태는 끝나야 하니까 여기서도 false 처리 } }; + fetchData(); }, [profileUserId]); @@ -100,7 +107,64 @@ const Profile: React.FC = () => { setIsBottomSheetOpen(false); }; - if (isLoading) return ; + // 로딩 중일 때 스켈레톤 UI 표시 + if (isLoading) { + return ( + + + {isMyPage ? ( + + ) : ( + navigate(-1)} + onClickRightButton={() => setIsOptionsBottomSheetOpen(true)} + /> + )} + +
+ {/* 프로필 섹션 스켈레톤 */} + + + + + + +
+ + {/* 버튼 스켈레톤 */} + + + + + {/* 통계 스켈레톤 */} + + + + + {isMyPage && ( + + + + )} + + + + + + {/* 포스트 스켈레톤 */} + + {[1, 2, 3, 4].map((item) => ( + + ))} + + + {isMyPage && } +
+
+ ); + } return ( diff --git a/src/pages/Profile/styles.tsx b/src/pages/Profile/styles.tsx index 50c6463f..39880a72 100644 --- a/src/pages/Profile/styles.tsx +++ b/src/pages/Profile/styles.tsx @@ -12,6 +12,14 @@ export const ProfileContainer = styled.div` padding-top: 0rem; `; +export const ProfileDetail = styled.div` + flex: 1; + margin-left: 15px; + display: flex; + flex-direction: column; + gap: 5px; +`; + export const Header = styled.div` margin: 0.5rem 1.25rem; display: flex; @@ -35,7 +43,6 @@ export const Stat = styled.div` export const StatNumber = styled.div` color: ${({ theme }) => theme.colors.text.caption}; - //변경된 컬러시스템에서의 gray4가 800으로 나와있어서 적용해보면 색상이 다르게 나옵니다! text-align: center; font-family: 'Pretendard'; font-size: 1rem; @@ -59,7 +66,9 @@ export const PostsContainer = styled.div` justify-content: space-between; gap: 0.9375rem; margin-bottom: 100px; - padding: 1.25rem; + padding: 20px; + width: 100%; + `; export const AddButton = styled.button` @@ -94,3 +103,8 @@ export const Button = styled.button` padding: 0.625rem; background: ${({ theme }) => theme.colors.brand.gradient}; `; + +export const ButtonSkeleton = styled.button` + width: 90%; + margin: 16px auto; +`; 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"