feat: implement leaderboard page (#7063)

* feat: implement leaderboard page

* feat: add empty and error pages

* feat: add rank badge
pull/7102/head
Arik Chakma 1 month ago committed by GitHub
parent a1aba2e026
commit 88d783680b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 28
      src/api/leaderboard.ts
  2. 26
      src/components/Leaderboard/ErrorPage.tsx
  3. 165
      src/components/Leaderboard/LeaderboardPage.tsx
  4. 19
      src/components/ReactIcons/RankBadgeIcon.tsx
  5. 21
      src/pages/leaderboard.astro

@ -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…
Cancel
Save