parent
dae737fa02
commit
b4edb07510
4 changed files with 193 additions and 0 deletions
@ -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…
Reference in new issue