What a mess
This commit is contained in:
32
pwa/.gitignore
vendored
Normal file
32
pwa/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
163
pwa/README.md
Normal file
163
pwa/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Echoes of the Ashes - PWA
|
||||
|
||||
A Progressive Web App (PWA) version of Echoes of the Ashes, bringing the post-apocalyptic survival RPG to web and mobile browsers.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎮 **Play on Any Device**: Works on desktop, tablet, and mobile browsers
|
||||
- 📱 **Install as App**: Add to home screen for app-like experience
|
||||
- 🔔 **Push Notifications**: Get notified of game events even when app is closed
|
||||
- 📶 **Offline Support**: Continue playing even without internet connection (coming soon)
|
||||
- 🔐 **Web Authentication**: Separate login system from Telegram
|
||||
- ⚡ **Fast & Responsive**: Optimized for quick loading and smooth gameplay
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **Styling**: CSS3 with mobile-first responsive design
|
||||
- **PWA**: Workbox for service worker and offline functionality
|
||||
- **API**: FastAPI backend with JWT authentication
|
||||
- **State Management**: Zustand (lightweight alternative to Redux)
|
||||
- **HTTP Client**: Axios with interceptors
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
pwa/
|
||||
├── public/ # Static assets (icons, manifest)
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── Login.tsx # Login/Register page
|
||||
│ │ └── Game.tsx # Main game interface
|
||||
│ ├── contexts/ # React contexts
|
||||
│ │ └── AuthContext.tsx # Authentication state
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ └── useAuth.ts # Auth hook
|
||||
│ ├── services/ # API services
|
||||
│ │ └── api.ts # Axios instance
|
||||
│ ├── App.tsx # Main app component
|
||||
│ ├── App.css # Global styles
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Base styles
|
||||
├── index.html # HTML template
|
||||
├── vite.config.ts # Vite configuration + PWA setup
|
||||
├── package.json # Dependencies
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm or yarn
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
cd pwa
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output will be in `dist/` directory.
|
||||
|
||||
## Deployment
|
||||
|
||||
The PWA is deployed as a Docker container behind Traefik reverse proxy:
|
||||
|
||||
- **Production URL**: https://echoesoftheashgame.patacuack.net
|
||||
- **SSL**: Automatic HTTPS via Traefik + Let's Encrypt
|
||||
- **Container**: Nginx serving static React build
|
||||
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.pwa -t echoes-pwa .
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No environment variables needed for the PWA frontend. API URL is determined by `import.meta.env.PROD`:
|
||||
|
||||
- **Development**: `http://localhost:3000` (proxied to API)
|
||||
- **Production**: `https://echoesoftheashgame.patacuack.net`
|
||||
|
||||
## API Integration
|
||||
|
||||
The PWA communicates with the FastAPI backend at `/api/*`:
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
- `POST /api/auth/register` - Register new account
|
||||
- `POST /api/auth/login` - Login with credentials
|
||||
- `GET /api/auth/me` - Get current user info
|
||||
|
||||
### Game Endpoints
|
||||
|
||||
- `GET /api/game/state` - Get player state
|
||||
- `POST /api/game/move` - Move player
|
||||
- More endpoints coming soon...
|
||||
|
||||
## PWA Features
|
||||
|
||||
### Service Worker
|
||||
|
||||
Configured in `vite.config.ts` using `vite-plugin-pwa`:
|
||||
|
||||
- **Auto Update**: Prompts user to reload when new version available
|
||||
- **Cache Strategy**: NetworkFirst for API, CacheFirst for images
|
||||
- **Offline Ready**: Caches essential assets for offline use
|
||||
|
||||
### Manifest
|
||||
|
||||
PWA manifest in `vite.config.ts`:
|
||||
|
||||
- **Name**: Echoes of the Ashes
|
||||
- **Icons**: 192x192 and 512x512 PNG icons
|
||||
- **Display**: Standalone (looks like native app)
|
||||
- **Theme**: Dark mode (#1a1a1a)
|
||||
|
||||
### Installation
|
||||
|
||||
Users can install the PWA:
|
||||
|
||||
- **Desktop**: Click install button in address bar
|
||||
- **iOS**: Share → Add to Home Screen
|
||||
- **Android**: Browser will prompt to install
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Complete game state API integration
|
||||
- [ ] Implement inventory management UI
|
||||
- [ ] Add combat interface
|
||||
- [ ] Create interactive map view
|
||||
- [ ] Implement NPC interactions
|
||||
- [ ] Add push notification service
|
||||
- [ ] Improve offline caching strategy
|
||||
- [ ] Add service worker update notifications
|
||||
- [ ] Implement WebSocket for real-time updates
|
||||
- [ ] Add sound effects and music
|
||||
- [ ] Create onboarding tutorial
|
||||
- [ ] Add accessibility features
|
||||
|
||||
## Contributing
|
||||
|
||||
This is part of the Echoes of the Ashes project. See main README for contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Same as main project.
|
||||
17
pwa/index.html
Normal file
17
pwa/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a1a" />
|
||||
<meta name="description" content="A post-apocalyptic survival RPG" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Echoes of the Ash</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
pwa/package.json
Normal file
33
pwa/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "echoes-of-the-ashes-pwa",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^0.17.4",
|
||||
"workbox-window": "^7.0.0"
|
||||
}
|
||||
}
|
||||
40
pwa/public/README.md
Normal file
40
pwa/public/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# PWA Icons
|
||||
|
||||
This directory should contain the following icons for the Progressive Web App:
|
||||
|
||||
## Required Icons
|
||||
|
||||
- `pwa-192x192.png` - 192x192px icon for mobile
|
||||
- `pwa-512x512.png` - 512x512px icon for desktop/splash screen
|
||||
- `apple-touch-icon.png` - 180x180px for iOS
|
||||
- `favicon.ico` - Standard favicon
|
||||
- `mask-icon.svg` - Safari pinned tab icon
|
||||
|
||||
## Icon Design Guidelines
|
||||
|
||||
- Use the game's theme (post-apocalyptic, dark colors)
|
||||
- Ensure icons are recognizable at small sizes
|
||||
- Test on various backgrounds (dark mode, light mode)
|
||||
- Keep designs simple and bold
|
||||
|
||||
## Generating Icons
|
||||
|
||||
You can use tools like:
|
||||
- https://realfavicongenerator.net/
|
||||
- https://favicon.io/
|
||||
- Photoshop/GIMP/Figma
|
||||
|
||||
## Placeholder
|
||||
|
||||
Until custom icons are created, you can use colored squares or the game logo.
|
||||
|
||||
Example quick generation:
|
||||
```bash
|
||||
# Using ImageMagick
|
||||
convert -size 192x192 xc:#646cff -font DejaVu-Sans-Bold -pointsize 72 \
|
||||
-fill white -gravity center -annotate +0+0 'E' pwa-192x192.png
|
||||
convert -size 512x512 xc:#646cff -font DejaVu-Sans-Bold -pointsize 200 \
|
||||
-fill white -gravity center -annotate +0+0 'E' pwa-512x512.png
|
||||
convert -size 180x180 xc:#646cff -font DejaVu-Sans-Bold -pointsize 72 \
|
||||
-fill white -gravity center -annotate +0+0 'E' apple-touch-icon.png
|
||||
```
|
||||
BIN
pwa/public/icon-192.png
Normal file
BIN
pwa/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
BIN
pwa/public/icon-512.png
Normal file
BIN
pwa/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
24
pwa/public/manifest.webmanifest
Normal file
24
pwa/public/manifest.webmanifest
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Echoes of the Ash",
|
||||
"short_name": "Echoes",
|
||||
"description": "A post-apocalyptic survival RPG",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
pwa/public/pwa-192x192.png
Normal file
BIN
pwa/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
BIN
pwa/public/pwa-512x512.png
Normal file
BIN
pwa/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
19
pwa/public/sw.js
Normal file
19
pwa/public/sw.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const CACHE_NAME = 'echoes-of-the-ash-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => response || fetch(event.request))
|
||||
);
|
||||
});
|
||||
93
pwa/src/App.css
Normal file
93
pwa/src/App.css
Normal 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
59
pwa/src/App.tsx
Normal 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
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
|
||||
85
pwa/src/contexts/AuthContext.tsx
Normal file
85
pwa/src/contexts/AuthContext.tsx
Normal 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
6
pwa/src/hooks/useAuth.ts
Normal 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
65
pwa/src/index.css
Normal 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
23
pwa/src/main.tsx
Normal 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
16
pwa/src/services/api.ts
Normal 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
12
pwa/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
25
pwa/tsconfig.json
Normal file
25
pwa/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
pwa/tsconfig.node.json
Normal file
10
pwa/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
83
pwa/vite.config.ts
Normal file
83
pwa/vite.config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||
manifest: {
|
||||
name: 'Echoes of the Ash',
|
||||
short_name: 'EotA',
|
||||
description: 'A post-apocalyptic survival RPG',
|
||||
theme_color: '#1a1a1a',
|
||||
background_color: '#1a1a1a',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/echoesoftheashgame\.patacuack\.net\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 // 1 hour
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/echoesoftheashgame\.patacuack\.net\/images\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'image-cache',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user