mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-29 20:21:50 +02:00
feat: implement leaderboard page (#7063)
* feat: implement leaderboard page * feat: add empty and error pages * feat: add rank badge
This commit is contained in:
28
src/api/leaderboard.ts
Normal file
28
src/api/leaderboard.ts
Normal file
@@ -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`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
26
src/components/Leaderboard/ErrorPage.tsx
Normal file
26
src/components/Leaderboard/ErrorPage.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
165
src/components/Leaderboard/LeaderboardPage.tsx
Normal file
165
src/components/Leaderboard/LeaderboardPage.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
19
src/components/ReactIcons/RankBadgeIcon.tsx
Normal file
19
src/components/ReactIcons/RankBadgeIcon.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
21
src/pages/leaderboard.astro
Normal file
21
src/pages/leaderboard.astro
Normal file
@@ -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>
|
Reference in New Issue
Block a user