What a mess
This commit is contained in:
284
pwa/src/components/Leaderboards.tsx
Normal file
284
pwa/src/components/Leaderboards.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import GameHeader from './GameHeader';
|
||||
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 token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`/api/leaderboard/${statName}?limit=100`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch leaderboard');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setLeaderboard(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="game-container">
|
||||
<GameHeader className={mobileHeaderOpen ? 'open' : ''} />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user