What a mess

This commit is contained in:
Joan
2025-11-07 15:27:13 +01:00
parent 0b79b3ae59
commit 33cc9586c2
130 changed files with 29819 additions and 1175 deletions

93
pwa/src/App.css Normal file
View File

@@ -0,0 +1,93 @@
.app {
min-height: 100vh;
width: 100%;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-size: 1.5rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.card {
background-color: #2a2a2a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.button-primary {
background-color: #646cff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s;
}
.button-primary:hover {
background-color: #535bf2;
}
.button-secondary {
background-color: #2a2a2a;
color: white;
border: 1px solid #646cff;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.25s, background-color 0.25s;
}
.button-secondary:hover {
background-color: #3a3a3a;
border-color: #535bf2;
}
input, textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #3a3a3a;
border-radius: 8px;
background-color: #1a1a1a;
color: white;
font-size: 1rem;
margin-bottom: 1rem;
}
input:focus, textarea:focus {
outline: none;
border-color: #646cff;
}
.error {
color: #ff6b6b;
margin-top: 0.5rem;
}
.success {
color: #51cf66;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.container {
padding: 0.5rem;
}
.card {
padding: 1rem;
}
}

59
pwa/src/App.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { useAuth } from './hooks/useAuth'
import Login from './components/Login'
import Game from './components/Game'
import Profile from './components/Profile'
import Leaderboards from './components/Leaderboards'
import './App.css'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div className="loading">Loading...</div>
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />
}
function App() {
return (
<AuthProvider>
<Router>
<div className="app">
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/game"
element={
<PrivateRoute>
<Game />
</PrivateRoute>
}
/>
<Route
path="/profile/:playerId"
element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}
/>
<Route
path="/leaderboards"
element={
<PrivateRoute>
<Leaderboards />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/game" />} />
</Routes>
</div>
</Router>
</AuthProvider>
)
}
export default App

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View 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

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

View 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

View File

@@ -0,0 +1,85 @@
import { createContext, useState, useEffect, ReactNode } from 'react'
import api from '../services/api'
interface AuthContextType {
isAuthenticated: boolean
loading: boolean
user: User | null
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => void
}
interface User {
id: number
username: string
telegram_id?: string
}
export const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
loading: true,
user: null,
login: async () => {},
register: async () => {},
logout: () => {},
})
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
fetchUser()
} else {
setLoading(false)
}
}, [])
const fetchUser = async () => {
try {
const response = await api.get('/api/auth/me')
setUser(response.data)
setIsAuthenticated(true)
} catch (error) {
console.error('Failed to fetch user:', error)
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
} finally {
setLoading(false)
}
}
const login = async (username: string, password: string) => {
const response = await api.post('/api/auth/login', { username, password })
const { access_token } = response.data
localStorage.setItem('token', access_token)
api.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
await fetchUser()
}
const register = async (username: string, password: string) => {
const response = await api.post('/api/auth/register', { username, password })
const { access_token } = response.data
localStorage.setItem('token', access_token)
api.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
await fetchUser()
}
const logout = () => {
localStorage.removeItem('token')
delete api.defaults.headers.common['Authorization']
setIsAuthenticated(false)
setUser(null)
}
return (
<AuthContext.Provider value={{ isAuthenticated, loading, user, login, register, logout }}>
{children}
</AuthContext.Provider>
)
}

6
pwa/src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContext } from 'react'
import { AuthContext } from '../contexts/AuthContext'
export function useAuth() {
return useContext(AuthContext)
}

65
pwa/src/index.css Normal file
View File

@@ -0,0 +1,65 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #1a1a1a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background-color: #1a1a1a;
}
#root {
width: 100%;
min-height: 100vh;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #2a2a2a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
button {
background-color: #f9f9f9;
}
}

23
pwa/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { registerSW } from 'virtual:pwa-register'
// Register service worker
registerSW({
onNeedRefresh() {
if (confirm('New version available! Reload to update?')) {
window.location.reload()
}
},
onOfflineReady() {
console.log('App ready to work offline')
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

16
pwa/src/services/api.ts Normal file
View File

@@ -0,0 +1,16 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.PROD ? 'https://echoesoftheashgame.patacuack.net' : 'http://localhost:3000',
headers: {
'Content-Type': 'application/json',
},
})
// Add token to requests if it exists
const token = localStorage.getItem('token')
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
export default api

12
pwa/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly PROD: boolean
readonly DEV: boolean
readonly MODE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}