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