All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m48s
218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Avatar } from "@/components/ui";
|
||
import { getCharacterClassIcon, getCharacterClassName, type CharacterClass } from "@/lib/character-classes";
|
||
|
||
interface LeaderboardEntry {
|
||
rank: number;
|
||
username: string;
|
||
email: string;
|
||
score: number;
|
||
level: number;
|
||
avatar?: string | null;
|
||
bio?: string | null;
|
||
characterClass?: CharacterClass | null;
|
||
}
|
||
|
||
// Format number with consistent locale to avoid hydration mismatch
|
||
const formatScore = (score: number): string => {
|
||
return score.toLocaleString("en-US");
|
||
};
|
||
|
||
export default function Leaderboard() {
|
||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedEntry, setSelectedEntry] = useState<LeaderboardEntry | null>(
|
||
null
|
||
);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/leaderboard")
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
setLeaderboard(data);
|
||
setLoading(false);
|
||
})
|
||
.catch((err) => {
|
||
console.error("Error fetching leaderboard:", err);
|
||
setLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<section className="w-full bg-black py-16 px-6 border-t-2 border-pixel-dark-purple">
|
||
<div className="max-w-4xl mx-auto text-center text-pixel-gold">
|
||
Chargement...
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
return (
|
||
<section className="w-full bg-black py-16 px-6 border-t-2 border-pixel-dark-purple">
|
||
<div className="max-w-4xl mx-auto">
|
||
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12 text-pixel-gold text-pixel">
|
||
LEADERBOARD
|
||
</h2>
|
||
|
||
<div className="bg-black/80 border-2 border-pixel-gold/30 rounded-lg backdrop-blur-sm">
|
||
{/* Header */}
|
||
<div className="bg-gray-900/80 border-b-2 border-pixel-gold/30 grid grid-cols-12 gap-4 p-4 font-bold text-sm text-gray-300">
|
||
<div className="col-span-1 text-center">Rank</div>
|
||
<div className="col-span-5">Player</div>
|
||
<div className="col-span-3 text-right">Score</div>
|
||
<div className="col-span-3 text-right">Level</div>
|
||
</div>
|
||
|
||
{/* Entries */}
|
||
<div className="divide-y divide-pixel-gold/10 overflow-visible">
|
||
{leaderboard.map((entry) => (
|
||
<div
|
||
key={entry.rank}
|
||
className={`grid grid-cols-12 gap-4 p-4 hover:bg-gray-900/50 transition relative ${
|
||
entry.rank <= 3 ? "bg-gray-950/50" : "bg-black/40"
|
||
}`}
|
||
>
|
||
<div className="col-span-1 text-center">
|
||
<span
|
||
className={`inline-block w-8 h-8 rounded-full flex items-center justify-center font-bold ${
|
||
entry.rank === 1
|
||
? "bg-pixel-gold text-black"
|
||
: entry.rank === 2
|
||
? "bg-gray-600 text-white"
|
||
: entry.rank === 3
|
||
? "bg-orange-800 text-white"
|
||
: "bg-gray-900 text-gray-400 border border-gray-800"
|
||
}`}
|
||
>
|
||
{entry.rank}
|
||
</span>
|
||
</div>
|
||
<div className="col-span-5 flex items-center gap-2">
|
||
<div
|
||
className="flex items-center gap-2 cursor-pointer hover:text-pixel-gold transition"
|
||
onClick={() => setSelectedEntry(entry)}
|
||
>
|
||
<span className="font-bold text-pixel-gold">
|
||
{entry.username}
|
||
</span>
|
||
{entry.characterClass && (
|
||
<span className="text-xs text-gray-400 uppercase tracking-wider">
|
||
[{getCharacterClassIcon(entry.characterClass)} {getCharacterClassName(entry.characterClass)}]
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="col-span-3 text-right flex items-center justify-end">
|
||
<span className="font-mono text-gray-300">
|
||
{formatScore(entry.score)}
|
||
</span>
|
||
</div>
|
||
<div className="col-span-3 text-right flex items-center justify-end">
|
||
<span className="font-bold text-gray-400">
|
||
Lv.{entry.level}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Additional info */}
|
||
<div className="mt-8 text-center text-sm text-gray-500">
|
||
<p>Compete with players worldwide and climb the ranks!</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Character Modal */}
|
||
{selectedEntry && (
|
||
<div
|
||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||
onClick={() => setSelectedEntry(null)}
|
||
>
|
||
<div
|
||
className="bg-black border-2 border-pixel-gold/70 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="p-8">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-3xl font-bold text-pixel-gold uppercase tracking-wider">
|
||
{selectedEntry.username}
|
||
</h2>
|
||
<button
|
||
onClick={() => setSelectedEntry(null)}
|
||
className="text-gray-400 hover:text-pixel-gold text-2xl font-bold transition"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Avatar and Class */}
|
||
<div className="flex items-center gap-6 mb-6">
|
||
<Avatar
|
||
src={selectedEntry.avatar}
|
||
username={selectedEntry.username}
|
||
size="xl"
|
||
borderClassName="border-4 border-pixel-gold/50"
|
||
/>
|
||
<div>
|
||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
|
||
Rank #{selectedEntry.rank}
|
||
</div>
|
||
<div className="text-sm text-gray-300 mb-2">
|
||
{selectedEntry.email}
|
||
</div>
|
||
{selectedEntry.characterClass && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-2xl">
|
||
{getCharacterClassIcon(selectedEntry.characterClass)}
|
||
</span>
|
||
<span className="text-lg font-bold text-pixel-gold uppercase tracking-wider">
|
||
{getCharacterClassName(selectedEntry.characterClass)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||
<div className="bg-black/60 border border-pixel-gold/30 rounded p-4">
|
||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
|
||
Score
|
||
</div>
|
||
<div className="text-2xl font-bold text-pixel-gold">
|
||
{formatScore(selectedEntry.score)}
|
||
</div>
|
||
</div>
|
||
<div className="bg-black/60 border border-pixel-gold/30 rounded p-4">
|
||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
|
||
Niveau
|
||
</div>
|
||
<div className="text-2xl font-bold text-pixel-gold">
|
||
Lv.{selectedEntry.level}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bio */}
|
||
{selectedEntry.bio && (
|
||
<div className="border-t border-pixel-gold/30 pt-6">
|
||
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-3 font-bold">
|
||
Bio
|
||
</div>
|
||
<p className="text-gray-200 leading-relaxed whitespace-pre-wrap break-words">
|
||
{selectedEntry.bio}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|