feat: implement leaderboard page

feat/leaderboard
Arik Chakma 3 months ago
parent dae737fa02
commit b4edb07510
  1. 28
      src/api/leaderboard.ts
  2. 26
      src/components/Leaderboard/ErrorPage.tsx
  3. 118
      src/components/Leaderboard/LeaderboardPage.tsx
  4. 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,118 @@
import { useState } from 'react';
import type {
LeadeboardUserDetails,
ListLeaderboardStatsResponse,
} from '../../api/leaderboard';
import { cn } from '../../lib/classname';
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 grid-cols-2 gap-2">
<LeaderboardLane
title="Most Streaks"
tabs={[{ title: 'All Time', users: stats.longestStreaks }]}
/>
<LeaderboardLane
title="Projects"
tabs={[
{ title: 'Lifetime', users: stats.projectSubmissions.lifetime },
{
title: 'This Month',
users: stats.projectSubmissions.currentMonth,
},
]}
/>
</div>
</div>
</div>
);
}
type LeaderboardLaneProps = {
title: string;
tabs: {
title: string;
users: LeadeboardUserDetails[];
}[];
};
function LeaderboardLane(props: LeaderboardLaneProps) {
const { title, tabs } = props;
const [activeTab, setActiveTab] = useState(tabs[0]);
const usersToShow = activeTab.users;
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>
<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';
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="flex size-7 shrink-0 items-center justify-center tabular-nums">
{counter + 1}
</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,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