feat: implement leaderboard page (#7063)
* feat: implement leaderboard page * feat: add empty and error pages * feat: add rank badgepull/7102/head
parent
a1aba2e026
commit
88d783680b
5 changed files with 259 additions and 0 deletions
@ -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<ListLeaderboardStatsResponse>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-list-leaderboard-stats`, |
||||||
|
{}, |
||||||
|
); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -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 ( |
||||||
|
<div className="min-h-screen bg-gray-50"> |
||||||
|
<div className="container py-10"> |
||||||
|
<div className="flex min-h-[250px] flex-col items-center justify-center px-5 py-3 sm:px-0 sm:py-20"> |
||||||
|
<ErrorIcon additionalClasses="mb-4 h-8 w-8 sm:h-14 sm:w-14" /> |
||||||
|
<h2 className="mb-1 text-lg font-semibold sm:text-xl"> |
||||||
|
Oops! Something went wrong |
||||||
|
</h2> |
||||||
|
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm"> |
||||||
|
{error?.message || 'An error occurred while fetching'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<div className="min-h-screen bg-gray-50"> |
||||||
|
<div className="container py-10"> |
||||||
|
<h2 className="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-3xl"> |
||||||
|
Leaderboard |
||||||
|
</h2> |
||||||
|
<p className="text-balance text-sm text-gray-500 sm:text-base"> |
||||||
|
Top users based on their activity on roadmap.sh |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-8 grid gap-2 md:grid-cols-2"> |
||||||
|
<LeaderboardLane |
||||||
|
title="Longest Visit Streak" |
||||||
|
tabs={[ |
||||||
|
{ |
||||||
|
title: 'All Time', |
||||||
|
users: stats.longestStreaks, |
||||||
|
emptyIcon: <Zap className="size-16 text-gray-300" />, |
||||||
|
emptyText: 'No users with streaks yet', |
||||||
|
}, |
||||||
|
]} |
||||||
|
/> |
||||||
|
<LeaderboardLane |
||||||
|
title="Projects Completed" |
||||||
|
tabs={[ |
||||||
|
{ |
||||||
|
title: 'This Month', |
||||||
|
users: stats.projectSubmissions.currentMonth, |
||||||
|
emptyIcon: <FolderKanban className="size-16 text-gray-300" />, |
||||||
|
emptyText: 'No projects submitted this month', |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Lifetime', |
||||||
|
users: stats.projectSubmissions.lifetime, |
||||||
|
emptyIcon: <FolderKanban className="size-16 text-gray-300" />, |
||||||
|
emptyText: 'No projects submitted yet', |
||||||
|
}, |
||||||
|
]} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
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 ( |
||||||
|
<div className="rounded-md border bg-white shadow-sm"> |
||||||
|
<div className="flex items-center justify-between gap-2 border-b px-4 py-2"> |
||||||
|
<h2 className="text-lg font-medium">{title}</h2> |
||||||
|
|
||||||
|
{tabs.length > 1 && ( |
||||||
|
<div className="flex items-center overflow-hidden rounded-md border"> |
||||||
|
{tabs.map((tab) => { |
||||||
|
const isActive = tab === activeTab; |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={tab.title} |
||||||
|
onClick={() => setActiveTab(tab)} |
||||||
|
className={cn( |
||||||
|
'px-2 py-0.5 text-sm text-gray-500 hover:bg-gray-100', |
||||||
|
isActive ? 'bg-gray-200 text-black' : 'bg-white', |
||||||
|
)} |
||||||
|
> |
||||||
|
{tab.title} |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{usersToShow.length === 0 && emptyText && ( |
||||||
|
<div className="flex flex-col items-center justify-center p-8"> |
||||||
|
{emptyIcon} |
||||||
|
<p className="mt-4 text-sm text-gray-500">{emptyText}</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{usersToShow.length > 0 && ( |
||||||
|
<ul className="divide-y"> |
||||||
|
{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 ( |
||||||
|
<li |
||||||
|
key={user.id} |
||||||
|
className="flex items-center justify-between gap-1 p-2 px-4" |
||||||
|
> |
||||||
|
<div className="flex min-w-0 items-center gap-2"> |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'relative flex size-7 shrink-0 items-center justify-center rounded-full font-medium tabular-nums', |
||||||
|
rank === 1 && 'bg-yellow-500 text-white', |
||||||
|
rank === 2 && 'bg-gray-500 text-white', |
||||||
|
rank === 3 && 'bg-yellow-800 text-white', |
||||||
|
rank > 3 && 'text-gray-400', |
||||||
|
)} |
||||||
|
> |
||||||
|
<span className="relative z-10">{rank}</span> |
||||||
|
|
||||||
|
{rank <= 3 && ( |
||||||
|
<RankBadeIcon |
||||||
|
className={cn( |
||||||
|
'absolute left-1/2 top-5 size-4 -translate-x-1/2', |
||||||
|
rank === 1 && 'text-yellow-500', |
||||||
|
rank === 2 && 'text-gray-500', |
||||||
|
rank === 3 && 'text-yellow-800', |
||||||
|
)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
|
||||||
|
<img |
||||||
|
src={avatar} |
||||||
|
alt={user.name} |
||||||
|
className="size-8 shrink-0 rounded-full" |
||||||
|
/> |
||||||
|
<span className="truncate">{user.name}</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<span className="text-sm text-gray-500">{user.count}</span> |
||||||
|
</li> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import type { SVGProps } from 'react'; |
||||||
|
|
||||||
|
export function RankBadeIcon(props: SVGProps<SVGSVGElement>) { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
width="11" |
||||||
|
height="11" |
||||||
|
viewBox="0 0 11 11" |
||||||
|
fill="none" |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M0 0L11 0V10.0442L5.73392 6.32786L0 10.0442L0 0Z" |
||||||
|
fill="currentColor" |
||||||
|
></path> |
||||||
|
</svg> |
||||||
|
); |
||||||
|
} |
@ -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(); |
||||||
|
--- |
||||||
|
|
||||||
|
<BaseLayout title='Leaderboard'> |
||||||
|
{leaderboardError && <ErrorPage error={leaderboardError} />} |
||||||
|
{ |
||||||
|
leaderboardStats && ( |
||||||
|
<LeaderboardPage stats={leaderboardStats!} client:load /> |
||||||
|
) |
||||||
|
} |
||||||
|
</BaseLayout> |
Loading…
Reference in new issue