Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/app/[locale]/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { getLeaderboardData } from '@/db/queries/leaderboard';
import LeaderboardClient from '@/components/leaderboard/LeaderboardClient';
import { Metadata } from 'next';

export const metadata: Metadata = {
title: 'Leaderboard | DevLovers',
description: 'Top performers of the community',
};

export const revalidate = 3600;

Expand Down
2 changes: 1 addition & 1 deletion frontend/components/header/AppMobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function AppMobileMenu({
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Toggle menu"
aria-expanded={open ? 'true' : 'false'}
aria-controls="app-mobile-nav"
aria-controls={open ? 'app-mobile-nav' : undefined}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
Expand Down
70 changes: 34 additions & 36 deletions frontend/components/leaderboard/LeaderboardClient.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
'use client';

import { useState } from 'react';
import { LeaderboardTabs } from './LeaderboardTabs';
import { useTranslations } from 'next-intl';
import { LeaderboardPodium } from './LeaderboardPodium';
import { LeaderboardTable } from './LeaderboardTable';
import { User } from './types';
import { Trophy } from 'lucide-react';

interface LeaderboardClientProps {
initialUsers: User[];
Expand All @@ -14,7 +12,7 @@ interface LeaderboardClientProps {
export default function LeaderboardClient({
initialUsers,
}: LeaderboardClientProps) {
const [activeTab, setActiveTab] = useState('Overall');
const t = useTranslations('leaderboard');

const usersWithPoints = initialUsers.filter(user => user.points > 0);
const hasResults = usersWithPoints.length > 0;
Expand All @@ -32,60 +30,60 @@ export default function LeaderboardClient({
className="pointer-events-none absolute inset-0 opacity-70 -z-10"
aria-hidden="true"
>
<div className="absolute -top-32 left-1/2 h-96 w-[36rem] -translate-x-1/2 rounded-full bg-sky-300/30 blur-3xl dark:bg-sky-500/20" />
<div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-pink-300/30 blur-3xl dark:bg-fuchsia-500/20" />
<div className="absolute bottom-[-10rem] right-0 h-[26rem] w-[26rem] rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-500/20" />
<div className="absolute -top-32 -left-32 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50 animate-blob" />
<div className="absolute top-1/4 -right-32 w-80 h-80 bg-sky-200 dark:bg-sky-900/20 rounded-full blur-3xl opacity-50 animate-blob animation-delay-2000" />
<div className="absolute -bottom-32 left-1/3 w-96 h-96 bg-pink-200 dark:bg-pink-900/20 rounded-full blur-3xl opacity-50 animate-blob animation-delay-4000" />
</div>

<main className="relative max-w-4xl mx-auto px-4 py-12 flex flex-col items-center z-10">
<header className="mb-10 text-center">
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight flex items-center justify-center gap-3 mb-4 drop-shadow-sm">
<Trophy
className="w-10 h-10 md:w-12 md:h-12 text-yellow-500 fill-yellow-500"
strokeWidth={1.5}
aria-hidden="true"
/>
<span className="bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300 bg-clip-text text-transparent">
Leaderboard
<div className="relative max-w-4xl mx-auto px-4 py-12 flex flex-col items-center z-10">
<header className="text-center mb-10 animate-in fade-in slide-in-from-top-4 duration-700">
<div className="inline-flex items-center justify-center p-3 mb-6 rounded-full bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm shadow-sm ring-1 ring-slate-200/50 dark:ring-slate-700/50">
<span className="text-2xl mr-2" role="img" aria-label="Trophy">
🏆
</span>
<span className="text-sm font-semibold text-slate-600 dark:text-slate-300 uppercase tracking-wider">
{t('championsArena')}
</span>
Comment thread
AlinaRyabova marked this conversation as resolved.
</div>

<h1 className="text-5xl md:text-7xl font-black mb-6 tracking-tighter drop-shadow-sm">
<span className="bg-gradient-to-r from-sky-500 via-indigo-500 to-pink-500 bg-clip-text text-transparent">
{t('title')}
</span>
</h1>
<p className="text-slate-600 dark:text-slate-400 font-medium text-lg">
Top performers of the community.

<p className="text-slate-600 dark:text-slate-400 font-medium text-lg max-w-md mx-auto leading-relaxed">
{t('subtitle')}
</p>
</header>

<section className="mb-10 w-full flex justify-center">
<LeaderboardTabs activeTab={activeTab} onTabChange={setActiveTab} />
</section>

<section
className="w-full flex flex-col items-center"
role="tabpanel"
aria-label={`Results for ${activeTab}`}
>
<div className="w-full mb-12">
<div className="w-full flex flex-col items-center">
<div className="w-full mb-16">
{hasResults ? (
<LeaderboardPodium topThree={topThree} />
) : (
<div className="text-center py-16" role="status">
<p className="text-6xl mb-4" aria-hidden="true">
<p
className="text-6xl mb-4 grayscale opacity-50"
aria-hidden="true"
>
🏆
</p>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2">
No results yet
{t('noResults')}
</h2>
<p className="text-slate-600 dark:text-slate-400">
Be the first to complete a quiz and claim the top spot!
{t('beFirst')}
</p>
</div>
)}
</div>

<div className="w-full animate-in fade-in slide-in-from-bottom-8 duration-700">
<LeaderboardTable users={otherUsers} />
<div className="w-full animate-in fade-in slide-in-from-bottom-8 duration-700 delay-200">
{hasResults && <LeaderboardTable users={otherUsers} />}
</div>
</section>
</main>
</div>
</div>
</div>
);
}
93 changes: 41 additions & 52 deletions frontend/components/leaderboard/LeaderboardPodium.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import Image from 'next/image';
import { Crown } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { User } from './types';

export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
const t = useTranslations('leaderboard');

return (
<ol
className="flex items-end justify-center gap-4 md:gap-8 pb-4 pt-8 min-h-[350px] list-none m-0 p-0"
aria-label="Top 3 Leaders"
className="flex items-end justify-center gap-2 md:gap-8 pb-4 pt-16 min-h-[340px] list-none m-0 p-0"
aria-label={t('topThreeLabel')}
>
{topThree.map(user => {
if (!user) return null;
Expand All @@ -21,95 +24,81 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) {
<li
key={user.id}
className={cn(
'flex flex-col items-center transition-all duration-500 relative',

isFirst ? 'order-2 z-10 -mt-8' : 'z-0',
'flex flex-col items-center transition-all duration-500 relative z-0',
isFirst ? 'order-2 z-10 -mt-8 md:-mt-12' : '',
isSecond ? 'order-1' : '',
isThird ? 'order-3' : ''
)}
>
<article className="flex flex-col items-center">
<div className="relative mb-4 group cursor-pointer">
<div className="flex flex-col items-center group">
<div className="relative mb-3 md:mb-5 transition-transform duration-300 group-hover:scale-105">
{isFirst && (
<Crown
className="absolute -top-12 left-1/2 -translate-x-1/2 w-10 h-10 text-yellow-500 fill-yellow-500 animate-bounce drop-shadow-lg"
strokeWidth={1.5}
className="absolute -top-10 md:-top-12 left-1/2 -translate-x-1/2 w-10 h-10 md:w-12 md:h-12 text-yellow-400 animate-bounce drop-shadow-[0_0_15px_rgba(250,204,21,0.5)]"
aria-hidden="true"
/>
)}

<div
className={cn(
'relative flex items-center justify-center rounded-full overflow-hidden shadow-2xl transition-transform duration-300 group-hover:scale-105 border-4',
'relative rounded-full p-[3px] shadow-2xl',
isFirst
? 'w-28 h-28 border-yellow-400 dark:border-yellow-500'
: '',
isSecond
? 'w-20 h-20 border-slate-300 dark:border-slate-500'
: '',
isThird
? 'w-20 h-20 border-orange-300 dark:border-orange-400'
: '',
'bg-white dark:bg-slate-800'
? 'bg-gradient-to-tr from-yellow-300 via-yellow-100 to-yellow-500'
: isSecond
? 'bg-gradient-to-tr from-slate-300 via-slate-100 to-slate-400'
: 'bg-gradient-to-tr from-orange-300 via-orange-100 to-orange-400'
)}
>
{user.avatar ? (
<div className="relative w-16 h-16 md:w-24 md:h-24 rounded-full overflow-hidden border-4 border-white dark:border-slate-900 bg-slate-200">
<Image
src={user.avatar}
alt={`${user.username}'s avatar`}
width={64}
height={64}
className="w-full h-full object-cover"
fill
className="object-cover"
/>
) : (
<span className="text-2xl font-bold text-slate-700 dark:text-slate-200">
{user.username.slice(0, 2).toUpperCase()}
</span>
)}
</div>
</div>

<div
className={cn(
'absolute -bottom-3 left-1/2 -translate-x-1/2 flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold border-2 border-white dark:border-slate-900 shadow-md',
isFirst ? 'bg-yellow-500 text-white' : '',
isSecond ? 'bg-slate-400 text-white' : '',
isThird ? 'bg-orange-400 text-white' : ''
'absolute -bottom-3 left-1/2 -translate-x-1/2 flex items-center justify-center w-7 h-7 md:w-9 md:h-9 rounded-full font-bold text-xs md:text-sm border-[3px] border-white dark:border-slate-900 shadow-lg',
isFirst
? 'bg-yellow-500 text-white'
: isSecond
? 'bg-slate-400 text-white'
: 'bg-orange-400 text-white'
)}
aria-label={`Rank ${user.rank}`}
>
{user.rank}
</div>
</div>

<div className="text-center mb-3">
<h3 className="font-bold text-slate-800 dark:text-slate-100 text-base mb-1 truncate max-w-[120px]">
<div className="text-center mb-3 md:mb-5">
<div className="font-bold text-slate-800 dark:text-slate-100 text-xs md:text-lg mb-1 truncate max-w-[85px] md:max-w-[140px]">
{user.username}
</h3>
<div className="font-mono font-bold text-xl text-slate-900 dark:text-white tracking-tight">
{user.points}{' '}
<span className="text-xs font-normal text-slate-500">
pts
</span>
</div>
<div className="inline-block px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<div className="font-mono font-bold text-xs md:text-sm text-slate-700 dark:text-slate-300">
{user.points}{' '}
<span className="text-slate-400">{t('pts')}</span>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<div
aria-hidden="true"
className={cn(
'w-24 md:w-36 rounded-t-2xl border-x border-t shadow-lg backdrop-blur-sm',
'bg-gradient-to-b from-white/80 via-white/40 to-transparent dark:from-slate-800/80 dark:via-slate-800/40',
'w-20 md:w-40 rounded-t-2xl backdrop-blur-xl transition-all duration-300',
'border-t-2 border-x-2 border-white/60 dark:border-white/10',

isFirst
? 'h-40 border-yellow-400/30 dark:border-yellow-500/30'
: '',
isSecond
? 'h-24 border-slate-300/30 dark:border-slate-500/30'
: '',
isThird
? 'h-16 border-orange-300/30 dark:border-orange-500/30'
: ''
? 'h-40 md:h-56 bg-gradient-to-b from-yellow-200/30 to-yellow-500/5 dark:from-yellow-400/20 dark:to-transparent shadow-[0_0_50px_-10px_rgba(234,179,8,0.4)]'
: isSecond
? 'h-28 md:h-40 bg-gradient-to-b from-slate-200/30 to-slate-500/5 dark:from-slate-400/20 dark:to-transparent shadow-[0_0_40px_-10px_rgba(148,163,184,0.3)]'
: 'h-20 md:h-28 bg-gradient-to-b from-orange-200/30 to-orange-500/5 dark:from-orange-400/20 dark:to-transparent shadow-[0_0_40px_-10px_rgba(251,146,60,0.3)]'
)}
/>
</article>
</div>
</li>
);
})}
Expand Down
50 changes: 28 additions & 22 deletions frontend/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,77 @@
'use client';

import { TrendingUp } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { User } from './types';

export function LeaderboardTable({ users }: { users: User[] }) {
const t = useTranslations('leaderboard');

return (
<div className="w-full bg-white/70 dark:bg-slate-900/60 backdrop-blur-md rounded-2xl shadow-xl border border-slate-200/60 dark:border-slate-700/60 overflow-hidden">
<div className="w-full bg-white/60 dark:bg-slate-900/60 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/50 dark:border-slate-700/50 overflow-hidden">
<table className="w-full text-left border-collapse">
<caption className="sr-only">
Leaderboard ranking for other participants
</caption>
<caption className="sr-only">{t('tableCaption')}</caption>

<thead className="bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-700">
<tr>
<th
scope="col"
className="px-6 py-4 text-center text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider w-[16%]"
className="px-6 py-5 text-center text-xs font-extrabold text-slate-400 dark:text-slate-500 uppercase tracking-widest w-[15%]"
>
Rank
{t('rank')}
</th>
<th
scope="col"
className="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider w-[58%]"
className="px-6 py-5 text-xs font-extrabold text-slate-400 dark:text-slate-500 uppercase tracking-widest w-[60%]"
>
UserName
{t('user')}
</th>
<th
scope="col"
className="px-6 py-4 text-right text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider w-[25%]"
className="px-6 py-5 text-right text-xs font-extrabold text-slate-400 dark:text-slate-500 uppercase tracking-widest w-[25%]"
>
Points
{t('score')}
</th>
</tr>
</thead>

<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
{users.map(user => (
<tr
key={user.id}
className="hover:bg-slate-50/80 dark:hover:bg-slate-800/60 transition-colors group"
className="group transition-colors hover:bg-white/80 dark:hover:bg-slate-800/80"
>
<td className="px-6 py-4 text-center font-mono font-semibold text-slate-400 dark:text-slate-500 group-hover:text-slate-900 dark:group-hover:text-slate-200">
<td className="px-6 py-4 text-center font-bold text-slate-500 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">
{user.rank}
</td>

<td className="px-6 py-4">
<div className="flex items-center gap-4">
<div
className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm font-bold text-slate-600 dark:text-slate-300 ring-2 ring-transparent group-hover:ring-slate-200 dark:group-hover:ring-slate-600 transition-all"
className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900 flex items-center justify-center text-sm font-bold text-slate-600 dark:text-slate-300 shadow-inner group-hover:scale-110 transition-transform duration-300"
aria-hidden="true"
>
<span>{user.username.slice(0, 1).toUpperCase()}</span>
{user.username.slice(0, 1).toUpperCase()}
</div>

<div className="flex flex-col">
<span className="font-bold text-slate-700 dark:text-slate-200 text-sm group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
<span className="font-bold text-slate-700 dark:text-slate-200 text-sm group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{user.username}
</span>
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
<TrendingUp className="w-3 h-3" aria-hidden="true" />
Rising
</span>
{user.change > 0 && (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-bold uppercase tracking-wide opacity-0 group-hover:opacity-100 transition-opacity">
<TrendingUp className="w-3 h-3" aria-hidden="true" />
{t('rising')}
</span>
)}
</div>
</div>
</td>

<td className="px-6 py-4 text-right font-mono font-bold text-slate-800 dark:text-slate-100">
{user.points.toLocaleString()}
<td className="px-6 py-4 text-right">
<span className="font-mono font-bold text-slate-800 dark:text-slate-100 group-hover:scale-105 inline-block transition-transform">
{user.points.toLocaleString()}
</span>
</td>
</tr>
))}
Expand Down
Loading