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

32
pwa/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

BIN
pwa/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

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

25
pwa/tsconfig.json Normal file
View 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
View 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
View 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
}
}
}
})