What a mess
This commit is contained in:
4290
pwa/src/components/Game.css
Normal file
4290
pwa/src/components/Game.css
Normal file
File diff suppressed because it is too large
Load Diff
2630
pwa/src/components/Game.tsx
Normal file
2630
pwa/src/components/Game.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
pwa/src/components/GameHeader.tsx
Normal file
48
pwa/src/components/GameHeader.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import './Game.css'
|
||||
|
||||
interface GameHeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function GameHeader({ className = '' }: GameHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const isOnOwnProfile = location.pathname === `/profile/${user?.id}`
|
||||
|
||||
return (
|
||||
<header className={`game-header ${className}`}>
|
||||
<h1>Echoes of the Ash</h1>
|
||||
<nav className="nav-links">
|
||||
<button
|
||||
onClick={() => navigate('/game')}
|
||||
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
|
||||
>
|
||||
🎮 Game
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/leaderboards')}
|
||||
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
|
||||
>
|
||||
🏆 Leaderboards
|
||||
</button>
|
||||
</nav>
|
||||
<div className="user-info">
|
||||
<button
|
||||
onClick={() => navigate(`/profile/${user?.id}`)}
|
||||
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
|
||||
>
|
||||
{user?.username}
|
||||
</button>
|
||||
<button onClick={logout} className="button-secondary">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
597
pwa/src/components/Leaderboards.css
Normal file
597
pwa/src/components/Leaderboards.css
Normal file
@@ -0,0 +1,597 @@
|
||||
/* Leaderboards-specific styles - uses game-container from Game.css */
|
||||
|
||||
/* Header styles removed - using game-header from Game.css */
|
||||
|
||||
.game-main .leaderboards-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.stat-selector {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.stat-selector h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #6bb9f0;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-option {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.3s;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.stat-option.active {
|
||||
background: rgba(107, 185, 240, 0.2);
|
||||
border-width: 2px;
|
||||
box-shadow: 0 0 10px rgba(107, 185, 240, 0.4);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leaderboard-content {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.leaderboard-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 3px solid;
|
||||
}
|
||||
|
||||
.title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.leaderboard-title h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.leaderboard-loading, .leaderboard-error, .leaderboard-empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 1rem;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #6bb9f0;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.leaderboard-error button {
|
||||
margin-top: 1rem;
|
||||
background: #6bb9f0;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leaderboard-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 120px 150px;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
color: #6bb9f0;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 120px 150px;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateX(4px);
|
||||
border-color: rgba(107, 185, 240, 0.5);
|
||||
}
|
||||
|
||||
.table-row.rank-gold {
|
||||
background: linear-gradient(90deg, rgba(255, 215, 0, 0.15) 0%, rgba(255, 255, 255, 0.03) 100%);
|
||||
border-color: rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.table-row.rank-gold:hover {
|
||||
border-color: rgba(255, 215, 0, 0.7);
|
||||
}
|
||||
|
||||
.table-row.rank-silver {
|
||||
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, rgba(255, 255, 255, 0.03) 100%);
|
||||
border-color: rgba(192, 192, 192, 0.4);
|
||||
}
|
||||
|
||||
.table-row.rank-silver:hover {
|
||||
border-color: rgba(192, 192, 192, 0.7);
|
||||
}
|
||||
|
||||
.table-row.rank-bronze {
|
||||
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, rgba(255, 255, 255, 0.03) 100%);
|
||||
border-color: rgba(205, 127, 50, 0.4);
|
||||
}
|
||||
|
||||
.table-row.rank-bronze:hover {
|
||||
border-color: rgba(205, 127, 50, 0.7);
|
||||
}
|
||||
|
||||
.col-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.col-player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-username {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.col-level {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.col-value {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.col-value .stat-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pagination-top {
|
||||
margin: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-top .pagination-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.pagination-top .pagination-info {
|
||||
font-size: 0.9rem;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
background: rgba(107, 185, 240, 0.1);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
color: #6bb9f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: rgba(107, 185, 240, 0.2);
|
||||
border-color: #6bb9f0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.game-main .leaderboards-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-selector {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.stat-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Remove tab bar spacing for leaderboards page */
|
||||
.game-main {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.game-main .leaderboards-container {
|
||||
padding: 0.75rem;
|
||||
padding-top: 4rem; /* Space for hamburger button */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Hide desktop stat selector on mobile */
|
||||
.stat-selector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stat-selector h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dropdown-style selector on mobile */
|
||||
.stat-options {
|
||||
position: relative;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 350px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.stat-option:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stat-option:first-child {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.stat-option:last-child {
|
||||
border-bottom: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
/* Show only active by default */
|
||||
.stat-option:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stat-option.active {
|
||||
background: rgba(107, 185, 240, 0.15);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Add dropdown arrow to active option */
|
||||
.stat-option.active::after {
|
||||
content: '▼';
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Show all options when expanded */
|
||||
.stat-options.expanded .stat-option:not(.active) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stat-options.expanded .stat-option.active {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.stat-options.expanded .stat-option.active::after {
|
||||
content: '▲';
|
||||
}
|
||||
|
||||
.stat-options.expanded {
|
||||
background: rgba(0, 0, 0, 0.98);
|
||||
border-radius: 6px;
|
||||
border-color: rgba(107, 185, 240, 0.6);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.leaderboard-content {
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.leaderboard-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.leaderboard-title.dropdown-open {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.title-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-title {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.clickable-title:active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.title-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.98);
|
||||
border: 2px solid rgba(107, 185, 240, 0.6);
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
margin-top: -0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 101;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title-dropdown-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title-dropdown-option:last-child {
|
||||
border-bottom: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.title-dropdown-option:hover,
|
||||
.title-dropdown-option:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.leaderboard-title h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.pagination-top,
|
||||
.pagination-bottom {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination-bottom {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 44px !important;
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
padding: 0.5rem !important;
|
||||
font-size: 1.2rem !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: none; /* Hide header on mobile */
|
||||
}
|
||||
|
||||
.table-row {
|
||||
grid-template-columns: 50px 1fr 70px;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.col-level {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.col-value {
|
||||
order: 2;
|
||||
grid-column: 2 / 3;
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-username {
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.col-value .stat-value {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
81
pwa/src/components/Login.css
Normal file
81
pwa/src/components/Login.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-toggle {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #646cff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
color: #535bf2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.button-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
89
pwa/src/components/Login.tsx
Normal file
89
pwa/src/components/Login.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import './Login.css'
|
||||
|
||||
function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password)
|
||||
} else {
|
||||
await register(username, password)
|
||||
}
|
||||
navigate('/game')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Authentication failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h1>Echoes of the Ash</h1>
|
||||
<p className="login-subtitle">A Post-Apocalyptic Survival RPG</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" className="button-primary" disabled={loading}>
|
||||
{loading ? 'Please wait...' : isLogin ? 'Login' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className="button-link"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLogin ? "Don't have an account? Register" : 'Already have an account? Login'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
205
pwa/src/components/Profile.css
Normal file
205
pwa/src/components/Profile.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* Profile-specific styles - uses game-container from Game.css */
|
||||
|
||||
/* Header styles removed - using game-header from Game.css */
|
||||
|
||||
/* Loading and error states */
|
||||
.game-main .profile-loading,
|
||||
.game-main .profile-error {
|
||||
max-width: 600px;
|
||||
margin: 4rem auto;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 3rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
}
|
||||
|
||||
.game-main .profile-error button {
|
||||
margin-top: 1rem;
|
||||
background: #6bb9f0;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.game-main .profile-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.profile-info-card {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #6bb9f0;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.profile-level {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.profile-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #6bb9f0;
|
||||
border-bottom: 2px solid rgba(107, 185, 240, 0.3);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.95rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.stat-value.highlight-red {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.stat-value.highlight-green {
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
.stat-value.highlight-blue {
|
||||
color: #6bb9f0;
|
||||
}
|
||||
|
||||
.stat-value.highlight-hp {
|
||||
color: #ff6b9d;
|
||||
}
|
||||
|
||||
.stat-value.highlight-stamina {
|
||||
color: #ffd93d;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
/* Remove tab bar spacing for profile page */
|
||||
.game-main {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.game-main .profile-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
padding-top: 4rem; /* Space for hamburger button */
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.profile-info-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.profile-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
224
pwa/src/components/Profile.tsx
Normal file
224
pwa/src/components/Profile.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import GameHeader from './GameHeader'
|
||||
import './Profile.css'
|
||||
import './Game.css'
|
||||
|
||||
interface PlayerStats {
|
||||
id: number
|
||||
player_id: number
|
||||
distance_walked: number
|
||||
enemies_killed: number
|
||||
damage_dealt: number
|
||||
damage_taken: number
|
||||
hp_restored: number
|
||||
stamina_used: number
|
||||
stamina_restored: number
|
||||
items_collected: number
|
||||
items_dropped: number
|
||||
items_used: number
|
||||
deaths: number
|
||||
successful_flees: number
|
||||
failed_flees: number
|
||||
combats_initiated: number
|
||||
total_playtime: number
|
||||
last_activity: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
id: number
|
||||
username: string
|
||||
name: string
|
||||
level: number
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
player: PlayerInfo
|
||||
statistics: PlayerStats
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
const { playerId } = useParams<{ playerId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile()
|
||||
}, [playerId])
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.get(`/api/statistics/${playerId}`)
|
||||
setProfile(response.data)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load profile')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatPlaytime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return 'Never'
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">Loading profile...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<h2>Error</h2>
|
||||
<p>{error || 'Profile not found'}</p>
|
||||
<button onClick={() => navigate('/leaderboards')}>Back to Leaderboards</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = profile.statistics
|
||||
const player = profile.player
|
||||
|
||||
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="profile-container">
|
||||
<div className="profile-info-card">
|
||||
<div className="profile-avatar">
|
||||
<span className="avatar-icon">👤</span>
|
||||
</div>
|
||||
<h1 className="profile-name">{player.name}</h1>
|
||||
<p className="profile-username">@{player.username}</p>
|
||||
<div className="profile-level">Level {player.level}</div>
|
||||
<div className="profile-meta">
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Member since</span>
|
||||
<span className="meta-value">{formatDate(stats.created_at)}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Last seen</span>
|
||||
<span className="meta-value">{formatDate(stats.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-stats-grid">
|
||||
{/* Combat Stats */}
|
||||
<div className="stats-section">
|
||||
<h2 className="section-title">⚔️ Combat</h2>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Enemies Killed</span>
|
||||
<span className="stat-value">{stats.enemies_killed.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Combats Initiated</span>
|
||||
<span className="stat-value">{stats.combats_initiated.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Damage Dealt</span>
|
||||
<span className="stat-value highlight-red">{stats.damage_dealt.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Damage Taken</span>
|
||||
<span className="stat-value">{stats.damage_taken.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Deaths</span>
|
||||
<span className="stat-value">{stats.deaths.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Successful Flees</span>
|
||||
<span className="stat-value highlight-green">{stats.successful_flees.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Failed Flees</span>
|
||||
<span className="stat-value">{stats.failed_flees.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exploration Stats */}
|
||||
<div className="stats-section">
|
||||
<h2 className="section-title">🗺️ Exploration</h2>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Distance Walked</span>
|
||||
<span className="stat-value highlight-blue">{stats.distance_walked.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Playtime</span>
|
||||
<span className="stat-value">{formatPlaytime(stats.total_playtime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Stats */}
|
||||
<div className="stats-section">
|
||||
<h2 className="section-title">📦 Items</h2>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Items Collected</span>
|
||||
<span className="stat-value">{stats.items_collected.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Items Dropped</span>
|
||||
<span className="stat-value">{stats.items_dropped.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Items Used</span>
|
||||
<span className="stat-value">{stats.items_used.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recovery Stats */}
|
||||
<div className="stats-section">
|
||||
<h2 className="section-title">❤️ Recovery</h2>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">HP Restored</span>
|
||||
<span className="stat-value highlight-hp">{stats.hp_restored.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Stamina Used</span>
|
||||
<span className="stat-value">{stats.stamina_used.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">Stamina Restored</span>
|
||||
<span className="stat-value highlight-stamina">{stats.stamina_restored.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
Reference in New Issue
Block a user