Add leaderboard page

pull/7102/head
Kamran Ahmed 2 months ago
parent 88d783680b
commit 851a0381b6
  1. 2
      src/components/HeroSection/HeroSection.astro
  2. 135
      src/components/Leaderboard/LeaderboardPage.tsx
  3. 2
      src/components/ReactIcons/RankBadgeIcon.tsx
  4. 25
      src/components/ReactIcons/SecondPlaceMedalEmoji.tsx
  5. 25
      src/components/ReactIcons/ThirdPlaceMedalEmoji.tsx
  6. 31
      src/components/ReactIcons/TrophyEmoji.tsx

@ -1,5 +1,4 @@
--- ---
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
import { FeatureAnnouncement } from "../FeatureAnnouncement"; import { FeatureAnnouncement } from "../FeatureAnnouncement";
--- ---
@ -31,5 +30,4 @@ import { FeatureAnnouncement } from "../FeatureAnnouncement";
their career. their career.
</p> </p>
</div> </div>
<FavoriteRoadmaps client:only='react' />
</div> </div>

@ -4,8 +4,11 @@ import type {
ListLeaderboardStatsResponse, ListLeaderboardStatsResponse,
} from '../../api/leaderboard'; } from '../../api/leaderboard';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { FolderKanban, Zap } from 'lucide-react'; import { FolderKanban, Zap, Trophy } from 'lucide-react';
import { RankBadeIcon } from '../ReactIcons/RankBadgeIcon'; import { RankBadgeIcon } from '../ReactIcons/RankBadgeIcon';
import { TrophyEmoji } from '../ReactIcons/TrophyEmoji';
import { SecondPlaceMedalEmoji } from '../ReactIcons/SecondPlaceMedalEmoji';
import { ThirdPlaceMedalEmoji } from '../ReactIcons/ThirdPlaceMedalEmoji';
type LeaderboardPageProps = { type LeaderboardPageProps = {
stats: ListLeaderboardStatsResponse; stats: ListLeaderboardStatsResponse;
@ -17,42 +20,45 @@ export function LeaderboardPage(props: LeaderboardPageProps) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="container py-10"> <div className="container py-10">
<h2 className="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-3xl"> <div className="mb-8 text-center">
Leaderboard <div className="mb-2 flex items-center justify-center gap-3">
</h2> <Trophy className="size-8 text-yellow-500" />
<p className="text-balance text-sm text-gray-500 sm:text-base"> <h2 className="text-2xl font-bold sm:text-3xl">Leaderboard</h2>
Top users based on their activity on roadmap.sh </div>
</p> <p className="mx-auto max-w-2xl text-balance text-sm text-gray-500 sm:text-base">
Top users based on their activity on roadmap.sh
<div className="mt-8 grid gap-2 md:grid-cols-2"> </p>
<LeaderboardLane
title="Longest Visit Streak" <div className="mt-8 grid gap-2 md:grid-cols-2">
tabs={[ <LeaderboardLane
{ title="Longest Visit Streak"
title: 'All Time', tabs={[
users: stats.longestStreaks, {
emptyIcon: <Zap className="size-16 text-gray-300" />, title: 'All Time',
emptyText: 'No users with streaks yet', users: stats.longestStreaks,
}, emptyIcon: <Zap className="size-16 text-gray-300" />,
]} emptyText: 'No users with streaks yet',
/> },
<LeaderboardLane ]}
title="Projects Completed" />
tabs={[ <LeaderboardLane
{ title="Projects Completed"
title: 'This Month', tabs={[
users: stats.projectSubmissions.currentMonth, {
emptyIcon: <FolderKanban className="size-16 text-gray-300" />, title: 'This Month',
emptyText: 'No projects submitted 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" />, title: 'Lifetime',
emptyText: 'No projects submitted yet', users: stats.projectSubmissions.lifetime,
}, emptyIcon: <FolderKanban className="size-16 text-gray-300" />,
]} emptyText: 'No projects submitted yet',
/> },
]}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -76,12 +82,12 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
const { users: usersToShow, emptyIcon, emptyText } = activeTab; const { users: usersToShow, emptyIcon, emptyText } = activeTab;
return ( return (
<div className="rounded-md border bg-white shadow-sm"> <div className="overflow-hidden rounded-md border bg-white shadow-sm">
<div className="flex items-center justify-between gap-2 border-b px-4 py-2"> <div className="flex items-center justify-between gap-2 bg-gray-100 px-3 py-2 mb-3">
<h2 className="text-lg font-medium">{title}</h2> <h3 className="text-base text-sm font-medium">{title}</h3>
{tabs.length > 1 && ( {tabs.length > 1 && (
<div className="flex items-center overflow-hidden rounded-md border"> <div className="flex items-center gap-2">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab === activeTab; const isActive = tab === activeTab;
@ -90,8 +96,11 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
key={tab.title} key={tab.title}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={cn( className={cn(
'px-2 py-0.5 text-sm text-gray-500 hover:bg-gray-100', 'text-xs font-medium underline-offset-2 transition-colors',
isActive ? 'bg-gray-200 text-black' : 'bg-white', {
'text-black underline': isActive,
'text-gray-400 hover:text-gray-600': !isActive,
},
)} )}
> >
{tab.title} {tab.title}
@ -110,7 +119,7 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
)} )}
{usersToShow.length > 0 && ( {usersToShow.length > 0 && (
<ul className="divide-y"> <ul className="divide-y divide-gray-100">
{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}`
@ -120,38 +129,36 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
return ( return (
<li <li
key={user.id} key={user.id}
className="flex items-center justify-between gap-1 p-2 px-4" className="flex items-center justify-between gap-1 pl-2 pr-5 py-2.5 hover:bg-gray-50"
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
className={cn( className={cn(
'relative flex size-7 shrink-0 items-center justify-center rounded-full font-medium tabular-nums', 'relative text-xs mr-1 flex size-6 shrink-0 items-center justify-center rounded-full tabular-nums',
rank === 1 && 'bg-yellow-500 text-white', {
rank === 2 && 'bg-gray-500 text-white', 'text-black': rank <= 3,
rank === 3 && 'bg-yellow-800 text-white', 'text-gray-400': rank > 3,
rank > 3 && 'text-gray-400', },
)} )}
> >
<span className="relative z-10">{rank}</span> {rank}
{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> </span>
<img <img
src={avatar} src={avatar}
alt={user.name} alt={user.name}
className="size-8 shrink-0 rounded-full" className="size-7 shrink-0 rounded-full"
/> />
<span className="truncate">{user.name}</span> <span className="truncate">{user.name}</span>
{rank === 1 ? (
<TrophyEmoji className="size-5" />
) : rank === 2 ? (
<SecondPlaceMedalEmoji className="size-5" />
) : rank === 3 ? (
<ThirdPlaceMedalEmoji className="size-5" />
) : (
''
)}
</div> </div>
<span className="text-sm text-gray-500">{user.count}</span> <span className="text-sm text-gray-500">{user.count}</span>

@ -1,6 +1,6 @@
import type { SVGProps } from 'react'; import type { SVGProps } from 'react';
export function RankBadeIcon(props: SVGProps<SVGSVGElement>) { export function RankBadgeIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
width="11" width="11"

@ -0,0 +1,25 @@
import React from 'react';
import type { SVGProps } from 'react';
export function SecondPlaceMedalEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path fill="#55acee" d="m18 8l-7-8H0l14 17l11.521-4.75z"></path>
<path fill="#3b88c3" d="m25 0l-7 8l5.39 7.312l1.227-1.489L36 0z"></path>
<path
fill="#ccd6dd"
d="M23.205 16.026c.08-.217.131-.448.131-.693a2 2 0 0 0-2-2h-6.667a2 2 0 0 0-2 2c0 .245.05.476.131.693c-3.258 1.826-5.464 5.307-5.464 9.307C7.335 31.224 12.111 36 18.002 36s10.667-4.776 10.667-10.667c0-4-2.206-7.481-5.464-9.307"
></path>
<path
fill="#627077"
d="M22.002 28.921h-3.543c.878-1.234 2.412-3.234 3.01-4.301c.449-.879.729-1.439.729-2.43c0-2.076-1.57-3.777-4.244-3.777c-2.225 0-3.74 1.832-3.74 1.832c-.131.15-.112.374.019.487l1.141 1.159a.36.36 0 0 0 .523 0c.355-.393 1.047-.935 1.813-.935c1.047 0 1.646.635 1.646 1.346c0 .523-.243 1.047-.486 1.421c-1.104 1.682-3.871 5.441-4.955 6.862v.374c0 .188.149.355.355.355h7.732a.37.37 0 0 0 .355-.355v-1.682a.367.367 0 0 0-.355-.356"
></path>
</svg>
);
}

@ -0,0 +1,25 @@
import React from 'react';
import type { SVGProps } from 'react';
export function ThirdPlaceMedalEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path fill="#55ACEE" d="m18 8l-7-8H0l14 17l11.521-4.75z"></path>
<path fill="#3B88C3" d="m25 0l-7 8l5.39 7.312l1.227-1.489L36 0z"></path>
<path
fill="#FF8A3B"
d="M23.205 16.026c.08-.217.131-.448.131-.693a2 2 0 0 0-2-2h-6.667a2 2 0 0 0-2 2c0 .245.05.476.131.693c-3.258 1.826-5.464 5.307-5.464 9.307C7.335 31.224 12.111 36 18.002 36s10.667-4.776 10.667-10.667c0-4-2.206-7.481-5.464-9.307"
></path>
<path
fill="#7C4119"
d="m14.121 29.35l1.178-1.178a.345.345 0 0 1 .467-.038s1.159.861 2.056.861c.805 0 1.628-.673 1.628-1.496s-.842-1.514-2.225-1.514h-.639a.367.367 0 0 1-.354-.355v-1.552c0-.206.168-.355.354-.355h.639c1.309 0 2-.635 2-1.439c0-.805-.691-1.402-1.496-1.402c-.823 0-1.346.43-1.626.747c-.132.15-.355.15-.504.02l-1.141-1.122c-.151-.132-.132-.355 0-.486c0 0 1.533-1.646 3.57-1.646c2.169 0 4.039 1.328 4.039 3.422c0 1.439-1.085 2.505-1.926 2.897v.057c.879.374 2.262 1.533 2.262 3.141c0 2.038-1.776 3.572-4.357 3.572c-2.354 0-3.552-1.16-3.944-1.664c-.113-.134-.093-.34.019-.47"
></path>
</svg>
);
}

@ -0,0 +1,31 @@
import React from 'react';
import type { SVGProps } from 'react';
export function TrophyEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path
fill="#ffac33"
d="M5.123 5h6C12.227 5 13 4.896 13 6V4c0-1.104-.773-2-1.877-2h-8c-2 0-3.583 2.125-3 5c0 0 1.791 9.375 1.917 9.958C2.373 18.5 4.164 20 6.081 20h6.958c1.105 0-.039-1.896-.039-3v-2c0 1.104-.773 2-1.877 2h-4c-1.104 0-1.833-1.042-2-2S3.539 7.667 3.539 7.667C3.206 5.75 4.018 5 5.123 5m25.812 0h-6C23.831 5 22 4.896 22 6V4c0-1.104 1.831-2 2.935-2h8c2 0 3.584 2.125 3 5c0 0-1.633 9.419-1.771 10c-.354 1.5-2.042 3-4 3h-7.146C21.914 20 22 18.104 22 17v-2c0 1.104 1.831 2 2.935 2h4c1.104 0 1.834-1.042 2-2s1.584-7.333 1.584-7.333C32.851 5.75 32.04 5 30.935 5M20.832 22c0-6.958-2.709 0-2.709 0s-3-6.958-3 0s-3.291 10-3.291 10h12.292c-.001 0-3.292-3.042-3.292-10"
></path>
<path
fill="#ffcc4d"
d="M29.123 6.577c0 6.775-6.77 18.192-11 18.192s-11-11.417-11-18.192c0-5.195 1-6.319 3-6.319c1.374 0 6.025-.027 8-.027l7-.001c2.917-.001 4 .684 4 6.347"
></path>
<path
fill="#c1694f"
d="M27 33c0 1.104.227 2-.877 2h-16C9.018 35 9 34.104 9 33v-1c0-1.104 1.164-2 2.206-2h13.917c1.042 0 1.877.896 1.877 2z"
></path>
<path
fill="#c1694f"
d="M29 34.625c0 .76.165 1.375-1.252 1.375H8.498C7.206 36 7 35.385 7 34.625v-.25C7 33.615 7.738 33 8.498 33h19.25c.759 0 1.252.615 1.252 1.375z"
></path>
</svg>
);
}
Loading…
Cancel
Save