From 88d783680be6accd5db3d7d88e397fbd4b49b204 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Thu, 12 Sep 2024 22:32:51 +0600 Subject: [PATCH] feat: implement leaderboard page (#7063) * feat: implement leaderboard page * feat: add empty and error pages * feat: add rank badge --- src/api/leaderboard.ts | 28 +++ src/components/Leaderboard/ErrorPage.tsx | 26 +++ .../Leaderboard/LeaderboardPage.tsx | 165 ++++++++++++++++++ src/components/ReactIcons/RankBadgeIcon.tsx | 19 ++ src/pages/leaderboard.astro | 21 +++ 5 files changed, 259 insertions(+) create mode 100644 src/api/leaderboard.ts create mode 100644 src/components/Leaderboard/ErrorPage.tsx create mode 100644 src/components/Leaderboard/LeaderboardPage.tsx create mode 100644 src/components/ReactIcons/RankBadgeIcon.tsx create mode 100644 src/pages/leaderboard.astro diff --git a/src/api/leaderboard.ts b/src/api/leaderboard.ts new file mode 100644 index 000000000..9048cb876 --- /dev/null +++ b/src/api/leaderboard.ts @@ -0,0 +1,28 @@ +import { type APIContext } from 'astro'; +import { api } from './api.ts'; + +export type LeadeboardUserDetails = { + id: string; + name: string; + avatar?: string; + count: number; +}; + +export type ListLeaderboardStatsResponse = { + longestStreaks: LeadeboardUserDetails[]; + projectSubmissions: { + currentMonth: LeadeboardUserDetails[]; + lifetime: LeadeboardUserDetails[]; + }; +}; + +export function leaderboardApi(context: APIContext) { + return { + listLeaderboardStats: async function () { + return api(context).get( + `${import.meta.env.PUBLIC_API_URL}/v1-list-leaderboard-stats`, + {}, + ); + }, + }; +} diff --git a/src/components/Leaderboard/ErrorPage.tsx b/src/components/Leaderboard/ErrorPage.tsx new file mode 100644 index 000000000..edbc7915c --- /dev/null +++ b/src/components/Leaderboard/ErrorPage.tsx @@ -0,0 +1,26 @@ +import type { AppError } from '../../api/api'; +import { ErrorIcon } from '../ReactIcons/ErrorIcon'; + +type ErrorPageProps = { + error: AppError; +}; + +export function ErrorPage(props: ErrorPageProps) { + const { error } = props; + + return ( +
+
+
+ +

+ Oops! Something went wrong +

+

+ {error?.message || 'An error occurred while fetching'} +

+
+
+
+ ); +} diff --git a/src/components/Leaderboard/LeaderboardPage.tsx b/src/components/Leaderboard/LeaderboardPage.tsx new file mode 100644 index 000000000..944ef57d6 --- /dev/null +++ b/src/components/Leaderboard/LeaderboardPage.tsx @@ -0,0 +1,165 @@ +import { useState, type ReactNode } from 'react'; +import type { + LeadeboardUserDetails, + ListLeaderboardStatsResponse, +} from '../../api/leaderboard'; +import { cn } from '../../lib/classname'; +import { FolderKanban, Zap } from 'lucide-react'; +import { RankBadeIcon } from '../ReactIcons/RankBadgeIcon'; + +type LeaderboardPageProps = { + stats: ListLeaderboardStatsResponse; +}; + +export function LeaderboardPage(props: LeaderboardPageProps) { + const { stats } = props; + + return ( +
+
+

+ Leaderboard +

+

+ Top users based on their activity on roadmap.sh +

+ +
+ , + emptyText: 'No users with streaks yet', + }, + ]} + /> + , + emptyText: 'No projects submitted this month', + }, + { + title: 'Lifetime', + users: stats.projectSubmissions.lifetime, + emptyIcon: , + emptyText: 'No projects submitted yet', + }, + ]} + /> +
+
+
+ ); +} + +type LeaderboardLaneProps = { + title: string; + tabs: { + title: string; + users: LeadeboardUserDetails[]; + emptyIcon?: ReactNode; + emptyText?: string; + }[]; +}; + +function LeaderboardLane(props: LeaderboardLaneProps) { + const { title, tabs } = props; + + const [activeTab, setActiveTab] = useState(tabs[0]); + const { users: usersToShow, emptyIcon, emptyText } = activeTab; + + return ( +
+
+

{title}

+ + {tabs.length > 1 && ( +
+ {tabs.map((tab) => { + const isActive = tab === activeTab; + + return ( + + ); + })} +
+ )} +
+ + {usersToShow.length === 0 && emptyText && ( +
+ {emptyIcon} +

{emptyText}

+
+ )} + + {usersToShow.length > 0 && ( +
    + {usersToShow.map((user, counter) => { + const avatar = user?.avatar + ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` + : '/images/default-avatar.png'; + const rank = counter + 1; + + return ( +
  • +
    + 3 && 'text-gray-400', + )} + > + {rank} + + {rank <= 3 && ( + + )} + + + {user.name} + {user.name} +
    + + {user.count} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/ReactIcons/RankBadgeIcon.tsx b/src/components/ReactIcons/RankBadgeIcon.tsx new file mode 100644 index 000000000..75170b391 --- /dev/null +++ b/src/components/ReactIcons/RankBadgeIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export function RankBadeIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/src/pages/leaderboard.astro b/src/pages/leaderboard.astro new file mode 100644 index 000000000..c7bf8f95f --- /dev/null +++ b/src/pages/leaderboard.astro @@ -0,0 +1,21 @@ +--- +import { LeaderboardPage } from '../components/Leaderboard/LeaderboardPage'; +import { ErrorPage } from '../components/Leaderboard/ErrorPage'; +import BaseLayout from '../layouts/BaseLayout.astro'; +import { leaderboardApi } from '../api/leaderboard'; + +export const prerender = false; + +const leaderboardClient = leaderboardApi(Astro); +const { response: leaderboardStats, error: leaderboardError } = + await leaderboardClient.listLeaderboardStats(); +--- + + + {leaderboardError && } + { + leaderboardStats && ( + + ) + } +