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