277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import api from '../services/api';
|
|
import './Leaderboards.css';
|
|
import './Game.css';
|
|
|
|
interface LeaderboardEntry {
|
|
rank: number;
|
|
player_id: number;
|
|
username: string;
|
|
name: string;
|
|
level: number;
|
|
value: number;
|
|
}
|
|
|
|
interface StatOption {
|
|
key: string;
|
|
label: string;
|
|
icon: string;
|
|
color: string;
|
|
}
|
|
|
|
const STAT_OPTIONS: StatOption[] = [
|
|
{ key: 'enemies_killed', label: 'Enemies Killed', icon: '⚔️', color: '#ff6b6b' },
|
|
{ key: 'distance_walked', label: 'Distance Traveled', icon: '🚶', color: '#6bb9f0' },
|
|
{ key: 'combats_initiated', label: 'Combats Started', icon: '💥', color: '#f093fb' },
|
|
{ key: 'damage_dealt', label: 'Damage Dealt', icon: '🗡️', color: '#ff8787' },
|
|
{ key: 'damage_taken', label: 'Damage Taken', icon: '🛡️', color: '#ffa94d' },
|
|
{ key: 'items_collected', label: 'Items Collected', icon: '📦', color: '#51cf66' },
|
|
{ key: 'items_used', label: 'Items Used', icon: '🧪', color: '#74c0fc' },
|
|
{ key: 'hp_restored', label: 'HP Restored', icon: '❤️', color: '#ff6b9d' },
|
|
{ key: 'stamina_restored', label: 'Stamina Restored', icon: '⚡', color: '#ffd93d' },
|
|
];
|
|
|
|
export default function Leaderboards() {
|
|
const navigate = useNavigate();
|
|
const [selectedStat, setSelectedStat] = useState<StatOption>(STAT_OPTIONS[0]);
|
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false);
|
|
const [statDropdownOpen, setStatDropdownOpen] = useState(false);
|
|
const ITEMS_PER_PAGE = 25;
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1); // Reset to page 1 when stat changes
|
|
fetchLeaderboard(selectedStat.key);
|
|
}, [selectedStat]);
|
|
|
|
const fetchLeaderboard = async (statName: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await api.get(`/api/leaderboard/${statName}`, {
|
|
params: { limit: 100 }
|
|
});
|
|
|
|
setLeaderboard(response.data.leaderboard || []);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatStatValue = (value: number, statKey: string): string => {
|
|
if (statKey === 'playtime') {
|
|
const hours = Math.floor(value / 3600);
|
|
const minutes = Math.floor((value % 3600) / 60);
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
return value.toLocaleString();
|
|
};
|
|
|
|
const getRankBadge = (rank: number): string => {
|
|
if (rank === 1) return '🥇';
|
|
if (rank === 2) return '🥈';
|
|
if (rank === 3) return '🥉';
|
|
return `#${rank}`;
|
|
};
|
|
|
|
const getRankClass = (rank: number): string => {
|
|
if (rank === 1) return 'rank-gold';
|
|
if (rank === 2) return 'rank-silver';
|
|
if (rank === 3) return 'rank-bronze';
|
|
return '';
|
|
};
|
|
|
|
return (
|
|
<div className="leaderboards-container">
|
|
{/* Game Header is now in GameLayout */}
|
|
|
|
{/* Mobile Header Toggle */}
|
|
<button
|
|
className="mobile-header-toggle"
|
|
onClick={() => setMobileHeaderOpen(!mobileHeaderOpen)}
|
|
>
|
|
{mobileHeaderOpen ? '✕' : '☰'}
|
|
</button>
|
|
|
|
<main className="game-main">
|
|
<div className="leaderboards-container">
|
|
<div className="stat-selector">
|
|
<h3>Select Statistic</h3>
|
|
<div className={`stat-options ${statDropdownOpen ? 'expanded' : ''}`}>
|
|
{STAT_OPTIONS.map((stat) => (
|
|
<button
|
|
key={stat.key}
|
|
className={`stat-option ${selectedStat.key === stat.key ? 'active' : ''}`}
|
|
onClick={() => {
|
|
if (selectedStat.key === stat.key) {
|
|
// Toggle dropdown when clicking active item
|
|
setStatDropdownOpen(!statDropdownOpen);
|
|
} else {
|
|
// Select new stat and close dropdown
|
|
setSelectedStat(stat);
|
|
setStatDropdownOpen(false);
|
|
}
|
|
}}
|
|
style={{
|
|
borderColor: selectedStat.key === stat.key ? stat.color : 'rgba(255, 255, 255, 0.2)',
|
|
}}
|
|
>
|
|
<span className="stat-icon">{stat.icon}</span>
|
|
<span className="stat-label">{stat.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="leaderboard-content">
|
|
<div
|
|
className={`leaderboard-title ${statDropdownOpen ? 'dropdown-open' : ''}`}
|
|
style={{ borderColor: selectedStat.color }}
|
|
>
|
|
<div
|
|
className="title-left clickable-title"
|
|
onClick={() => setStatDropdownOpen(!statDropdownOpen)}
|
|
>
|
|
<span className="title-icon">{selectedStat.icon}</span>
|
|
<h2>{selectedStat.label}</h2>
|
|
<span className="dropdown-arrow">{statDropdownOpen ? '▲' : '▼'}</span>
|
|
</div>
|
|
|
|
{/* Dropdown options */}
|
|
{statDropdownOpen && (
|
|
<div className="title-dropdown">
|
|
{STAT_OPTIONS.filter(stat => stat.key !== selectedStat.key).map((stat) => (
|
|
<button
|
|
key={stat.key}
|
|
className="title-dropdown-option"
|
|
onClick={() => {
|
|
setSelectedStat(stat);
|
|
setStatDropdownOpen(false);
|
|
}}
|
|
>
|
|
<span className="stat-icon">{stat.icon}</span>
|
|
<span className="stat-label">{stat.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!loading && !error && leaderboard.length > ITEMS_PER_PAGE && (
|
|
<div className="pagination pagination-top">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="pagination-btn"
|
|
>
|
|
←
|
|
</button>
|
|
<span className="pagination-info">
|
|
{currentPage} / {Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
|
|
disabled={currentPage >= Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
|
|
className="pagination-btn"
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{loading && (
|
|
<div className="leaderboard-loading">
|
|
<div className="spinner"></div>
|
|
<p>Loading leaderboard...</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="leaderboard-error">
|
|
<p>❌ {error}</p>
|
|
<button onClick={() => fetchLeaderboard(selectedStat.key)}>Retry</button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && leaderboard.length === 0 && (
|
|
<div className="leaderboard-empty">
|
|
<p>📊 No data available yet</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && leaderboard.length > 0 && (
|
|
<>
|
|
<div className="leaderboard-table">
|
|
<div className="table-header">
|
|
<div className="col-rank">Rank</div>
|
|
<div className="col-player">Player</div>
|
|
<div className="col-level">Level</div>
|
|
<div className="col-value">Value</div>
|
|
</div>
|
|
|
|
{leaderboard
|
|
.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)
|
|
.map((entry, index) => {
|
|
const rank = (currentPage - 1) * ITEMS_PER_PAGE + index + 1;
|
|
return (
|
|
<div
|
|
key={entry.player_id}
|
|
className={`table-row ${getRankClass(rank)}`}
|
|
onClick={() => navigate(`/profile/${entry.player_id}`)}
|
|
>
|
|
<div className="col-rank">
|
|
<span className="rank-badge">{getRankBadge(rank)}</span>
|
|
</div>
|
|
<div className="col-player">
|
|
<div className="player-name">{entry.name}</div>
|
|
<div className="player-username">@{entry.username}</div>
|
|
</div>
|
|
<div className="col-level">
|
|
<span className="level-badge">Lv {entry.level}</span>
|
|
</div>
|
|
<div className="col-value">
|
|
<span className="stat-value" style={{ color: selectedStat.color }}>
|
|
{formatStatValue(entry.value, selectedStat.key)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{Math.ceil(leaderboard.length / ITEMS_PER_PAGE) > 1 && (
|
|
<div className="pagination pagination-bottom">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="pagination-btn"
|
|
>
|
|
←
|
|
</button>
|
|
<span className="pagination-info">
|
|
{currentPage} / {Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(Math.ceil(leaderboard.length / ITEMS_PER_PAGE), p + 1))}
|
|
disabled={currentPage >= Math.ceil(leaderboard.length / ITEMS_PER_PAGE)}
|
|
className="pagination-btn"
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|