Files
echoes-of-the-ash/pwa/src/components/Leaderboards.tsx
2025-11-27 16:27:01 +01:00

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