|
|
@ -1,9 +1,10 @@ |
|
|
|
import { useState } from 'react'; |
|
|
|
import { useState, type ReactNode } from 'react'; |
|
|
|
import type { |
|
|
|
import type { |
|
|
|
LeadeboardUserDetails, |
|
|
|
LeadeboardUserDetails, |
|
|
|
ListLeaderboardStatsResponse, |
|
|
|
ListLeaderboardStatsResponse, |
|
|
|
} from '../../api/leaderboard'; |
|
|
|
} from '../../api/leaderboard'; |
|
|
|
import { cn } from '../../lib/classname'; |
|
|
|
import { cn } from '../../lib/classname'; |
|
|
|
|
|
|
|
import { FolderKanban, Zap } from 'lucide-react'; |
|
|
|
|
|
|
|
|
|
|
|
type LeaderboardPageProps = { |
|
|
|
type LeaderboardPageProps = { |
|
|
|
stats: ListLeaderboardStatsResponse; |
|
|
|
stats: ListLeaderboardStatsResponse; |
|
|
@ -22,18 +23,32 @@ export function LeaderboardPage(props: LeaderboardPageProps) { |
|
|
|
Top users based on their activity on roadmap.sh |
|
|
|
Top users based on their activity on roadmap.sh |
|
|
|
</p> |
|
|
|
</p> |
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-8 grid grid-cols-2 gap-2"> |
|
|
|
<div className="mt-8 grid gap-2 md:grid-cols-2"> |
|
|
|
<LeaderboardLane |
|
|
|
<LeaderboardLane |
|
|
|
title="Most Streaks" |
|
|
|
title="Most Streaks" |
|
|
|
tabs={[{ title: 'All Time', users: stats.longestStreaks }]} |
|
|
|
tabs={[ |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
title: 'All Time', |
|
|
|
|
|
|
|
users: stats.longestStreaks, |
|
|
|
|
|
|
|
emptyIcon: <Zap className="size-16 text-gray-300" />, |
|
|
|
|
|
|
|
emptyText: 'No users with streaks yet', |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
]} |
|
|
|
/> |
|
|
|
/> |
|
|
|
<LeaderboardLane |
|
|
|
<LeaderboardLane |
|
|
|
title="Projects" |
|
|
|
title="Projects" |
|
|
|
tabs={[ |
|
|
|
tabs={[ |
|
|
|
{ title: 'Lifetime', users: stats.projectSubmissions.lifetime }, |
|
|
|
|
|
|
|
{ |
|
|
|
{ |
|
|
|
title: 'This Month', |
|
|
|
title: 'This Month', |
|
|
|
users: stats.projectSubmissions.currentMonth, |
|
|
|
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', |
|
|
|
}, |
|
|
|
}, |
|
|
|
]} |
|
|
|
]} |
|
|
|
/> |
|
|
|
/> |
|
|
@ -48,6 +63,8 @@ type LeaderboardLaneProps = { |
|
|
|
tabs: { |
|
|
|
tabs: { |
|
|
|
title: string; |
|
|
|
title: string; |
|
|
|
users: LeadeboardUserDetails[]; |
|
|
|
users: LeadeboardUserDetails[]; |
|
|
|
|
|
|
|
emptyIcon?: ReactNode; |
|
|
|
|
|
|
|
emptyText?: string; |
|
|
|
}[]; |
|
|
|
}[]; |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
@ -55,7 +72,7 @@ function LeaderboardLane(props: LeaderboardLaneProps) { |
|
|
|
const { title, tabs } = props; |
|
|
|
const { title, tabs } = props; |
|
|
|
|
|
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState(tabs[0]); |
|
|
|
const [activeTab, setActiveTab] = useState(tabs[0]); |
|
|
|
const usersToShow = activeTab.users; |
|
|
|
const { users: usersToShow, emptyIcon, emptyText } = activeTab; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
return ( |
|
|
|
<div className="rounded-md border bg-white shadow-sm"> |
|
|
|
<div className="rounded-md border bg-white shadow-sm"> |
|
|
@ -84,11 +101,20 @@ function LeaderboardLane(props: LeaderboardLaneProps) { |
|
|
|
)} |
|
|
|
)} |
|
|
|
</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"> |
|
|
|
<ul className="divide-y"> |
|
|
|
{usersToShow.map((user, counter) => { |
|
|
|
{usersToShow.map((user, counter) => { |
|
|
|
const avatar = user?.avatar |
|
|
|
const avatar = user?.avatar |
|
|
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` |
|
|
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` |
|
|
|
: '/images/default-avatar.png'; |
|
|
|
: '/images/default-avatar.png'; |
|
|
|
|
|
|
|
const rank = counter + 1; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
return ( |
|
|
|
<li |
|
|
|
<li |
|
|
@ -96,8 +122,13 @@ function LeaderboardLane(props: LeaderboardLaneProps) { |
|
|
|
className="flex items-center justify-between gap-1 p-2 px-4" |
|
|
|
className="flex items-center justify-between gap-1 p-2 px-4" |
|
|
|
> |
|
|
|
> |
|
|
|
<div className="flex min-w-0 items-center gap-2"> |
|
|
|
<div className="flex min-w-0 items-center gap-2"> |
|
|
|
<span className="flex size-7 shrink-0 items-center justify-center tabular-nums"> |
|
|
|
<span |
|
|
|
{counter + 1} |
|
|
|
className={cn( |
|
|
|
|
|
|
|
'flex size-7 shrink-0 items-center justify-center font-medium tabular-nums', |
|
|
|
|
|
|
|
rank <= 3 ? 'text-black' : 'text-gray-400', |
|
|
|
|
|
|
|
)} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
{rank} |
|
|
|
</span> |
|
|
|
</span> |
|
|
|
|
|
|
|
|
|
|
|
<img |
|
|
|
<img |
|
|
@ -113,6 +144,7 @@ function LeaderboardLane(props: LeaderboardLaneProps) { |
|
|
|
); |
|
|
|
); |
|
|
|
})} |
|
|
|
})} |
|
|
|
</ul> |
|
|
|
</ul> |
|
|
|
|
|
|
|
)} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
); |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|