169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
interface LeaderboardEntry {
|
|
id: string;
|
|
username: string;
|
|
points: number;
|
|
badgeCount: number;
|
|
rank: number;
|
|
isCurrentUser?: boolean;
|
|
}
|
|
|
|
export default function LeaderboardTable() {
|
|
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [timeframe, setTimeframe] = useState<"daily" | "weekly" | "alltime">("alltime");
|
|
|
|
useEffect(() => {
|
|
fetchLeaderboard();
|
|
}, [timeframe]);
|
|
|
|
const fetchLeaderboard = async () => {
|
|
try {
|
|
const token = localStorage.getItem("token");
|
|
const response = await fetch(`/api/leaderboard?timeframe=${timeframe}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const data = await response.json();
|
|
if (data.success && data.data) {
|
|
// API returns { entries: LeaderboardEntry[], userRank }
|
|
const leaderboardEntries = (data.data.entries || []).map((entry: { userId: string; userName: string; points: number; badgesEarned: number; rank: number }) => ({
|
|
id: entry.userId,
|
|
username: entry.userName,
|
|
points: entry.points,
|
|
badgeCount: entry.badgesEarned,
|
|
rank: entry.rank,
|
|
}));
|
|
setEntries(leaderboardEntries);
|
|
} else if (Array.isArray(data)) {
|
|
setEntries(data);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch leaderboard:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getRankIcon = (rank: number) => {
|
|
switch (rank) {
|
|
case 1:
|
|
return <span className="text-3xl">🥇</span>;
|
|
case 2:
|
|
return <span className="text-3xl">🥈</span>;
|
|
case 3:
|
|
return <span className="text-3xl">🥉</span>;
|
|
default:
|
|
return <span className="text-gray-400 font-bold text-lg">#{rank}</span>;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-yellow-500"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex gap-2">
|
|
{(["daily", "weekly", "alltime"] as const).map(tf => (
|
|
<button
|
|
key={tf}
|
|
onClick={() => setTimeframe(tf)}
|
|
className={`px-4 py-2 rounded-lg font-medium transition capitalize ${
|
|
timeframe === tf
|
|
? "bg-gradient-to-r from-yellow-500 to-orange-500 text-white"
|
|
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
|
}`}
|
|
>
|
|
{tf === "alltime" ? "All Time" : tf}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-gray-800 border border-gray-700 rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-900 border-b border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Rank
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Player
|
|
</th>
|
|
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Points
|
|
</th>
|
|
<th className="px-6 py-4 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Badges
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700">
|
|
{entries.map(entry => (
|
|
<tr
|
|
key={entry.id}
|
|
className={`transition ${
|
|
entry.isCurrentUser
|
|
? "bg-yellow-500/10 border-l-4 border-yellow-500"
|
|
: "hover:bg-gray-750"
|
|
}`}
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center justify-center w-12">
|
|
{getRankIcon(entry.rank)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-yellow-400 to-orange-500 flex items-center justify-center text-white font-bold">
|
|
{entry.username[0].toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className={`font-semibold ${
|
|
entry.isCurrentUser ? "text-yellow-400" : "text-white"
|
|
}`}>
|
|
{entry.username}
|
|
{entry.isCurrentUser && (
|
|
<span className="ml-2 text-xs bg-yellow-500/20 border border-yellow-500/30 rounded-full px-2 py-1">
|
|
You
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<span className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500">
|
|
{entry.points.toLocaleString()}
|
|
</span>
|
|
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
</svg>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
<div className="inline-flex items-center gap-1 bg-purple-500/20 border border-purple-500/30 rounded-full px-3 py-1">
|
|
<svg className="w-4 h-4 text-purple-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
</svg>
|
|
<span className="text-purple-400 font-semibold">{entry.badgeCount}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|