project-standalo-todo-super/app/components/LeaderboardTable.tsx

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>
);
}