diff --git a/client/src/app/(protectedRoute)/user/follow-list/layout.tsx b/client/src/app/(protectedRoute)/user/follow-list/layout.tsx new file mode 100644 index 0000000..0c8f813 --- /dev/null +++ b/client/src/app/(protectedRoute)/user/follow-list/layout.tsx @@ -0,0 +1,22 @@ +"use client"; +import CustomAppbar from "@/components/layout/CustomAppbar"; +import CustomContainer from "@/components/layout/CustomContainer"; +import { useMyInfoQuery } from "@/queries/auth/useMyInfoQuery"; +import { ReactNode } from "react"; + +type FollowListLayoutProps = { + children: ReactNode; +}; + +const FollowListLayout = ({ children }: FollowListLayoutProps) => { + const { data: myInfo } = useMyInfoQuery(); + + return ( + <> + + {children} + + ); +}; + +export default FollowListLayout; diff --git a/client/src/app/(protectedRoute)/user/follow-list/page.tsx b/client/src/app/(protectedRoute)/user/follow-list/page.tsx new file mode 100644 index 0000000..6028be2 --- /dev/null +++ b/client/src/app/(protectedRoute)/user/follow-list/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Box } from "@mui/material"; +import CustomToggleButtonGroup from "@/components/CustomToggleButtonGroup"; +import { appbarHeight } from "@/const/uiSizes"; +import { Suspense, useState } from "react"; +import FollowingList from "@/components/user/followList/FollowingList"; +import FollowingUserCardSkeleton from "@/components/user/followList/FollowingUserCardSkeleton"; +import ComponentRepeater from "@/components/ComponentRepeater"; +import FollowerList from "@/components/user/followList/FollowerList"; + +const FollowListPage = () => { + const selectableList = ["팔로잉", "팔로워"]; + const [currentView, setCurrentView] = useState(selectableList[0]); + + return ( + <> + + {/* Fixed로 빠진 button 위치만큼의 place holder */} + + + + + } + > + {currentView === "팔로잉" && } + {currentView === "팔로워" && } + + + ); +}; + +export default FollowListPage; diff --git a/client/src/app/(protectedRoute)/user/setting/layout.tsx b/client/src/app/(protectedRoute)/user/setting/layout.tsx index 459189a..5d6a50d 100644 --- a/client/src/app/(protectedRoute)/user/setting/layout.tsx +++ b/client/src/app/(protectedRoute)/user/setting/layout.tsx @@ -1,6 +1,7 @@ "use client"; import CustomAppbar from "@/components/layout/CustomAppbar"; +import { appbarHeight } from "@/const/uiSizes"; import { Container, Stack } from "@mui/material"; import { ReactNode } from "react"; @@ -12,7 +13,10 @@ const UserInfoPageLayout = ({ children }: Props) => { return ( <> - + {children} diff --git a/client/src/components/ComponentRepeater.tsx b/client/src/components/ComponentRepeater.tsx new file mode 100644 index 0000000..7dd0fba --- /dev/null +++ b/client/src/components/ComponentRepeater.tsx @@ -0,0 +1,18 @@ +import { cloneElement, ReactComponentElement } from "react"; + +type Props = { + children: ReactComponentElement; + count: number; +}; + +const ComponentRepeater = ({ children, count }: Props) => { + return ( + <> + {Array.from(new Array(count)).map((_e, i) => + cloneElement(children, { key: i }) + )} + + ); +}; + +export default ComponentRepeater; diff --git a/client/src/components/CustomToggleButtonGroup.tsx b/client/src/components/CustomToggleButtonGroup.tsx new file mode 100644 index 0000000..e042fc2 --- /dev/null +++ b/client/src/components/CustomToggleButtonGroup.tsx @@ -0,0 +1,72 @@ +"use client"; +import { + ToggleButton, + ToggleButtonGroup, + ToggleButtonGroupProps, + Typography, +} from "@mui/material"; +import { useState } from "react"; + +interface CustomToggleButtonGroupType + extends Omit { + onChange: (val: string) => void; + value: string[]; +} + +const CustomToggleButtonGroup = ({ + onChange, + value, + sx, + ...toggleBtnGroupProps +}: CustomToggleButtonGroupType) => { + const [currentValue, setCurrentValue] = useState(value[0]); + + return ( + { + if (val !== null) { + setCurrentValue(val); + onChange(val); + } + }} + sx={{ backgroundColor: "background.paper",px:2, ...sx }} + {...toggleBtnGroupProps} + > + {value.map((val, i) => { + return ( + + + {val} + + + ); + })} + + ); +}; + +const ToggleButtonStyle = { + border: 0, + borderRadius: 0, + "&.Mui-selected": { + backgroundColor: "background.paper", + borderBottom: "1px solid", + ":hover": { + backgroundColor: "background.paper", + }, + }, + ":hover": { + backgroundColor: "background.paper", + }, +}; + +export default CustomToggleButtonGroup; diff --git a/client/src/components/layout/CustomAppbar.tsx b/client/src/components/layout/CustomAppbar.tsx index 9fda6eb..79775a6 100644 --- a/client/src/components/layout/CustomAppbar.tsx +++ b/client/src/components/layout/CustomAppbar.tsx @@ -11,6 +11,7 @@ import { import GoBackIcon from "@/assets/icons/GoBackIcon.svg"; import { MouseEventHandler, ReactNode, memo } from "react"; import { useRouter } from "next/navigation"; +import { appbarHeight } from "@/const/uiSizes"; interface CustomAppbarInterface extends AppBarProps { title?: string; @@ -34,7 +35,7 @@ const CustomAppbar = ({ const router = useRouter(); return ( - + {/* 프리팬드 버튼 */} {prependButton ? ( diff --git a/client/src/components/layout/CustomContainer.tsx b/client/src/components/layout/CustomContainer.tsx index 4544021..ae95642 100644 --- a/client/src/components/layout/CustomContainer.tsx +++ b/client/src/components/layout/CustomContainer.tsx @@ -1,3 +1,4 @@ +import { appbarHeight, navbarHeight } from "@/const/uiSizes"; import { Container, ContainerProps, Paper } from "@mui/material"; interface CustomContainerInterface extends ContainerProps { @@ -9,9 +10,6 @@ const CustomContainer = ({ disableMt, children, }: CustomContainerInterface) => { - const appbarHeight = '64px' - const navbarHeight = '56px' - return ( {children} diff --git a/client/src/components/post/PostCardList.tsx b/client/src/components/post/PostCardList.tsx index b507601..01adf81 100644 --- a/client/src/components/post/PostCardList.tsx +++ b/client/src/components/post/PostCardList.tsx @@ -59,7 +59,7 @@ function PostCardList(props: UseGetPostListQueryInterface) { ) : ( // 인터섹션옵저버 -
+ hasNextPage&&
)} diff --git a/client/src/components/user/followList/FollowUserCard.tsx b/client/src/components/user/followList/FollowUserCard.tsx new file mode 100644 index 0000000..edb8be9 --- /dev/null +++ b/client/src/components/user/followList/FollowUserCard.tsx @@ -0,0 +1,61 @@ +import { Button, Stack, Typography } from "@mui/material"; +import React from "react"; +import UserAvatar from "@/components/user/info/UserAvatar"; +import { useRouter } from "next/navigation"; +import { USER_PAGE } from "@/const/clientPath"; +import useUnFollowMutation from "@/queries/user/useUnFollowMutation"; + +type Props = { + imageUrl?: string; + nickName: string; + userId: string; + content: string; + userPk: number; +}; + +const FollowUserCard = ({ + userPk, + imageUrl, + nickName, + userId, + content, +}: Props) => { + const router = useRouter(); + const { mutate: unfollowHandler } = useUnFollowMutation(); + + return ( + + router.push(USER_PAGE(userPk))} + sx={{ cursor: "pointer" }} + /> + + + router.push(USER_PAGE(userPk))} + > + + {nickName} + + + @{userId} + + + + + {content} + + + ); +}; + +export default FollowUserCard; diff --git a/client/src/components/user/followList/FollowerList.tsx b/client/src/components/user/followList/FollowerList.tsx new file mode 100644 index 0000000..ec88f26 --- /dev/null +++ b/client/src/components/user/followList/FollowerList.tsx @@ -0,0 +1,43 @@ +"use client"; + +import FollowUserCard from "@/components/user/followList/FollowUserCard"; +import { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import FollowingUserCardSkeleton from "@/components/user/followList/FollowingUserCardSkeleton"; +import ComponentRepeater from "@/components/ComponentRepeater"; +import useFollowerUserInfiniteQuery from "@/queries/user/useFollowerUserInfiniteQuery"; + +const FollowerList = () => { + const { data, isFetchingNextPage, hasNextPage, fetchNextPage } = + useFollowerUserInfiniteQuery(); + const { ref, inView } = useInView(); + + useEffect(() => { + if (hasNextPage && inView) fetchNextPage(); + }, [inView, hasNextPage]); + + return ( + <> + {data.pages.map((page) => + page.content.map(({ nickname, id, introduction }) => ( + + )) + )} + {isFetchingNextPage ? ( + + + + ) : ( + // 인터섹션옵저버 + hasNextPage &&
+ )} + + ); +}; + +export default FollowerList; diff --git a/client/src/components/user/followList/FollowingList.tsx b/client/src/components/user/followList/FollowingList.tsx new file mode 100644 index 0000000..7e5ab07 --- /dev/null +++ b/client/src/components/user/followList/FollowingList.tsx @@ -0,0 +1,45 @@ +"use client"; + +import FollowUserCard from "@/components/user/followList/FollowUserCard"; +import useFollowingUserInfiniteQuery from "@/queries/user/useFollowingUserInfiniteQuery"; +import { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import FollowingUserCardSkeleton from "@/components/user/followList/FollowingUserCardSkeleton"; +import ComponentRepeater from "@/components/ComponentRepeater"; + +const FollowingList = () => { + const { data, isFetchingNextPage, hasNextPage, fetchNextPage } = + useFollowingUserInfiniteQuery(); + const { ref, inView } = useInView(); + + useEffect(() => { + if (hasNextPage && inView) fetchNextPage(); + }, [inView, hasNextPage]); + + return ( + <> + {data.pages.map((page) => + page.content.map(({ nickname, id, introduction, profileImgUrls, userNo }) => ( + + )) + )} + {isFetchingNextPage ? ( + + + + ) : ( + // 인터섹션옵저버 + hasNextPage &&
+ )} + + ); +}; + +export default FollowingList; diff --git a/client/src/components/user/followList/FollowingUserCardSkeleton.tsx b/client/src/components/user/followList/FollowingUserCardSkeleton.tsx new file mode 100644 index 0000000..a6b0c53 --- /dev/null +++ b/client/src/components/user/followList/FollowingUserCardSkeleton.tsx @@ -0,0 +1,22 @@ +import { Stack } from "@mui/material"; +import { Skeleton } from "@mui/material"; + +const FollowingUserCardSkeleton = () => { + return ( + + + + + + + + + + + + + + ); +}; + +export default FollowingUserCardSkeleton; diff --git a/client/src/components/user/info/UserInfoCard.tsx b/client/src/components/user/info/UserInfoCard.tsx index 4b6b363..b770879 100644 --- a/client/src/components/user/info/UserInfoCard.tsx +++ b/client/src/components/user/info/UserInfoCard.tsx @@ -9,6 +9,8 @@ import { useMyInfoQuery } from "@/queries/auth/useMyInfoQuery"; import { useContext, useMemo } from "react"; import UserPageContext from "@/store/user/UserPageContext"; import UserInfoCardSkeleton from "./UserInfoCardSkeleton"; +import { useRouter } from "next/navigation"; +import { USER_FOLLOW_LIST } from "@/const/clientPath"; type Props = { initialData?: UserInfoInterface; @@ -24,6 +26,7 @@ const UserInfo = ({ initialData, userId }: Props) => { ); const token = getTokenFromLocalStorage(); + const router = useRouter(); const { setIsEditing } = useContext(UserPageContext); const { data } = useUserInfoQuery({ @@ -33,7 +36,7 @@ const UserInfo = ({ initialData, userId }: Props) => { }); if (!data) { - return ; + return ; } const { @@ -64,7 +67,14 @@ const UserInfo = ({ initialData, userId }: Props) => { {introduction ?? "자기소개가 없습니다"}
- + { + isMyProfile && router.push(USER_FOLLOW_LIST); + }} + > {followerCount} 팔로워 {followingCount} diff --git a/client/src/const/clientPath.ts b/client/src/const/clientPath.ts index f920467..f6c329a 100644 --- a/client/src/const/clientPath.ts +++ b/client/src/const/clientPath.ts @@ -18,12 +18,17 @@ export const MY_PROFILE = "/user" as const; /** * 유저의 PK를 입력받아 해당유저의 프로필 페이지로 이동하는 URL */ -export const USER_PAGE = (pk: string | number) => `/user/${pk}`; +export const USER_PAGE = (pk: string | number) => `${MY_PROFILE}/${pk}`; + +/** + * 유저가 팔로잉/팔로워 리스트페이지로 이동하는 라우트 + */ +export const USER_FOLLOW_LIST = `${MY_PROFILE}/follow-list` /** * 유저정보 세팅 페이지로 이동하는 라우트 */ -export const SETTING_PAGE = '/user/setting' as const +export const SETTING_PAGE = `${MY_PROFILE}/setting` as const /** * 술과사전 페이지 라우트 diff --git a/client/src/const/serverPath.ts b/client/src/const/serverPath.ts index 852adbc..b02d8ea 100644 --- a/client/src/const/serverPath.ts +++ b/client/src/const/serverPath.ts @@ -84,7 +84,8 @@ export const GET_ALCOHOL_LIST = "/alcohols" as const; /** * 알콜 디테일을 받아오는 URL */ -export const GET_ALCOHOL_DETAIL = (id: string) => `${GET_ALCOHOL_LIST}/${id}` as const; +export const GET_ALCOHOL_DETAIL = (id: string) => + `${GET_ALCOHOL_LIST}/${id}` as const; /** * 포스트의 PK를 입력받아 해당 PK의 게시글의 좋아요 취소를 요청 @@ -98,6 +99,15 @@ export const POST_UN_LIKE_URL = (id: string) => * @returns */ export const USER_SUMMARY = (id: string) => `/user/${id}/summary` as const; +/** + * 내가 팔로우 하고 있는 유저를 불러오는 URL + */ +export const FOLLOWING_USER = "/user/my-following-users"; + +/** + * 나를 팔로우 하고 있는 유저를 불러오는 URL + */ +export const FOLLOWER_USER = "/user/users-of-following-me"; /** * 유저 ID 를 입력받아 해당 유저를 팔로우 하는 URL diff --git a/client/src/const/uiSizes.ts b/client/src/const/uiSizes.ts new file mode 100644 index 0000000..e09b97b --- /dev/null +++ b/client/src/const/uiSizes.ts @@ -0,0 +1,8 @@ +/** + * 최상단 앱바의 높이 + */ +export const appbarHeight = '64px' +/** + * 최하단 네비게이션바의 높이 + */ +export const navbarHeight = '56px' \ No newline at end of file diff --git a/client/src/queries/user/useFollowMutation.ts b/client/src/queries/user/useFollowMutation.ts index 027a006..6e47527 100644 --- a/client/src/queries/user/useFollowMutation.ts +++ b/client/src/queries/user/useFollowMutation.ts @@ -7,6 +7,7 @@ import { UserInfoInterface } from "@/types/user/userInfoInterface"; import { MyInfoQueryKeys } from "../auth/useMyInfoQuery"; import { MyInfoInterface } from "@/types/auth/myInfo"; import { useErrorHandler } from "@/utils/errorHandler"; +import { followerUserQueryKey } from "./useFollowerUserInfiniteQuery"; const useFollowMutation = () => { const queryClient = useQueryClient(); @@ -59,6 +60,8 @@ const useFollowMutation = () => { queryClient.invalidateQueries({ queryKey: UserInfoQueryKey.byId(userInfo?.userNo), }); + // TODO 낙관적업데이트 구현 + queryClient.invalidateQueries({ queryKey: followerUserQueryKey.all }); }, }); }; diff --git a/client/src/queries/user/useFollowerUserInfiniteQuery.ts b/client/src/queries/user/useFollowerUserInfiniteQuery.ts new file mode 100644 index 0000000..d53b948 --- /dev/null +++ b/client/src/queries/user/useFollowerUserInfiniteQuery.ts @@ -0,0 +1,51 @@ +"use client"; +import { FOLLOWER_USER } from "@/const/serverPath"; +import useAxiosPrivate from "@/hooks/useAxiosPrivate"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import Pagenated, { PagenationParams } from "@/types/Pagenated"; +import FollowingUserInterface from "@/types/user/followingUserInterface"; + +const useFollowerUserInfiniteQuery = () => { + return useSuspenseInfiniteQuery({ + queryKey: followerUserQueryKey.all, + + queryFn: async ({ pageParam = 0 }) => + await getFollowerUserFn({ page: pageParam }), + + getNextPageParam: ({ currentPage, hasNextPage }) => + hasNextPage ? currentPage + 1 : undefined, + + getPreviousPageParam: ({ currentPage }) => + currentPage > 0 ? currentPage - 1 : undefined, + initialPageParam: 0, + }); +}; + +export const getFollowerUserFn = async ({ + page = 0, + size = 10, + sort = "desc", +}: PagenationParams) => { + const axiosPrivate = useAxiosPrivate(); + const { data } = await axiosPrivate.get>( + FOLLOWER_USER, + { + params: { + page, + size, + sort, + }, + } + ); + return { + ...data, + currentPage: page, + hasNextPage: data.totalElements / ((page + 1) * size) > 1, + }; +}; + +export const followerUserQueryKey = { + all: ["follower"], +}; + +export default useFollowerUserInfiniteQuery; diff --git a/client/src/queries/user/useFollowingUserInfiniteQuery.ts b/client/src/queries/user/useFollowingUserInfiniteQuery.ts new file mode 100644 index 0000000..c79bb59 --- /dev/null +++ b/client/src/queries/user/useFollowingUserInfiniteQuery.ts @@ -0,0 +1,51 @@ +"use client"; +import { FOLLOWING_USER } from "@/const/serverPath"; +import useAxiosPrivate from "@/hooks/useAxiosPrivate"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import Pagenated, { PagenationParams } from "@/types/Pagenated"; +import FollowingUserInterface from "@/types/user/followingUserInterface"; + +const useFollowingUserInfiniteQuery = () => { + return useSuspenseInfiniteQuery({ + queryKey: followingUserQueryKey.all, + + queryFn: async ({ pageParam = 0 }) => + await getFollowingUserFn({ page: pageParam }), + + getNextPageParam: ({ currentPage, hasNextPage }) => + hasNextPage ? currentPage + 1 : undefined, + + getPreviousPageParam: ({ currentPage }) => + currentPage > 0 ? currentPage - 1 : undefined, + initialPageParam: 0, + }); +}; + +export const getFollowingUserFn = async ({ + page = 0, + size = 10, + sort = "desc", +}: PagenationParams) => { + const axiosPrivate = useAxiosPrivate(); + const { data } = await axiosPrivate.get>( + FOLLOWING_USER, + { + params: { + page, + size, + sort, + }, + } + ); + return { + ...data, + currentPage: page, + hasNextPage: data.totalElements / ((page + 1) * size) > 1, + }; +}; + +export const followingUserQueryKey = { + all: ["followingUser"], +}; + +export default useFollowingUserInfiniteQuery; diff --git a/client/src/queries/user/useUnFollowMutation.ts b/client/src/queries/user/useUnFollowMutation.ts index 36cdc89..f73438b 100644 --- a/client/src/queries/user/useUnFollowMutation.ts +++ b/client/src/queries/user/useUnFollowMutation.ts @@ -7,11 +7,12 @@ import { UserInfoInterface } from "@/types/user/userInfoInterface"; import { MyInfoQueryKeys } from "../auth/useMyInfoQuery"; import { MyInfoInterface } from "@/types/auth/myInfo"; import { useErrorHandler } from "@/utils/errorHandler"; +import { followingUserQueryKey } from "./useFollowingUserInfiniteQuery"; const useUnFollowMutation = () => { const queryClient = useQueryClient(); const errorHandler = useErrorHandler(); - + return useMutation({ mutationFn: async (userNo: string) => await followUserMutationFn(userNo), /** @@ -46,7 +47,7 @@ const useUnFollowMutation = () => { * Mutation 실패시 원래 QuerySnapShot정보로 롤백 */ onError: (err, queryFnParams, context) => { - errorHandler(err) + errorHandler(err); if (!context) { return; } @@ -63,6 +64,8 @@ const useUnFollowMutation = () => { queryClient.invalidateQueries({ queryKey: UserInfoQueryKey.byId(userInfo?.userNo), }); + // TODO 낙관적업데이트 구현 + queryClient.invalidateQueries({ queryKey: followingUserQueryKey.all }); }, }); }; diff --git a/client/src/types/Pagenated.ts b/client/src/types/Pagenated.ts index 513796b..93c8f3a 100644 --- a/client/src/types/Pagenated.ts +++ b/client/src/types/Pagenated.ts @@ -33,3 +33,9 @@ interface PageableInterface { paged: boolean; unpaged: boolean; } + +export interface PagenationParams { + page?: number; + size?: number; + sort?: string; +} diff --git a/client/src/types/user/followingUserInterface.ts b/client/src/types/user/followingUserInterface.ts new file mode 100644 index 0000000..e51e28a --- /dev/null +++ b/client/src/types/user/followingUserInterface.ts @@ -0,0 +1,12 @@ +import AttachInterface from "../attach/attachInterface"; + +interface FollowingUserInterface { + nickname: string; + id: string; + userNo: number; + introduction: string; + createdBy: number; + profileImgUrls: AttachInterface[]; +} + +export default FollowingUserInterface;