diff --git a/package.json b/package.json index 3ab39bf..b355d27 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,7 @@ "prepare": "husky" }, "lint-staged": { - "*.{js,jsx,ts,tsx}": [ - "biome check" - ] + "*.{js,jsx,ts,tsx}": ["biome check"] }, "dependencies": { "@emotion/react": "^11.14.0", diff --git a/src/Follow/page.tsx b/src/Follow/page.tsx index 5cbd75d..7054356 100644 --- a/src/Follow/page.tsx +++ b/src/Follow/page.tsx @@ -3,6 +3,7 @@ import { DetectTypeSelector } from "@/Follow/components/DetectTypeSelector"; import { FollowList } from "@/Follow/components/FollowList"; import { RefreshButton } from "@/Follow/components/RefreshButton"; import { useQueryFollowDetect } from "@/Follow/hooks/apis/useQueryFollowDetect"; +import { BottomNav } from "@/common/components/BottomNav"; import Header from "@/common/components/Header"; import Spacer from "@/common/components/Spacer"; import UserListItemCard from "@/common/components/UserListItemCard"; @@ -18,7 +19,11 @@ export function FollowPage() { const [selectedType, setSelectedType] = useState("mutual"); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useQueryFollowDetect(selectedType); - const observerRef = useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage }); + const observerRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); return ( <> @@ -82,6 +87,7 @@ export function FollowPage() { )} + ); } diff --git a/src/Ranking/apis/ranking.ts b/src/Ranking/apis/ranking.ts new file mode 100644 index 0000000..e52c38a --- /dev/null +++ b/src/Ranking/apis/ranking.ts @@ -0,0 +1,31 @@ +import { apiClient } from "@/common/api"; + +export type durationType = "week" | "month" | "year"; + +interface RankingUser { + userid: number; + profileImageUrl: string; + username: string; + rankPoint: number; +} + +interface RankingUserResponse { + rankingList: RankingUser[]; + lastPage: boolean; +} + +interface QueryUserRankingParams { + duration: durationType; + page?: number; + size?: number; +} + +export async function queryUserRanking({ duration, page = 0, size = 20 }: QueryUserRankingParams) { + const params = new URLSearchParams({ + period: duration, + page: page.toString(), + size: size.toString(), + }); + + return apiClient.get(`api/v1/rankings?${params.toString()}`); +} diff --git a/src/Ranking/components/PodiumItem.tsx b/src/Ranking/components/PodiumItem.tsx new file mode 100644 index 0000000..43a012d --- /dev/null +++ b/src/Ranking/components/PodiumItem.tsx @@ -0,0 +1,104 @@ +import UserProfileAvatar from "@/common/components/UserProfileAvatar"; +import { rem } from "@/common/utils/rem"; +import styled from "@emotion/styled"; + +interface PodiumItemProps { + rank: number; + nickname: string; + point: number; + profileImage: string; +} + +export function PodiumItem({ rank, nickname, point, profileImage }: PodiumItemProps) { + return ( + + + + {rank} + + {nickname} + {point}pt + + ); +} + +const PodiumItemWrapper = styled.div<{ rank: number }>(({ rank }) => { + const getPosition = () => { + switch (rank) { + case 1: + return { + left: "50%", + transform: "translateX(-50%)", + top: 0, + }; + case 2: + return { + left: "2rem", + top: rem(3), + }; + case 3: + return { + right: "2rem", + top: rem(3), + }; + default: + return {}; + } + }; + + return { + position: "absolute", + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "fit-content", + ...getPosition(), + }; +}); + +const ImageWrapper = styled.div({ + position: "relative", + + width: rem(7.8), + height: rem(7.8), + padding: rem(0.3), + backgroundColor: "#ffffff", + + borderRadius: rem(999), +}); + +const Medal = styled.div<{ rank: number }>(({ rank }) => ({ + position: "absolute", + bottom: 0, + right: 0, + + width: rem(2.4), + height: rem(2.4), + + display: "flex", + alignItems: "center", + justifyContent: "center", + + fontSize: rem(1.7), + fontWeight: 700, + color: "#ffffff", + + borderRadius: rem(999), + backgroundColor: rank === 1 ? "#F8A900" : rank === 2 ? "#B4B5BB" : rank === 3 ? "#D46A28" : "#FFFFFF", +})); + +const Nickname = styled.div<{ rank: number }>(({ rank }) => ({ + marginTop: rem(0.5), + + fontSize: rem(1.7), + fontWeight: 700, + color: rank === 1 ? "#F8A900" : rank === 2 ? "#B4B5BB" : rank === 3 ? "#D46A28" : "#ffffff", +})); + +const Point = styled.div({ + marginTop: rem(0.1), + + fontSize: rem(1.7), + fontWeight: 700, + color: "#ffffff", +}); diff --git a/src/Ranking/components/RankingItem.tsx b/src/Ranking/components/RankingItem.tsx new file mode 100644 index 0000000..296cccb --- /dev/null +++ b/src/Ranking/components/RankingItem.tsx @@ -0,0 +1,77 @@ +import UserProfileAvatar from "@/common/components/UserProfileAvatar"; +import { rem } from "@/common/utils/rem"; +import styled from "@emotion/styled"; + +interface RankingItemProps { + rank: number; + nickname: string; + point: number; + profileImage: string; +} + +export function RankingItem({ rank, nickname, point, profileImage }: RankingItemProps) { + return ( + + + {rank} + + + + {nickname} + + {point}pt + + ); +} + +const LeftSection = styled.div({ + display: "flex", + alignItems: "center", + gap: rem(2.6), +}); + +const RankingItemWrapper = styled.div({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexShrink: 0, + + width: "100%", + height: rem(6.9), + + padding: `0 ${rem(2)}`, + + borderRadius: rem(1), + backgroundColor: "rgba(138, 138, 138, 0.19)", +}); + +const Rank = styled.div({ + fontSize: rem(2), + fontWeight: 700, + color: "#000000", +}); + +const ImageWrapper = styled.div({ + position: "relative", + + width: rem(5), + height: rem(5), + padding: rem(0.2), + backgroundColor: "#ffffff", + + borderRadius: rem(999), +}); + +const Nickname = styled.div({ + fontSize: rem(1.6), + fontWeight: 700, + color: "#000000", +}); + +const Point = styled.div({ + fontSize: rem(1.6), + fontWeight: 700, + color: "#000000", + + justifySelf: "flex-end", +}); diff --git a/src/Ranking/hooks/useQueryUserRanking.ts b/src/Ranking/hooks/useQueryUserRanking.ts new file mode 100644 index 0000000..cfe86a5 --- /dev/null +++ b/src/Ranking/hooks/useQueryUserRanking.ts @@ -0,0 +1,33 @@ +import type { ApiResponse } from "@/common/types/api"; +import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query"; +import { type durationType, queryUserRanking } from "../apis/ranking"; + +interface RankingUserResponse { + rankingList: Array<{ + userid: number; + profileImageUrl: string; + username: string; + rankPoint: number; + }>; + lastPage: boolean; +} + +export function useQueryUserRanking({ duration }: { duration: durationType }) { + return useInfiniteQuery< + ApiResponse, + Error, + InfiniteData>, + string[], + number + >({ + queryKey: ["userRanking", duration], + queryFn: ({ pageParam = 0 }) => queryUserRanking({ duration, page: pageParam, size: 20 }), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.data.lastPage) { + return undefined; + } + return allPages.length; + }, + }); +} diff --git a/src/Ranking/page.tsx b/src/Ranking/page.tsx new file mode 100644 index 0000000..ebb8993 --- /dev/null +++ b/src/Ranking/page.tsx @@ -0,0 +1,179 @@ +import { BottomNav } from "@/common/components/BottomNav"; +import Header from "@/common/components/Header"; +import { useInfiniteScroll } from "@/common/hooks/useInfiniteScroll"; +import { rem } from "@/common/utils/rem"; +import styled from "@emotion/styled"; +import { Flex, Spinner } from "@radix-ui/themes"; +import { useState } from "react"; +import { PodiumItem } from "./components/PodiumItem"; +import { RankingItem } from "./components/RankingItem"; +import { useQueryUserRanking } from "./hooks/useQueryUserRanking"; + +export function RankingPage() { + const [selectedOption, setSelectedOption] = useState<"week" | "month" | "year">("week"); + + const { + data: userRanking, + isPending: isPendingUserRanking, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useQueryUserRanking({ + duration: selectedOption, + }); + + const observerRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); + + const allRankingList = userRanking?.pages.flatMap((page) => page.data.rankingList) ?? []; + + const topThree = allRankingList.slice(0, 3); + const rest = allRankingList.slice(3); + + return ( + <> +
+ + + + setSelectedOption("week")}> + 주간 + + + setSelectedOption("month")}> + 월간 + + + setSelectedOption("year")}> + 연간 + + + {!isPendingUserRanking && ( + + + + + + )} + + + {!isPendingUserRanking && + rest.map((user, index) => ( + + ))} + + + {isFetchingNextPage && } + + + + + + ); +} + +const PageBody = styled.div({ + width: "100%", + height: "calc(100dvh - 9.8rem)", + display: "flex", + flexDirection: "column", +}); + +const PodiumContainer = styled.div({ + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "center", + + width: "100%", + height: rem(26), + + background: "linear-gradient(to bottom, #8745c7 0%, #520189 40%, #000000 100%)", +}); + +const PodiumItemWrapper = styled.div({ + position: "absolute", + top: rem(6.3), + width: "100%", + height: rem(15), +}); + +const RankingOptionButtonGroup = styled.div({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + + width: rem(22), + height: rem(2.7), + padding: `0 ${rem(0.3)}`, + marginTop: rem(1.2), + + borderRadius: rem(3), + backgroundColor: "rgba(75, 75, 75, 0.85)", +}); + +const RankingOptionButton = styled.button<{ isSelected: boolean }>(({ isSelected }) => ({ + flex: 1, + + display: "flex", + alignItems: "center", + justifyContent: "center", + + height: rem(2.1), + + borderRadius: rem(3), + + fontSize: rem(1), + fontWeight: 700, + color: "#ffffff", + + backgroundColor: isSelected ? "#A1A1A1" : "transparent", +})); + +const RankingList = styled.div({ + position: "absolute", + bottom: 0, + + display: "flex", + flexDirection: "column", + gap: rem(1.1), + + width: "100%", + height: "calc(100dvh - 29rem)", + padding: `${rem(2.3)} ${rem(1.3)}`, + overflowY: "auto", // 스크롤 가능하도록 설정 + + borderRadius: `${rem(1.5)} ${rem(1.5)} 0 0`, + backgroundColor: "#ffffff", +}); + +const Divider = styled.div({ + width: rem(0.1), + height: rem(2), + margin: `0 ${rem(0.3)}`, + backgroundColor: "#A1A1A1", +}); diff --git a/src/Router.tsx b/src/Router.tsx index 6e06e46..3091948 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -3,13 +3,19 @@ import { LoginPage } from "@/Login/page"; import MenuPage from "@/Menu/page"; import GlobalLayout from "@/common/components/GlobalLayout"; import { authLoader } from "@/loaders/authLoader"; -import { createBrowserRouter } from "react-router"; +import { Navigate, createBrowserRouter } from "react-router"; +import { RankingPage } from "./Ranking/page"; export const router = createBrowserRouter([ { path: "/", element: , children: [ + // 메인페이지 부재로 임시로 초기 / 접근시 강제 로그인 리다이렉트 구현 부분 + { + index: true, + element: , + }, { path: "login", element: , @@ -21,6 +27,10 @@ export const router = createBrowserRouter([ path: "menu", element: , }, + { + path: "ranking", + element: , + }, { path: "follow", element: , diff --git a/src/assets/icons/ic_follow.svg b/src/assets/icons/ic_follow.svg new file mode 100644 index 0000000..5bf073c --- /dev/null +++ b/src/assets/icons/ic_follow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ic_follow_active.svg b/src/assets/icons/ic_follow_active.svg new file mode 100644 index 0000000..c3ec93d --- /dev/null +++ b/src/assets/icons/ic_follow_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ic_ranking.svg b/src/assets/icons/ic_ranking.svg new file mode 100644 index 0000000..8aa5715 --- /dev/null +++ b/src/assets/icons/ic_ranking.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/ic_ranking_active.svg b/src/assets/icons/ic_ranking_active.svg new file mode 100644 index 0000000..e19ac46 --- /dev/null +++ b/src/assets/icons/ic_ranking_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index a59d6f3..36930ab 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -1,4 +1,8 @@ import IconGithubLogo from "./github_logo.svg?react"; +import IconFollow from "./ic_follow.svg?react"; +import IconFollowActive from "./ic_follow_active.svg?react"; +import IconRanking from "./ic_ranking.svg?react"; +import IconRankingActive from "./ic_ranking_active.svg?react"; import IconRefreshArrow from "./refresh_arrow.svg?react"; -export { IconGithubLogo, IconRefreshArrow }; +export { IconFollow, IconFollowActive, IconGithubLogo, IconRanking, IconRankingActive, IconRefreshArrow }; diff --git a/src/common/components/BottomNav.tsx b/src/common/components/BottomNav.tsx new file mode 100644 index 0000000..6391c7f --- /dev/null +++ b/src/common/components/BottomNav.tsx @@ -0,0 +1,69 @@ +import { IconFollow, IconFollowActive, IconRanking, IconRankingActive } from "@/assets/icons"; +import styled from "@emotion/styled"; +import { useLocation, useNavigate } from "react-router"; +import { colors } from "../styles/theme"; +import { rem } from "../utils/rem"; + +const navItems = [ + { + label: "팔로우", + path: "/follow", + icon: IconFollow, + activeIcon: IconFollowActive, + }, + { + label: "랭킹", + path: "/ranking", + icon: IconRanking, + activeIcon: IconRankingActive, + }, +]; + +export function BottomNav() { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + + {navItems.map((item) => { + const isActive = location.pathname === item.path; + + return ( + navigate(item.path)} type="button"> + {isActive ? : } + {item.label} + + ); + })} + + ); +} + +const BottomNavWrapper = styled.nav({ + position: "sticky", + bottom: 0, + + display: "flex", + justifyContent: "space-around", + alignItems: "center", + + width: "100%", + height: "5rem", + backgroundColor: "white", + + borderTop: `1px solid ${colors.gray2}`, +}); + +const BottomNavItem = styled.button({ + display: "flex", + flexDirection: "column", + alignItems: "center", + + gap: rem(0.3), +}); + +const BottomNavItemText = styled.span<{ active?: boolean }>(({ active }) => ({ + fontSize: rem(1), + fontWeight: 700, + color: active ? "#520189" : "#ABABAB", +}));