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;