Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95063d4066 | |||
| 3cec7abee9 | |||
| 8e92c28272 | |||
| de14c646fa | |||
| 29456a07bc | |||
| 7b729c5cb2 | |||
| 7a32d0e874 | |||
| 15f054d291 | |||
| 883a07fed5 | |||
| d7297346f2 | |||
| d00cc3225c | |||
| ed69fcf299 |
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
env:
|
||||
FRONTEND_HOST: psg-leagues-tools-frontend
|
||||
@@ -60,10 +59,24 @@ jobs:
|
||||
run: |
|
||||
echo "Deploying ${{ env.ENVIRONMENT }} to ${{ env.DEPLOY_PATH }}"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "sudo systemctl stop ${{ env.SERVICE_NAME }} || true"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p ${{ env.DEPLOY_PATH }}"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf ${{ env.DEPLOY_PATH }}/build"
|
||||
scp os-league-tools-master/build.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/build.tar.gz -C ${{ env.DEPLOY_PATH }}/"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/build.tar.gz"
|
||||
|
||||
if [ "${{ env.ENVIRONMENT }}" = "production" ]; then
|
||||
# Production: deploy only built files
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p ${{ env.DEPLOY_PATH }}"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf ${{ env.DEPLOY_PATH }}/build"
|
||||
scp os-league-tools-master/build.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/build.tar.gz -C ${{ env.DEPLOY_PATH }}/"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/build.tar.gz"
|
||||
else
|
||||
# Dev: deploy full source for hot reload
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p /home/sonder/leagues-tools-dev"
|
||||
tar -czf source.tar.gz os-league-tools-master/
|
||||
scp source.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf /home/sonder/leagues-tools-dev/os-league-tools-master"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/source.tar.gz -C /home/sonder/leagues-tools-dev/"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "cd /home/sonder/leagues-tools-dev/os-league-tools-master && npm ci"
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/source.tar.gz"
|
||||
fi
|
||||
|
||||
ssh sonder@${{ env.FRONTEND_HOST }} "sudo systemctl start ${{ env.SERVICE_NAME }}"
|
||||
echo "Deployed ${{ env.ENVIRONMENT }} successfully!"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Build and Deploy to GH Pages
|
||||
on: [workflow_dispatch]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
env:
|
||||
REACT_APP_GA_MID: ${{ secrets.REACT_APP_GA_MID }}
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
if: success()
|
||||
uses: crazy-max/ghaction-github-pages@v2
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.gitignore
vendored
@@ -60,3 +60,9 @@ blake2tokenhasher-test/*.*
|
||||
group-ironmen-master/*.*
|
||||
group-ironmen-tracker-master/*.*
|
||||
tasks-tracker-plugin-master/*.*
|
||||
os-league-tools-master/build.tar.gz
|
||||
|
||||
node_modules/
|
||||
.vscode/
|
||||
.gitea/
|
||||
.claude/
|
||||
|
||||
142
CLAUDE.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Leagues Tools
|
||||
|
||||
OSRS (Old School RuneScape) League tracker and planning application. Allows users to track League tasks, unlocks, planning, and group ironman progress via RuneLite plugin integration.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend (`os-league-tools-master/`)
|
||||
- **React 18.3** with React Router DOM 6.28
|
||||
- **Redux Toolkit** for state management
|
||||
- **TailwindCSS 3.0** for styling
|
||||
- **Webpack 5** for bundling
|
||||
|
||||
### Backend (`server/`)
|
||||
- **Hono** - lightweight web framework on Node.js
|
||||
- **TypeScript** (ES2022 target)
|
||||
- **Prisma 6.2** ORM with **SQLite** database
|
||||
- **bcrypt** for password hashing
|
||||
- **Nodemailer** for emails
|
||||
- **Blake2b** for token hashing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
leagues-tools-dev/
|
||||
├── os-league-tools-master/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.js # Main entry, routing
|
||||
│ │ ├── client/ # API client modules
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── store/ # Redux slices
|
||||
│ │ └── hooks/ # Custom hooks
|
||||
│ └── build/ # Production build output
|
||||
│
|
||||
├── server/ # Hono backend
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Server entry point
|
||||
│ │ ├── app.ts # Hono app setup
|
||||
│ │ ├── db.ts # Prisma client
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── middleware/ # Auth middleware
|
||||
│ │ └── utils/ # Helpers (email, password, blake2)
|
||||
│ └── prisma/schema.prisma # Database schema
|
||||
│
|
||||
└── ecosystem.config.js # PM2 deployment config
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd os-league-tools-master
|
||||
npm run dev # Start dev server (port 3000)
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd server
|
||||
npm run dev # Start with hot reload (tsx watch)
|
||||
npm run build # Compile TypeScript
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:generate # Generate Prisma client
|
||||
```
|
||||
|
||||
### PM2 (Root)
|
||||
```bash
|
||||
npm run start # Start all PM2 apps
|
||||
npm run stop # Stop all apps
|
||||
npm run logs # View logs
|
||||
```
|
||||
|
||||
## Database Schema (Key Models)
|
||||
|
||||
- **User** - Auth accounts with role (USER/ADMIN), sessions, characters
|
||||
- **Character** - User's OSRS characters with RSN, stores tasks/unlocks/notes as JSON
|
||||
- **Session** - Cookie-based sessions (7-day TTL)
|
||||
- **Group** - Group Ironmen tracking with Blake2b hashed token
|
||||
- **Member** - Group member data (stats, inventory, equipment, bank, quests)
|
||||
- **HiscoresCache** - Cached OSRS hiscores (5-min TTL)
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes prefixed with `/api`:
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/register`, `/login`, `/logout` | Auth |
|
||||
| `/me`, `/auth/status` | Current user |
|
||||
| `/forgot-password`, `/reset-password` | Password reset |
|
||||
| `/characters` | User character CRUD |
|
||||
| `/group/:name/*` | Group data (RuneLite plugin) |
|
||||
| `/hiscores/:rsn` | OSRS hiscores with caching |
|
||||
| `/admin/*` | Admin user management |
|
||||
| `/create-group`, `/ge-prices` | Public endpoints |
|
||||
|
||||
## Authentication
|
||||
|
||||
- **Session-based**: HTTP-only secure cookies, 7-day TTL
|
||||
- **Group tokens**: Blake2b-256 hashed, used by RuneLite plugin
|
||||
- **Roles**: USER (default), ADMIN (access to `/api/admin/*`)
|
||||
|
||||
Middleware in `server/src/middleware/`:
|
||||
- `session.ts` - requireAuth, requireAdmin
|
||||
- `groupAuth.ts` - RuneLite token validation
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Backend expects (via `.env` or ecosystem.config.js):
|
||||
- `PORT` - Server port (3001 default)
|
||||
- `DATABASE_URL` - SQLite path (`file:./data.db`)
|
||||
- `CORS_ORIGINS` - Allowed origins (comma-separated)
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` - Email config
|
||||
- `FRONTEND_BUILD_PATH` - Path to React build
|
||||
|
||||
Frontend uses:
|
||||
- `REACT_APP_RELDO_URL` - Override API endpoint
|
||||
- `REACT_APP_GA_MID` - Google Analytics ID
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **JSON Storage**: Complex data (tasks, unlocks) stored as JSON strings in SQLite
|
||||
2. **Graceful Fallbacks**: Hiscores returns stale cache if OSRS API fails
|
||||
3. **Character Active State**: Only one character active per user
|
||||
4. **Bulk Sync**: Merges local client data with server on login
|
||||
5. **SPA Routing**: Backend serves `index.html` for non-API routes
|
||||
|
||||
## Ports
|
||||
|
||||
| Service | Dev | Prod |
|
||||
|---------|-----|------|
|
||||
| Frontend | 3000 | (served by backend) |
|
||||
| Backend | 3003 | 3002 |
|
||||
|
||||
## Important Files
|
||||
|
||||
- `server/src/app.ts` - All route mounting, CORS, middleware
|
||||
- `server/prisma/schema.prisma` - Full database schema
|
||||
- `os-league-tools-master/src/App.js` - Frontend routing
|
||||
- `os-league-tools-master/src/client/` - API client functions
|
||||
- `ecosystem.config.js` - PM2 deployment configuration
|
||||
63
ecosystem.config.js
Normal file
@@ -0,0 +1,63 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'api-prod',
|
||||
cwd: './server',
|
||||
script: 'npx',
|
||||
args: 'tsx src/index.ts',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3002,
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
|
||||
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
|
||||
CORS_ORIGINS: 'https://leagues.tools,https://www.leagues.tools,http://localhost:3002',
|
||||
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
|
||||
APP_URL: 'https://leagues.tools',
|
||||
// SMTP Configuration - fill in your server details
|
||||
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com', // e.g., 'smtp.gmail.com' or 'mail.yourserver.com'
|
||||
SMTP_PORT: '25', // 587 for TLS, 465 for SSL
|
||||
SMTP_SECURE: 'false', // 'true' for port 465, 'false' for 587
|
||||
SMTP_USER: 'bailey@yeahnah.net', // SMTP username/email
|
||||
SMTP_PASS: 'Howaboutno123!', // SMTP password or app password
|
||||
SMTP_FROM: 'noreply@leagues.tools', // From address for emails
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api-dev',
|
||||
cwd: './server',
|
||||
script: 'npx',
|
||||
args: 'tsx watch src/index.ts',
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 3003,
|
||||
DATABASE_URL: 'file:./data-dev.db',
|
||||
SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
|
||||
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
|
||||
CORS_ORIGINS: 'http://localhost:3000,http://localhost:3001,https://dev.leagues.tools',
|
||||
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
|
||||
APP_URL: 'https://dev.leagues.tools',
|
||||
// SMTP Configuration - same as prod or leave empty for console logging
|
||||
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com',
|
||||
SMTP_PORT: '25',
|
||||
SMTP_SECURE: 'false',
|
||||
SMTP_USER: 'bailey@yeahnah.net',
|
||||
SMTP_PASS: 'Howaboutno123!',
|
||||
SMTP_FROM: 'noreply@leagues.tools',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'frontend-dev',
|
||||
cwd: './os-league-tools-master',
|
||||
script: 'npm',
|
||||
args: 'run dev',
|
||||
env: {
|
||||
BROWSER: 'none',
|
||||
PORT: 3000,
|
||||
WDS_SOCKET_PORT: 443, // Use nginx's HTTPS port for WebSocket
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -10,11 +10,18 @@ Group=sonder
|
||||
WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
|
||||
|
||||
# Environment variables
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="NODE_ENV=development"
|
||||
Environment="PORT=3001"
|
||||
Environment="HOST=0.0.0.0"
|
||||
# WebSocket config for hot reload through reverse proxy
|
||||
Environment="WDS_SOCKET_PROTOCOL=wss"
|
||||
Environment="WDS_SOCKET_HOST=dev.leagues.tools"
|
||||
Environment="WDS_SOCKET_PORT=443"
|
||||
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
|
||||
|
||||
# Start the application (serves the pre-built static files)
|
||||
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3001
|
||||
|
||||
# Start the dev server with hot reload
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
|
||||
7
os-league-tools-master/.env.development
Normal file
@@ -0,0 +1,7 @@
|
||||
# Development environment variables
|
||||
|
||||
# API server - use same domain, nginx proxies /api to backend
|
||||
REACT_APP_RELDO_URL=
|
||||
|
||||
# Google Analytics (optional - leave empty for dev)
|
||||
REACT_APP_GA_MID=
|
||||
5
os-league-tools-master/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# API server URL
|
||||
REACT_APP_RELDO_URL=http://localhost:3001
|
||||
|
||||
# Google Analytics tracking ID (optional)
|
||||
REACT_APP_GA_MID=
|
||||
7
os-league-tools-master/.env.production
Normal file
@@ -0,0 +1,7 @@
|
||||
# Production environment variables
|
||||
|
||||
# Production API server (same origin - served by Node.js)
|
||||
REACT_APP_RELDO_URL=
|
||||
|
||||
# Google Analytics
|
||||
REACT_APP_GA_MID=
|
||||
BIN
os-league-tools-master/build.tar.gz
Normal file
@@ -83,6 +83,7 @@ function getClientEnvironment(publicUrl) {
|
||||
WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
|
||||
WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
|
||||
WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
|
||||
WDS_SOCKET_PROTOCOL: process.env.WDS_SOCKET_PROTOCOL,
|
||||
// Whether or not react-refresh is enabled.
|
||||
// It is defined here so it is available in the webpackHotDevClient.
|
||||
FAST_REFRESH: process.env.FAST_REFRESH !== 'false',
|
||||
|
||||
@@ -10,6 +10,7 @@ const host = process.env.HOST || '0.0.0.0';
|
||||
const sockHost = process.env.WDS_SOCKET_HOST;
|
||||
const sockPath = process.env.WDS_SOCKET_PATH; // default: '/ws'
|
||||
const sockPort = process.env.WDS_SOCKET_PORT;
|
||||
const sockProtocol = process.env.WDS_SOCKET_PROTOCOL;
|
||||
|
||||
module.exports = function (proxy, allowedHost) {
|
||||
const disableFirewall = !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true';
|
||||
@@ -71,6 +72,7 @@ module.exports = function (proxy, allowedHost) {
|
||||
// Enable custom sockjs pathname for websocket connection to hot reloading server.
|
||||
// Enable custom sockjs hostname, pathname and port for websocket connection
|
||||
// to hot reloading server.
|
||||
protocol: 'wss',
|
||||
hostname: sockHost,
|
||||
pathname: sockPath,
|
||||
port: sockPort,
|
||||
|
||||
19
os-league-tools-master/leagues-tools-frontend-dev.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Leagues Tools Frontend Dev Server (Hot Reload)
|
||||
After=network.target leagues-tools-dev.service
|
||||
Wants=leagues-tools-dev.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sonder
|
||||
WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=leagues-tools-frontend-dev
|
||||
Environment=NODE_ENV=development
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
62
os-league-tools-master/package-lock.json
generated
@@ -96,6 +96,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^6.2.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.4.7",
|
||||
"lint-staged": "^12.1.5",
|
||||
@@ -10254,26 +10255,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
|
||||
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/express": {
|
||||
"optional": true
|
||||
}
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
@@ -10858,6 +10854,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-port-reachable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
|
||||
@@ -19831,6 +19837,30 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/express": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^6.2.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.4.7",
|
||||
"lint-staged": "^12.1.5",
|
||||
|
||||
@@ -18,6 +18,9 @@ import Calculators from './pages/Calculators';
|
||||
import Faq from './pages/Faq';
|
||||
import ViewCharacter from './pages/ViewCharacter';
|
||||
import Groups from './pages/Groups';
|
||||
import Planner from './pages/Planner';
|
||||
import Admin from './pages/Admin';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import { submitRenderError } from './client/feedback-client';
|
||||
import { ErrorPage } from './components/common/util/ErrorBoundary';
|
||||
|
||||
@@ -41,6 +44,12 @@ history.listen(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const isDevEnvironment = () =>
|
||||
window.location.hostname.startsWith('dev.') ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.port === '3000' ||
|
||||
window.location.port === '3001';
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
if (trackingId) {
|
||||
@@ -52,6 +61,30 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Prefix title with [DEV] on dev environment
|
||||
useEffect(() => {
|
||||
if (!isDevEnvironment()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prefixTitle = () => {
|
||||
if (!document.title.startsWith('[DEV] ')) {
|
||||
document.title = `[DEV] ${document.title}`;
|
||||
}
|
||||
};
|
||||
|
||||
prefixTitle();
|
||||
|
||||
// Watch for title changes and re-prefix
|
||||
const observer = new MutationObserver(prefixTitle);
|
||||
const titleElement = document.querySelector('title');
|
||||
if (titleElement) {
|
||||
observer.observe(titleElement, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<SidebarProvider>
|
||||
@@ -61,8 +94,10 @@ export default function App() {
|
||||
<Auth0Provider
|
||||
domain='login.osleague.tools'
|
||||
clientId='yfqwKEhQO8FL7MlxWmWo7ekuGgzSrfmh'
|
||||
redirectUri={window.location.origin}
|
||||
audience='https://dev-u4mby-kt.us.auth0.com/api/v2/'
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
audience: 'https://dev-u4mby-kt.us.auth0.com/api/v2/',
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={ErrorPage} onError={submitRenderError}>
|
||||
<Routes>
|
||||
@@ -75,9 +110,12 @@ export default function App() {
|
||||
<Route path=':skill' element={<Calculators />} />
|
||||
</Route>
|
||||
<Route path='groups' element={<Groups />} />
|
||||
<Route path='planner' element={<Planner />} />
|
||||
<Route path='about' element={<About />} />
|
||||
<Route path='settings' element={<Settings />} />
|
||||
<Route path='faq' element={<Faq />} />
|
||||
<Route path='admin' element={<Admin />} />
|
||||
<Route path='reset-password' element={<ResetPassword />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Auth0Provider>
|
||||
|
||||
BIN
os-league-tools-master/src/assets/img/Asgarnia_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 581 B |
BIN
os-league-tools-master/src/assets/img/Desert_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
os-league-tools-master/src/assets/img/Fremennik_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 573 B |
BIN
os-league-tools-master/src/assets/img/Kandarin_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 579 B |
BIN
os-league-tools-master/src/assets/img/Karamja_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 582 B |
BIN
os-league-tools-master/src/assets/img/Kourend_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 259 B |
BIN
os-league-tools-master/src/assets/img/Misthalin_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 586 B |
BIN
os-league-tools-master/src/assets/img/Morytania_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 619 B |
BIN
os-league-tools-master/src/assets/img/Tirannwn_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 577 B |
BIN
os-league-tools-master/src/assets/img/Varlamore_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
os-league-tools-master/src/assets/img/Wilderness_Area_Badge.png
Normal file
|
After Width: | Height: | Size: 598 B |
95
os-league-tools-master/src/client/admin-client.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => ({
|
||||
success: false,
|
||||
error: data.message || data.error || 'An error occurred',
|
||||
}));
|
||||
}
|
||||
return response.json().then(data => ({
|
||||
success: true,
|
||||
value: data,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.warn(error);
|
||||
return { success: false, error: error.message || 'Network error' };
|
||||
}
|
||||
|
||||
export function getAdminStats() {
|
||||
return fetch(`${BASE_URL}/api/admin/stats`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return fetch(`${BASE_URL}/api/admin/users`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getUser(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function updateUser(id, data) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function resetUserPassword(id, password) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}/password`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function deleteUser(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function invalidateUserSessions(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}/sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
97
os-league-tools-master/src/client/auth-client.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => ({
|
||||
success: false,
|
||||
error: data.message || data.error || 'An error occurred',
|
||||
}));
|
||||
}
|
||||
return response.json().then(data => ({
|
||||
success: true,
|
||||
value: data,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.warn(error);
|
||||
return { success: false, error: error.message || 'Network error' };
|
||||
}
|
||||
|
||||
export function login(username, password) {
|
||||
return fetch(`${BASE_URL}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function register(username, email, password) {
|
||||
return fetch(`${BASE_URL}/api/register`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return fetch(`${BASE_URL}/api/logout`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getAuthStatus() {
|
||||
return fetch(`${BASE_URL}/api/auth/status`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return fetch(`${BASE_URL}/api/me`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function forgotPassword(email) {
|
||||
return fetch(`${BASE_URL}/api/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function resetPassword(token, password) {
|
||||
return fetch(`${BASE_URL}/api/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
98
os-league-tools-master/src/client/character-client.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => ({
|
||||
success: false,
|
||||
error: data.message || data.error || 'An error occurred',
|
||||
}));
|
||||
}
|
||||
return response.json().then(data => ({
|
||||
success: true,
|
||||
value: data,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.warn(error);
|
||||
return { success: false, error: error.message || 'Network error' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all characters for the authenticated user
|
||||
*/
|
||||
export function getCharacters() {
|
||||
return fetch(`${BASE_URL}/api/characters`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new character
|
||||
* @param {string} rsn - RuneScape Name
|
||||
* @param {boolean} setActive - Whether to set this character as active
|
||||
*/
|
||||
export function createCharacter(rsn, setActive = false) {
|
||||
return fetch(`${BASE_URL}/api/characters`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ rsn, setActive }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a character (rename, set active, or sync data)
|
||||
* @param {number} id - Character ID
|
||||
* @param {object} updates - Fields to update (rsn, isActive, tasksData, unlocksData, notesData)
|
||||
*/
|
||||
export function updateCharacter(id, updates) {
|
||||
return fetch(`${BASE_URL}/api/characters/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a character
|
||||
* @param {number} id - Character ID
|
||||
*/
|
||||
export function deleteCharacter(id) {
|
||||
return fetch(`${BASE_URL}/api/characters/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk sync characters (used on login to merge local data with server)
|
||||
* @param {Array} characters - Array of character objects or RSN strings
|
||||
* @param {number} activeIndex - Index of the active character
|
||||
*/
|
||||
export function syncCharacters(characters, activeIndex) {
|
||||
return fetch(`${BASE_URL}/api/characters/sync`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ characters, activeIndex }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
|
||||
export function submitBug(formData) {
|
||||
return submitFeedback(formData, '/bug');
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Communicates with the Java/Spring Boot backend at spring-backend/
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_GROUP_IRONMEN_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
|
||||
export default async function getHiscores(rsn, handleResultCallback) {
|
||||
if (!rsn) {
|
||||
@@ -8,7 +9,7 @@ export default async function getHiscores(rsn, handleResultCallback) {
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/hiscores/${rsn}`;
|
||||
const url = `${BASE_URL}/api/hiscores/${rsn}`;
|
||||
await fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
340
os-league-tools-master/src/components/AuthModal.js
Normal file
@@ -0,0 +1,340 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import Spinner from './common/Spinner';
|
||||
import { login, register, forgotPassword } from '../client/auth-client';
|
||||
|
||||
const VIEW = {
|
||||
LOGIN: 'login',
|
||||
REGISTER: 'register',
|
||||
FORGOT_PASSWORD: 'forgot_password',
|
||||
};
|
||||
|
||||
export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
|
||||
const [view, setView] = useState(VIEW.LOGIN);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [loginUsername, setLoginUsername] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
const [registerUsername, setRegisterUsername] = useState('');
|
||||
const [registerEmail, setRegisterEmail] = useState('');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
|
||||
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const resetForm = () => {
|
||||
setLoginUsername('');
|
||||
setLoginPassword('');
|
||||
setRegisterUsername('');
|
||||
setRegisterEmail('');
|
||||
setRegisterPassword('');
|
||||
setRegisterConfirmPassword('');
|
||||
setForgotEmail('');
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
setView(VIEW.LOGIN);
|
||||
};
|
||||
|
||||
const switchView = newView => {
|
||||
resetForm();
|
||||
setView(newView);
|
||||
};
|
||||
|
||||
const handleLogin = async e => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!loginUsername || !loginPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await login(loginUsername, loginPassword);
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
resetForm();
|
||||
setIsOpen(false);
|
||||
if (onAuthSuccess) {
|
||||
onAuthSuccess(result.value);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async e => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!registerUsername || !registerEmail || !registerPassword || !registerConfirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerPassword !== registerConfirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await register(registerUsername, registerEmail, registerPassword);
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
resetForm();
|
||||
setIsOpen(false);
|
||||
if (onAuthSuccess) {
|
||||
onAuthSuccess(result.value);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async e => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
|
||||
if (!forgotEmail) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await forgotPassword(forgotEmail);
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage(result.value.message);
|
||||
setForgotEmail('');
|
||||
} else {
|
||||
setError(result.error || 'Failed to send reset email');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
onClose={handleClose}
|
||||
className='w-96 shadow shadow-primary rounded-md bg-primary-alt'
|
||||
>
|
||||
<Modal.Header className='text-center small-caps tracking-wide text-xl text-accent font-semibold'>
|
||||
{view === VIEW.LOGIN && 'Login'}
|
||||
{view === VIEW.REGISTER && 'Create Account'}
|
||||
{view === VIEW.FORGOT_PASSWORD && 'Reset Password'}
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className='text-primary text-sm'>
|
||||
{view === VIEW.LOGIN && (
|
||||
<form onSubmit={handleLogin} className='m-4 flex flex-col gap-3'>
|
||||
<label htmlFor='login-username' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Username</span>
|
||||
<input
|
||||
id='login-username'
|
||||
name='username'
|
||||
type='text'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Enter username'
|
||||
value={loginUsername}
|
||||
onChange={e => setLoginUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor='login-password' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Password</span>
|
||||
<input
|
||||
id='login-password'
|
||||
name='password'
|
||||
type='password'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Enter password'
|
||||
value={loginPassword}
|
||||
onChange={e => setLoginPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className='text-error text-xs text-center'>{error}</div>}
|
||||
|
||||
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center justify-center gap-2'>
|
||||
<Spinner size={Spinner.SIZE.sm} /> Logging in...
|
||||
</span>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
className='text-accent text-xs hover:underline'
|
||||
onClick={() => switchView(VIEW.FORGOT_PASSWORD)}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{view === VIEW.REGISTER && (
|
||||
<form onSubmit={handleRegister} className='m-4 flex flex-col gap-3'>
|
||||
<label htmlFor='register-username' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Username</span>
|
||||
<input
|
||||
id='register-username'
|
||||
name='username'
|
||||
type='text'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Choose a username'
|
||||
value={registerUsername}
|
||||
onChange={e => setRegisterUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor='register-email' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Email</span>
|
||||
<input
|
||||
id='register-email'
|
||||
name='email'
|
||||
type='email'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Enter your email'
|
||||
value={registerEmail}
|
||||
onChange={e => setRegisterEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='email'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor='register-password' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Password</span>
|
||||
<input
|
||||
id='register-password'
|
||||
name='new-password'
|
||||
type='password'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Create a password'
|
||||
value={registerPassword}
|
||||
onChange={e => setRegisterPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor='register-confirm-password' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Confirm Password</span>
|
||||
<input
|
||||
id='register-confirm-password'
|
||||
name='confirm-password'
|
||||
type='password'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Confirm your password'
|
||||
value={registerConfirmPassword}
|
||||
onChange={e => setRegisterConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className='text-error text-xs text-center'>{error}</div>}
|
||||
|
||||
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center justify-center gap-2'>
|
||||
<Spinner size={Spinner.SIZE.sm} /> Creating account...
|
||||
</span>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{view === VIEW.FORGOT_PASSWORD && (
|
||||
<form onSubmit={handleForgotPassword} className='m-4 flex flex-col gap-3'>
|
||||
<p className='text-secondary text-xs'>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<label htmlFor='forgot-email' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Email</span>
|
||||
<input
|
||||
id='forgot-email'
|
||||
name='email'
|
||||
type='email'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Enter your email'
|
||||
value={forgotEmail}
|
||||
onChange={e => setForgotEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='email'
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className='text-error text-xs text-center'>{error}</div>}
|
||||
{successMessage && <div className='text-success text-xs text-center'>{successMessage}</div>}
|
||||
|
||||
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center justify-center gap-2'>
|
||||
<Spinner size={Spinner.SIZE.sm} /> Sending...
|
||||
</span>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer className='text-center text-sm'>
|
||||
{view === VIEW.LOGIN && (
|
||||
<p className='text-secondary py-2'>
|
||||
Don't have an account?{' '}
|
||||
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.REGISTER)}>
|
||||
Register
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{view === VIEW.REGISTER && (
|
||||
<p className='text-secondary py-2'>
|
||||
Already have an account?{' '}
|
||||
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{view === VIEW.FORGOT_PASSWORD && (
|
||||
<p className='text-secondary py-2'>
|
||||
Remember your password?{' '}
|
||||
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import FeedbackModal from './FeedbackModal';
|
||||
import ManageDataModal from './ManageDataModal';
|
||||
import images from '../assets/images';
|
||||
import useQueryString from '../hooks/useQueryString';
|
||||
import ManageData from './nav/ManageData';
|
||||
import Feedback from './nav/Feedback';
|
||||
import AuthButton from './nav/AuthButton';
|
||||
import Character from './nav/Character';
|
||||
import ManageCharactersModal from './ManageCharactersModal';
|
||||
|
||||
@@ -20,37 +20,18 @@ export default function PageWrapper({ children }) {
|
||||
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
|
||||
|
||||
const navItems = [
|
||||
new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('Character', 'secondary', 1, 0).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<Character.SideBarItem
|
||||
key='character'
|
||||
setCharacterModalOpen={setCharacterModalOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('Data', 'secondary', 2, 0).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<ManageData.SideBarItem
|
||||
key='manage'
|
||||
setManageDataModalType={setManageDataModalType}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('WIP-Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('WIP-Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('WIP-Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('WIP-Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('WIP-Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
|
||||
new NavItem('Settings', 'overflow', 3, 1).withRouterLink('/settings').withIconFont('settings'),
|
||||
new NavItem('FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
|
||||
new NavItem('About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
|
||||
new NavItem('Discord', 'overflow', 4, 0).withHref('https://discord.gg/GQ5kVyU', '_blank').withIconFont('discord'),
|
||||
new NavItem('Github', 'overflow', 4, 1)
|
||||
.withHref('https://github.com/osrs-reldo/os-league-tools', '_blank')
|
||||
.withIconFont('code'),
|
||||
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
|
||||
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
|
||||
new NavItem('WIP-Discord', 'overflow', 4, 0).withHref('https://discord.gg/GQ5kVyU', '_blank').withIconFont('discord'),
|
||||
// new NavItem('Github', 'overflow', 4, 1)
|
||||
// .withHref('https://github.com/osrs-reldo/os-league-tools', '_blank')
|
||||
// .withIconFont('code'),
|
||||
new NavItem('Feedback', 'overflow', 4, 2).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<Feedback.SideBarItem
|
||||
@@ -61,9 +42,28 @@ export default function PageWrapper({ children }) {
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('Tip Jar', 'overflow', 4, 3)
|
||||
.withHref('https://ko-fi.com/osleaguetools', '_blank')
|
||||
.withIconFont('savings'),
|
||||
new NavItem('Account', 'footer', 10, 0).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<AuthButton.SideBarItem
|
||||
key='auth'
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('Character', 'footer', 10, 1).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<Character.SideBarItem
|
||||
key='character'
|
||||
setCharacterModalOpen={setCharacterModalOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
// new NavItem('Tip Jar', 'overflow', 4, 3)
|
||||
// .withHref('https://ko-fi.com/osleaguetools', '_blank')
|
||||
// .withIconFont('savings'),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
primary: primaryNavItems,
|
||||
secondary: secondaryNavItems,
|
||||
overflow: overflowNavItems,
|
||||
footer: footerNavItems,
|
||||
} = groupNavItemsByVariant(navItems);
|
||||
|
||||
// Group overflow items by collapseGroup for organization
|
||||
@@ -55,7 +56,6 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
))}
|
||||
</SideBarSection>
|
||||
|
||||
<SideBarDivider />
|
||||
|
||||
{/* Secondary navigation (Character, Manage Data) */}
|
||||
<SideBarSection>
|
||||
@@ -78,7 +78,20 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
{i < overflowGroups.length - 1 && <SideBarDivider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* footer pinned to bottom */}
|
||||
{footerNavItems?.length ? (
|
||||
<div className='sidebar-nav-content-footer'>
|
||||
<SideBarDivider />
|
||||
<SideBarSection>
|
||||
{footerNavItems.map(navItem => (
|
||||
<SideBarLink key={navItem.id} item={navItem} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</SideBarSection>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Spinner from '../common/Spinner';
|
||||
import Dropdown from '../common/Dropdown';
|
||||
import useAccount from '../../hooks/useAccount';
|
||||
import useClickListener from '../../hooks/useClickListener';
|
||||
import AuthModal from '../AuthModal';
|
||||
|
||||
function NavBarItem() {
|
||||
const { isLoggedIn, isAuthenticating, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const {
|
||||
isLoggedIn,
|
||||
isAuthenticating,
|
||||
username,
|
||||
userEmail,
|
||||
isAdmin,
|
||||
openAuthModal,
|
||||
logout,
|
||||
authModalOpen,
|
||||
closeAuthModal,
|
||||
handleAuthSuccess,
|
||||
} = useAccount();
|
||||
|
||||
useClickListener(menuRef, () => setExpanded(false), true);
|
||||
|
||||
if (isAuthenticating) {
|
||||
return (
|
||||
@@ -14,41 +33,221 @@ function NavBarItem() {
|
||||
);
|
||||
}
|
||||
|
||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
|
||||
onClick={openAuthModal}
|
||||
type='button'
|
||||
>
|
||||
<span className='icon-base mr-1 align-bottom'>login</span>
|
||||
Login
|
||||
</button>
|
||||
<button className='md:hidden inline navbar-icon-link' onClick={openAuthModal} type='button'>
|
||||
<span className='text-primary-alt icon-lg leading-tight align-middle'>login</span>
|
||||
</button>
|
||||
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<button
|
||||
className='text-primary md:inline hidden navbar-link-alt bg-hover py-1 px-2'
|
||||
type='button'
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
|
||||
{username}
|
||||
</button>
|
||||
<button className='md:hidden inline navbar-icon-link' onClick={() => setExpanded(true)} type='button'>
|
||||
<span className='text-primary-alt icon-lg leading-tight align-middle'>account_circle</span>
|
||||
</button>
|
||||
<div className='mt-1 absolute right-0 text-center'>
|
||||
<Dropdown show={isExpanded} innerRef={menuRef}>
|
||||
<Dropdown.Text isHeading>
|
||||
<span className='text-accent'>{username}</span>
|
||||
{userEmail && <p className='text-xs text-secondary font-normal'>{userEmail}</p>}
|
||||
</Dropdown.Text>
|
||||
<Dropdown.Separator />
|
||||
{isAdmin && (
|
||||
<Link to='/admin' onClick={() => setExpanded(false)}>
|
||||
<Dropdown.Button className='text-left' icon='admin_panel_settings'>
|
||||
Admin
|
||||
</Dropdown.Button>
|
||||
</Link>
|
||||
)}
|
||||
<Dropdown.Button
|
||||
className='text-left'
|
||||
icon='logout'
|
||||
onClick={() => {
|
||||
logout();
|
||||
setExpanded(false);
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Dropdown.Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedMenu() {
|
||||
const {
|
||||
isLoggedIn,
|
||||
isAuthenticating,
|
||||
username,
|
||||
openAuthModal,
|
||||
logout,
|
||||
authModalOpen,
|
||||
closeAuthModal,
|
||||
handleAuthSuccess,
|
||||
} = useAccount();
|
||||
|
||||
if (isAuthenticating) {
|
||||
return (
|
||||
<div className='text-primary bg-hover py-1 text-left'>
|
||||
<Spinner size={Spinner.SIZE.sm} />
|
||||
<p className='h-4 inline pl-1 font-sans-alt'>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<button className='text-primary bg-hover py-1 text-left' onClick={openAuthModal} type='button'>
|
||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>login</span>
|
||||
<p className='h-4 inline pl-1 font-sans-alt'>Login</p>
|
||||
</button>
|
||||
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
|
||||
onClick={() => action()}
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<button className='md:hidden inline navbar-icon-link' onClick={action} type='button'>
|
||||
<span className='text-primary-alt icon-lg leading-tight align-middle'>{icon}</span>
|
||||
<div className='text-primary py-1 text-left'>
|
||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
|
||||
<p className='h-4 inline pl-1 font-sans-alt text-accent'>{username}</p>
|
||||
</div>
|
||||
<button className='text-primary bg-hover py-1 text-left' onClick={logout} type='button'>
|
||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>logout</span>
|
||||
<p className='h-4 inline pl-1 font-sans-alt'>Logout</p>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedMenu() {
|
||||
const { isLoggedIn, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
|
||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
||||
function SideBarItem({ isCollapsed, onNavigate }) {
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const {
|
||||
isLoggedIn,
|
||||
isAuthenticating,
|
||||
username,
|
||||
userEmail,
|
||||
isAdmin,
|
||||
openAuthModal,
|
||||
logout,
|
||||
authModalOpen,
|
||||
closeAuthModal,
|
||||
handleAuthSuccess,
|
||||
} = useAccount();
|
||||
|
||||
useClickListener(menuRef, () => setExpanded(false), true);
|
||||
|
||||
if (isAuthenticating) {
|
||||
return (
|
||||
<div className='sidebar-nav-link w-full text-left'>
|
||||
<Spinner size={Spinner.SIZE.sm} />
|
||||
{!isCollapsed && <span className='sidebar-nav-link-label'>Loading...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='sidebar-nav-link w-full text-left'
|
||||
type='button'
|
||||
onClick={() => {
|
||||
openAuthModal();
|
||||
if (onNavigate) {
|
||||
onNavigate();
|
||||
}
|
||||
}}
|
||||
title={isCollapsed ? 'Login' : undefined}
|
||||
>
|
||||
<span className='sidebar-nav-link-icon icon-base'>login</span>
|
||||
{!isCollapsed && <span className='sidebar-nav-link-label'>Login</span>}
|
||||
</button>
|
||||
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className='text-primary bg-hover py-1 text-left' onClick={() => action()} type='button'>
|
||||
<span className='text-primary-alt icon-lg inline align-middle mr-1'>{icon}</span>
|
||||
<p className='h-4 inline pl-1 font-sans-alt'>{label}</p>
|
||||
</button>
|
||||
<div className='relative' ref={menuRef}>
|
||||
<button
|
||||
className='sidebar-nav-link w-full text-left'
|
||||
type='button'
|
||||
onClick={() => setExpanded(!isExpanded)}
|
||||
title={isCollapsed ? username : undefined}
|
||||
>
|
||||
<span className='sidebar-nav-link-icon icon-base'>account_circle</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className='sidebar-nav-link-label'>{username}</span>
|
||||
<span className='ml-auto icon-sm text-primary-alt'>{isExpanded ? 'expand_less' : 'expand_more'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && !isCollapsed && (
|
||||
<div className='bg-secondary-alt pl-4'>
|
||||
{userEmail && (
|
||||
<div className='sidebar-nav-link w-full text-left text-sm text-secondary'>
|
||||
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>mail</span>
|
||||
<span className='sidebar-nav-link-label text-xs'>{userEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to='/admin'
|
||||
className='sidebar-nav-link w-full text-left text-sm'
|
||||
onClick={() => {
|
||||
setExpanded(false);
|
||||
if (onNavigate) {
|
||||
onNavigate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>admin_panel_settings</span>
|
||||
<span className='sidebar-nav-link-label'>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className='sidebar-nav-link w-full text-left text-sm'
|
||||
onClick={() => {
|
||||
logout();
|
||||
setExpanded(false);
|
||||
if (onNavigate) {
|
||||
onNavigate();
|
||||
}
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>logout</span>
|
||||
<span className='sidebar-nav-link-label'>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getButtonValues(isLoggedIn, login, logout) {
|
||||
return {
|
||||
label: isLoggedIn ? 'Logout' : 'Login',
|
||||
icon: isLoggedIn ? 'logout' : 'login',
|
||||
action: isLoggedIn ? logout : login,
|
||||
};
|
||||
}
|
||||
|
||||
export default { NavBarItem, CollapsedMenu };
|
||||
export default { NavBarItem, CollapsedMenu, SideBarItem };
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
"title": "Demonic Pacts Leagues teaser!!",
|
||||
"date": "Jan 8 2026",
|
||||
"thumbnail": "https://i.imgur.com/hkZmhsr.png",
|
||||
"leadText": "Let's get excited!",
|
||||
"htmlContent": "<p>Over the coming weeks we will be updating the website with information as we learn about it."
|
||||
},
|
||||
{
|
||||
"type": "separator",
|
||||
"title": "Legacy Updates",
|
||||
"subtitle": "From the original OS League Tools project"
|
||||
},
|
||||
{
|
||||
"title": "Raging Echoes Leagues launch!",
|
||||
"date": "Nov 11 2024",
|
||||
@@ -51,7 +63,7 @@
|
||||
{
|
||||
"title": "Improved mobile support",
|
||||
"date": "Nov 08 2020",
|
||||
"thumbnail": "http://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
|
||||
"thumbnail": "https://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
|
||||
"leadText": "OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).",
|
||||
"htmlContent": "<p>OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).</p> <p>I've been hard at work this weekend improving the site experience on mobile, so you can now track and plan your tasks on the go. While my focus was on mobile, you should see some improvements all across the site for just about any screen size, as many components were revamped completely to be more responsive to the size of your screen.</p> <p>If you come across any parts of the site that still look strange on mobile devices, please make a bug report in the <a href='https://discord.gg/GQ5kVyU'>discord</a>, preferably with a screenshot and information about what device and browser you're using.</p>"
|
||||
},
|
||||
|
||||
210
os-league-tools-master/src/data/planner/plannerItems.json
Normal file
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Abyssal Whip",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Morytania"],
|
||||
"obtainedFrom": "Abyssal Demons",
|
||||
"requirements": [
|
||||
{"skill": "Attack", "level": 70}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Trident of the Seas",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Morytania"],
|
||||
"obtainedFrom": "Kraken",
|
||||
"requirements": [
|
||||
{"skill": "Magic", "level": 75}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Blowpipe",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Tirannwn"],
|
||||
"obtainedFrom": "Zulrah",
|
||||
"requirements": [
|
||||
{"skill": "Ranged", "level": 75}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Dragon Scimitar",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Kandarin"],
|
||||
"obtainedFrom": "Ape Atoll Shop (after Monkey Madness)",
|
||||
"requirements": [
|
||||
{"skill": "Attack", "level": 60}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Fire Cape",
|
||||
"slot": "Cape",
|
||||
"regions": ["Karamja"],
|
||||
"obtainedFrom": "Fight Caves (TzTok-Jad)",
|
||||
"requirements": [],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Infernal Cape",
|
||||
"slot": "Cape",
|
||||
"regions": ["Karamja"],
|
||||
"obtainedFrom": "Inferno (TzKal-Zuk)",
|
||||
"requirements": [],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Barrows Gloves",
|
||||
"slot": "Gloves",
|
||||
"regions": ["Misthalin"],
|
||||
"obtainedFrom": "Recipe for Disaster completion",
|
||||
"requirements": [
|
||||
{"skill": "Defence", "level": 41}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Scythe of Vitur",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Morytania"],
|
||||
"obtainedFrom": "Theatre of Blood",
|
||||
"requirements": [
|
||||
{"skill": "Attack", "level": 75},
|
||||
{"skill": "Strength", "level": 75}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Twisted Bow",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Kourend"],
|
||||
"obtainedFrom": "Chambers of Xeric",
|
||||
"requirements": [
|
||||
{"skill": "Ranged", "level": 75}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Blade of Saeldor",
|
||||
"slot": "Weapon",
|
||||
"regions": ["Tirannwn"],
|
||||
"obtainedFrom": "The Gauntlet",
|
||||
"requirements": [
|
||||
{"skill": "Attack", "level": 75}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Echo Pickaxe",
|
||||
"slot": "Tool",
|
||||
"regions": ["Global"],
|
||||
"obtainedFrom": "Power Miner Relic",
|
||||
"requirements": [],
|
||||
"relicUnlock": "t1-0"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Echo Axe",
|
||||
"slot": "Tool",
|
||||
"regions": ["Global"],
|
||||
"obtainedFrom": "Lumberjack Relic",
|
||||
"requirements": [],
|
||||
"relicUnlock": "t1-1"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Echo Harpoon",
|
||||
"slot": "Tool",
|
||||
"regions": ["Global"],
|
||||
"obtainedFrom": "Animal Wrangler Relic",
|
||||
"requirements": [],
|
||||
"relicUnlock": "t1-2"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "Berserker Ring",
|
||||
"slot": "Ring",
|
||||
"regions": ["Fremennik"],
|
||||
"obtainedFrom": "Dagannoth Rex",
|
||||
"requirements": [],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Archers Ring",
|
||||
"slot": "Ring",
|
||||
"regions": ["Fremennik"],
|
||||
"obtainedFrom": "Dagannoth Supreme",
|
||||
"requirements": [],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"name": "Seers Ring",
|
||||
"slot": "Ring",
|
||||
"regions": ["Fremennik"],
|
||||
"obtainedFrom": "Dagannoth Prime",
|
||||
"requirements": [],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"name": "Dragon Defender",
|
||||
"slot": "Shield",
|
||||
"regions": ["Asgarnia"],
|
||||
"obtainedFrom": "Warriors Guild",
|
||||
"requirements": [
|
||||
{"skill": "Attack", "level": 60},
|
||||
{"skill": "Defence", "level": 60}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"name": "Amulet of Fury",
|
||||
"slot": "Amulet",
|
||||
"regions": ["Global"],
|
||||
"obtainedFrom": "Crafting (onyx)",
|
||||
"requirements": [
|
||||
{"skill": "Crafting", "level": 90}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Occult Necklace",
|
||||
"slot": "Amulet",
|
||||
"regions": ["Morytania"],
|
||||
"obtainedFrom": "Smoke Devils",
|
||||
"requirements": [
|
||||
{"skill": "Magic", "level": 70}
|
||||
],
|
||||
"relicUnlock": null
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"name": "Necklace of Anguish",
|
||||
"slot": "Amulet",
|
||||
"regions": ["Tirannwn"],
|
||||
"obtainedFrom": "Crafting (zenyte from demonic gorillas)",
|
||||
"requirements": [
|
||||
{"skill": "Crafting", "level": 92}
|
||||
],
|
||||
"relicUnlock": null
|
||||
}
|
||||
]
|
||||
}
|
||||
92
os-league-tools-master/src/data/planner/plannerRegions.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"regions": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Misthalin",
|
||||
"isDefault": true,
|
||||
"description": "The heart of Gielinor, home to Lumbridge and Varrock",
|
||||
"keyLocations": ["Lumbridge", "Varrock", "Edgeville", "Barbarian Village"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Karamja",
|
||||
"isDefault": true,
|
||||
"description": "A tropical island with jungle dangers and volcanic activity",
|
||||
"keyLocations": ["Brimhaven", "Tai Bwo Wannai", "Shilo Village"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Asgarnia",
|
||||
"isDefault": false,
|
||||
"description": "Home to Falador, the White Knights, and the Dwarven Mines",
|
||||
"keyLocations": ["Falador", "Port Sarim", "Mudskipper Point", "Ice Mountain"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Desert",
|
||||
"isDefault": false,
|
||||
"description": "A harsh desert region with ancient pyramids and hidden cities",
|
||||
"keyLocations": ["Al Kharid", "Pollnivneach", "Nardah", "Sophanem"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Fremennik",
|
||||
"isDefault": false,
|
||||
"description": "Nordic lands of warriors, featuring Rellekka and the Lunar Isle",
|
||||
"keyLocations": ["Rellekka", "Lunar Isle", "Neitiznot", "Jatizso"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Morytania",
|
||||
"isDefault": false,
|
||||
"description": "A dark swampland ruled by vampyres and filled with undead",
|
||||
"keyLocations": ["Canifis", "Port Phasmatys", "Barrows", "Theatre of Blood"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Kandarin",
|
||||
"isDefault": false,
|
||||
"description": "A diverse region featuring Ardougne, Seers Village, and Prifddinas",
|
||||
"keyLocations": ["Ardougne", "Seers' Village", "Catherby", "Yanille"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Kourend",
|
||||
"isDefault": false,
|
||||
"description": "The Great Kourend, a kingdom of five houses with unique cultures",
|
||||
"keyLocations": ["Hosidius", "Shayzien", "Lovakengj", "Arceuus", "Piscarilius"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Tirannwn",
|
||||
"isDefault": false,
|
||||
"description": "The elven lands, featuring dense forests and the crystal city",
|
||||
"keyLocations": ["Lletya", "Prifddinas", "Zul-Andra"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Wilderness",
|
||||
"isDefault": false,
|
||||
"description": "A dangerous PvP zone with powerful bosses and high-risk rewards",
|
||||
"keyLocations": ["Edgeville Dungeon", "Mage Arena", "Revenant Caves"],
|
||||
"unlocksRegions": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Varlamore",
|
||||
"isDefault": false,
|
||||
"description": "A newly discovered continent with ancient secrets",
|
||||
"keyLocations": ["Civitas illa Fortis", "Cam Torum", "Hunter Guild"],
|
||||
"unlocksRegions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
244
os-league-tools-master/src/data/planner/plannerRelics.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"relicTiers": [
|
||||
{
|
||||
"tier": 1,
|
||||
"pointsRequired": 0,
|
||||
"name": "Gathering Tier"
|
||||
},
|
||||
{
|
||||
"tier": 2,
|
||||
"pointsRequired": 750,
|
||||
"name": "Skilling Tier"
|
||||
},
|
||||
{
|
||||
"tier": 3,
|
||||
"pointsRequired": 1500,
|
||||
"name": "Utility Tier"
|
||||
},
|
||||
{
|
||||
"tier": 4,
|
||||
"pointsRequired": 2500,
|
||||
"name": "Bonus Tier"
|
||||
},
|
||||
{
|
||||
"tier": 5,
|
||||
"pointsRequired": 5000,
|
||||
"name": "Mastery Tier"
|
||||
},
|
||||
{
|
||||
"tier": 6,
|
||||
"pointsRequired": 8000,
|
||||
"name": "Convenience Tier"
|
||||
},
|
||||
{
|
||||
"tier": 7,
|
||||
"pointsRequired": 16000,
|
||||
"name": "Advanced Tier"
|
||||
},
|
||||
{
|
||||
"tier": 8,
|
||||
"pointsRequired": 25000,
|
||||
"name": "Combat Tier"
|
||||
}
|
||||
],
|
||||
"relics": [
|
||||
{
|
||||
"id": "t1-0",
|
||||
"tier": 1,
|
||||
"name": "Power Miner",
|
||||
"description": "Grants the Echo pickaxe with mining bonuses and auto-smelting",
|
||||
"effects": [
|
||||
"50% chance to succeed on failed mining attempts",
|
||||
"Rock doesn't deplete until 4 ores mined",
|
||||
"Optional auto-smelt and gem cutting"
|
||||
],
|
||||
"unlocksItems": ["Echo Pickaxe"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t1-1",
|
||||
"tier": 1,
|
||||
"name": "Lumberjack",
|
||||
"description": "Grants the Echo axe with woodcutting bonuses and auto-banking",
|
||||
"effects": [
|
||||
"50% chance to succeed on failed chops",
|
||||
"Items sent directly to bank",
|
||||
"Optional auto-burn and fletching"
|
||||
],
|
||||
"unlocksItems": ["Echo Axe"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t1-2",
|
||||
"tier": 1,
|
||||
"name": "Animal Wrangler",
|
||||
"description": "Grants the Echo harpoon with fishing and hunter bonuses",
|
||||
"effects": [
|
||||
"50% chance to succeed on failed fishing attempts",
|
||||
"Fish automatically sent to bank",
|
||||
"50% auto-cook chance",
|
||||
"Hunter traps never fail"
|
||||
],
|
||||
"unlocksItems": ["Echo Harpoon"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t2-0",
|
||||
"tier": 2,
|
||||
"name": "Corner Cutter",
|
||||
"description": "Agility course bonuses and never fail obstacles",
|
||||
"effects": [
|
||||
"Grants Sage's Greaves for passive Agility XP",
|
||||
"Never fail agility obstacles",
|
||||
"Double course completion credit"
|
||||
],
|
||||
"unlocksItems": ["Sage's Greaves"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t2-1",
|
||||
"tier": 2,
|
||||
"name": "Friendly Forager",
|
||||
"description": "Herb gathering while skilling and potion bonuses",
|
||||
"effects": [
|
||||
"Grants Forager's Pouch that collects herbs",
|
||||
"90% chance to save secondary ingredients",
|
||||
"Potions have 4 doses instead of 3"
|
||||
],
|
||||
"unlocksItems": ["Forager's Pouch"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t2-2",
|
||||
"tier": 2,
|
||||
"name": "Dodgy Deals",
|
||||
"description": "Massive thieving upgrades and AoE pickpocketing",
|
||||
"effects": [
|
||||
"AoE pickpocketing in 11x11 area",
|
||||
"100% success rate on thieving",
|
||||
"Auto re-pickpocket until stopped",
|
||||
"Noted loot from pickpocketing"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t3-0",
|
||||
"tier": 3,
|
||||
"name": "Clue Compass",
|
||||
"description": "Teleport to STASH units and clue locations",
|
||||
"effects": [
|
||||
"Teleport to any STASH unit",
|
||||
"Teleport to current clue step",
|
||||
"Ignores wilderness teleport restrictions"
|
||||
],
|
||||
"unlocksItems": ["Clue Compass"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t3-1",
|
||||
"tier": 3,
|
||||
"name": "Bank Heist",
|
||||
"description": "Teleport to any bank or deposit box",
|
||||
"effects": [
|
||||
"Teleport to any deposit box, bank, or bank chest",
|
||||
"Ignores wilderness teleport restrictions"
|
||||
],
|
||||
"unlocksItems": ["Banker's Briefcase"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t3-2",
|
||||
"tier": 3,
|
||||
"name": "Fairy's Flight",
|
||||
"description": "Teleport to fairy rings, spirit trees, and leprechauns",
|
||||
"effects": [
|
||||
"Teleport to any fairy ring or spirit tree",
|
||||
"Teleport to any tool leprechaun",
|
||||
"Auto-completes Tree Gnome Village quest"
|
||||
],
|
||||
"unlocksItems": ["Fairy Mushroom"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t5-0",
|
||||
"tier": 5,
|
||||
"name": "Treasure Arbiter",
|
||||
"description": "Enhanced clue scroll drops and rewards",
|
||||
"effects": [
|
||||
"1/15 clue drop rate from implings",
|
||||
"10x more clue geodes/nests/bottles",
|
||||
"Minimum steps for all clue tiers",
|
||||
"Maximum reward rolls for all caskets"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t5-1",
|
||||
"tier": 5,
|
||||
"name": "Production Master",
|
||||
"description": "Process entire inventories at once",
|
||||
"effects": [
|
||||
"All items processed in one action",
|
||||
"Works for smithing, fletching, crafting, cooking, herblore"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t5-2",
|
||||
"tier": 5,
|
||||
"name": "Slayer Master",
|
||||
"description": "Always on task for all eligible slayer monsters",
|
||||
"effects": [
|
||||
"Always on task for all slayer monsters",
|
||||
"Free slayer perks and skips",
|
||||
"Bonus XP for killing 100th of each monster"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t8-0",
|
||||
"tier": 8,
|
||||
"name": "Specialist",
|
||||
"description": "All special attacks cost 20% with enhanced accuracy",
|
||||
"effects": [
|
||||
"All specs cost 20%",
|
||||
"+100% spec accuracy",
|
||||
"10% spec restore on missed hit",
|
||||
"15% spec restore on kill"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t8-1",
|
||||
"tier": 8,
|
||||
"name": "Guardian",
|
||||
"description": "Summon a powerful Guardian Thrall to fight for you",
|
||||
"effects": [
|
||||
"Grants Guardian Horn to summon thrall",
|
||||
"Thrall attacks with target's weakness",
|
||||
"AoE attacks in multi-combat"
|
||||
],
|
||||
"unlocksItems": ["Guardian Horn"],
|
||||
"autoCompletes": []
|
||||
},
|
||||
{
|
||||
"id": "t8-2",
|
||||
"tier": 8,
|
||||
"name": "Last Stand",
|
||||
"description": "Cheat death with a powerful combat buff",
|
||||
"effects": [
|
||||
"Survive fatal damage at 1 HP",
|
||||
"Combat stats boosted to 255",
|
||||
"16 tick invulnerability",
|
||||
"Heal based on damage dealt"
|
||||
],
|
||||
"unlocksItems": [],
|
||||
"autoCompletes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
226
os-league-tools-master/src/data/planner/plannerTasks.json
Normal file
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Achieve Your First Level Up",
|
||||
"description": "Level up any of your skills for the first time",
|
||||
"points": 10,
|
||||
"difficulty": "Easy",
|
||||
"category": "Milestone",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Achieve Your First Level 5",
|
||||
"description": "Reach level 5 in any skill",
|
||||
"points": 10,
|
||||
"difficulty": "Easy",
|
||||
"category": "Milestone",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [1],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Achieve Your First Level 10",
|
||||
"description": "Reach level 10 in any skill",
|
||||
"points": 10,
|
||||
"difficulty": "Easy",
|
||||
"category": "Milestone",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [1, 2],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Achieve Your First Level 20",
|
||||
"description": "Reach level 20 in any skill",
|
||||
"points": 25,
|
||||
"difficulty": "Easy",
|
||||
"category": "Milestone",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [1, 2, 3],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Achieve Your First Level 30",
|
||||
"description": "Reach level 30 in any skill",
|
||||
"points": 25,
|
||||
"difficulty": "Medium",
|
||||
"category": "Milestone",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [1, 2, 3, 4],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"name": "Complete Dragon Slayer",
|
||||
"description": "Complete the Dragon Slayer quest",
|
||||
"points": 50,
|
||||
"difficulty": "Medium",
|
||||
"category": "Quest",
|
||||
"regions": ["Misthalin", "Asgarnia"],
|
||||
"skillRequirements": [
|
||||
{"skill": "Quest Points", "level": 32}
|
||||
],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"name": "Enter the Chambers of Xeric",
|
||||
"description": "Enter the Chambers of Xeric raid",
|
||||
"points": 100,
|
||||
"difficulty": "Hard",
|
||||
"category": "Combat",
|
||||
"regions": ["Kourend"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [102],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "Complete Any Raid",
|
||||
"description": "Complete any raid in the game",
|
||||
"points": 50,
|
||||
"difficulty": "Medium",
|
||||
"category": "Combat",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"name": "Complete Theatre of Blood",
|
||||
"description": "Complete the Theatre of Blood raid",
|
||||
"points": 250,
|
||||
"difficulty": "Elite",
|
||||
"category": "Combat",
|
||||
"regions": ["Morytania"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [102],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 200,
|
||||
"name": "Catch a Shrimp",
|
||||
"description": "Catch raw shrimp while fishing",
|
||||
"points": 10,
|
||||
"difficulty": "Easy",
|
||||
"category": "Skilling",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 201,
|
||||
"name": "Catch a Lobster",
|
||||
"description": "Catch a raw lobster while fishing",
|
||||
"points": 25,
|
||||
"difficulty": "Easy",
|
||||
"category": "Skilling",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [
|
||||
{"skill": "Fishing", "level": 40}
|
||||
],
|
||||
"autoCompletes": [200],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 202,
|
||||
"name": "Catch a Shark",
|
||||
"description": "Catch a raw shark while fishing",
|
||||
"points": 50,
|
||||
"difficulty": "Medium",
|
||||
"category": "Skilling",
|
||||
"regions": ["Global"],
|
||||
"skillRequirements": [
|
||||
{"skill": "Fishing", "level": 76}
|
||||
],
|
||||
"autoCompletes": [200, 201],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 300,
|
||||
"name": "Enter TzHaar City",
|
||||
"description": "Enter the TzHaar City beneath Karamja",
|
||||
"points": 25,
|
||||
"difficulty": "Easy",
|
||||
"category": "Exploration",
|
||||
"regions": ["Karamja"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 301,
|
||||
"name": "Complete the Fight Caves",
|
||||
"description": "Defeat TzTok-Jad and obtain a Fire Cape",
|
||||
"points": 250,
|
||||
"difficulty": "Elite",
|
||||
"category": "Combat",
|
||||
"regions": ["Karamja"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [300],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 302,
|
||||
"name": "Complete the Inferno",
|
||||
"description": "Defeat TzKal-Zuk and obtain an Infernal Cape",
|
||||
"points": 500,
|
||||
"difficulty": "Master",
|
||||
"category": "Combat",
|
||||
"regions": ["Karamja"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [300, 301],
|
||||
"prerequisites": [301]
|
||||
},
|
||||
{
|
||||
"id": 400,
|
||||
"name": "Visit Prifddinas",
|
||||
"description": "Enter the elven city of Prifddinas",
|
||||
"points": 100,
|
||||
"difficulty": "Hard",
|
||||
"category": "Exploration",
|
||||
"regions": ["Tirannwn"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [],
|
||||
"prerequisites": []
|
||||
},
|
||||
{
|
||||
"id": 401,
|
||||
"name": "Complete The Gauntlet",
|
||||
"description": "Complete The Gauntlet in Prifddinas",
|
||||
"points": 150,
|
||||
"difficulty": "Hard",
|
||||
"category": "Combat",
|
||||
"regions": ["Tirannwn"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [400],
|
||||
"prerequisites": [400]
|
||||
},
|
||||
{
|
||||
"id": 402,
|
||||
"name": "Complete The Corrupted Gauntlet",
|
||||
"description": "Complete The Corrupted Gauntlet in Prifddinas",
|
||||
"points": 250,
|
||||
"difficulty": "Elite",
|
||||
"category": "Combat",
|
||||
"regions": ["Tirannwn"],
|
||||
"skillRequirements": [],
|
||||
"autoCompletes": [400, 401],
|
||||
"prerequisites": [401]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,101 +1,119 @@
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector, batch } from 'react-redux';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { resetState } from '../store/common';
|
||||
import { load as loadSettingsState, loadState as loadSettingsLocalState } from '../store/settings/settings';
|
||||
import { load as loadTasksState, loadState as loadTasksLocalState } from '../store/tasks/tasks';
|
||||
import { load as loadUnlocksState, loadState as loadUnlocksLocalState } from '../store/unlocks/unlocks';
|
||||
import {
|
||||
fetchHiscores,
|
||||
load as loadCharacterState,
|
||||
loadState as loadCharacterLocalState,
|
||||
} from '../store/user/character';
|
||||
import { updateAccountCache } from '../store/user/account';
|
||||
import { createUserIfNeeded, getUser } from '../client/user-data-client';
|
||||
import { INITIAL_STATE as INITIAL_TASKS_STATE } from '../store/tasks/constants';
|
||||
import { INITIAL_STATE as INITIAL_UNLOCKS_STATE } from '../store/unlocks/constants';
|
||||
import { INITIAL_STATE as INITIAL_CHARACTER_STATE , INITIAL_STATE as INITIAL_SETTINGS_STATE } from '../store/user/constants';
|
||||
import updateTasksVersion from '../store/tasks/updateTasksVersion';
|
||||
import updateCharacterVersion from '../store/user/updateCharacterVersion';
|
||||
import updateUnlocksVersion from '../store/unlocks/updateUnlocksVersion';
|
||||
import { setLoggedIn, setLoggedOut } from '../store/user/account';
|
||||
import { syncFromServer } from '../store/user/character';
|
||||
import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client';
|
||||
import { syncCharacters, getCharacters } from '../client/character-client';
|
||||
|
||||
export default function useAccount({ redirectReturnToUrl }) {
|
||||
const {
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
user,
|
||||
loginWithRedirect,
|
||||
logout: logoutWithRedirect,
|
||||
getAccessTokenSilently,
|
||||
} = useAuth0();
|
||||
const isLoginCache = useSelector(state => state.account.accountCache.isLoggedIn);
|
||||
const accessToken = useSelector(state => state.account.accountCache.accessToken);
|
||||
export default function useAccount() {
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const hasSyncedRef = useRef(false);
|
||||
const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn);
|
||||
const isChecking = useSelector(state => state.account.accountCache.isChecking);
|
||||
const username = useSelector(state => state.account.accountCache.username);
|
||||
const userEmail = useSelector(state => state.account.accountCache.userEmail);
|
||||
const userRole = useSelector(state => state.account.accountCache.userRole);
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
const localCharacters = useSelector(state => state.character.characters);
|
||||
const localActiveCharacter = useSelector(state => state.character.activeCharacter);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getAccessTokenSilently().then(token => {
|
||||
dispatch(updateAccountCache({ isAuthenticated, user, accessToken: token }));
|
||||
});
|
||||
} else {
|
||||
updateAccountCache({ isAuthenticated, user, accessToken: undefined });
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
getAuthStatus().then(result => {
|
||||
if (result.success && result.value?.authenticated) {
|
||||
getCurrentUser().then(userResult => {
|
||||
if (userResult.success) {
|
||||
dispatch(setLoggedIn({
|
||||
username: userResult.value.username,
|
||||
email: userResult.value.email,
|
||||
role: userResult.value.role,
|
||||
}));
|
||||
} else {
|
||||
dispatch(setLoggedOut());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dispatch(setLoggedOut());
|
||||
}
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const isLoggedIn = isLoginCache || isAuthenticated;
|
||||
// Sync characters once when user logs in
|
||||
useEffect(() => {
|
||||
if (isLoginCache && accessToken) {
|
||||
getUser(user.email, accessToken).then(res => {
|
||||
if (res.success) {
|
||||
// user exists already, load their data and overwrite existing
|
||||
batch(() => {
|
||||
const settingsState = res.value.settings?.S ? JSON.parse(res.value.settings?.S) : INITIAL_SETTINGS_STATE;
|
||||
const characterState = updateCharacterVersion(
|
||||
res.value.character?.S ? JSON.parse(res.value.character?.S) : INITIAL_CHARACTER_STATE
|
||||
);
|
||||
const activeCharacter = characterState.characters[characterState.activeCharacter] ?? 'DEFAULT';
|
||||
const taskState = updateTasksVersion(
|
||||
res.value[`tasks_${activeCharacter}`]?.S
|
||||
? JSON.parse(res.value[`tasks_${activeCharacter}`].S)
|
||||
: INITIAL_TASKS_STATE
|
||||
);
|
||||
const unlocksState = updateUnlocksVersion(
|
||||
res.value[`unlocks_${activeCharacter}`]?.S
|
||||
? JSON.parse(res.value[`unlocks_${activeCharacter}`].S)
|
||||
: INITIAL_UNLOCKS_STATE
|
||||
);
|
||||
dispatch(loadTasksState({ forceOverwrite: true, newState: taskState, skipDbUpdate: true }));
|
||||
dispatch(loadSettingsState({ forceOverwrite: true, newState: settingsState, skipDbUpdate: true }));
|
||||
dispatch(loadUnlocksState({ forceOverwrite: true, newState: unlocksState, skipDbUpdate: true }));
|
||||
dispatch(loadCharacterState({ forceOverwrite: true, newState: characterState, skipDbUpdate: true }));
|
||||
dispatch(fetchHiscores(characterState, null, true));
|
||||
});
|
||||
if (isLoggedIn && !hasSyncedRef.current) {
|
||||
hasSyncedRef.current = true;
|
||||
|
||||
const doSync = async () => {
|
||||
if (localCharacters.length > 0) {
|
||||
const result = await syncCharacters(
|
||||
localCharacters.map(rsn => ({ rsn })),
|
||||
localActiveCharacter
|
||||
);
|
||||
if (result.success) {
|
||||
const activeIndex = result.value.characters.findIndex(c => c.isActive);
|
||||
dispatch(syncFromServer({
|
||||
characters: result.value.characters,
|
||||
activeIndex: activeIndex >= 0 ? activeIndex : 0,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// user does not exist, create them and reload the current local state to save it to the DB
|
||||
createUserIfNeeded(user.email, accessToken).then(result => {
|
||||
if (result.success) {
|
||||
batch(() => {
|
||||
dispatch(loadTasksState({ forceOverwrite: true, newState: loadTasksLocalState() }));
|
||||
dispatch(loadSettingsState({ forceOverwrite: true, newState: loadSettingsLocalState() }));
|
||||
dispatch(loadUnlocksState({ forceOverwrite: true, newState: loadUnlocksLocalState() }));
|
||||
dispatch(loadCharacterState({ forceOverwrite: true, newState: loadCharacterLocalState() }));
|
||||
});
|
||||
}
|
||||
});
|
||||
const result = await getCharacters();
|
||||
if (result.success && result.value.characters.length > 0) {
|
||||
const activeIndex = result.value.characters.findIndex(c => c.isActive);
|
||||
dispatch(syncFromServer({
|
||||
characters: result.value.characters,
|
||||
activeIndex: activeIndex >= 0 ? activeIndex : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
doSync();
|
||||
}
|
||||
}, [isLoginCache, accessToken]);
|
||||
|
||||
const isAuthenticating = isLoading;
|
||||
// Reset sync flag on logout
|
||||
if (!isLoggedIn) {
|
||||
hasSyncedRef.current = false;
|
||||
}
|
||||
}, [isLoggedIn, localCharacters, localActiveCharacter, dispatch]);
|
||||
|
||||
const login = () => {
|
||||
loginWithRedirect({ redirect_uri: redirectReturnToUrl });
|
||||
};
|
||||
const logout = () => {
|
||||
logoutWithRedirect({ returnTo: redirectReturnToUrl });
|
||||
resetState(dispatch, true);
|
||||
const handleAuthSuccess = userData => {
|
||||
dispatch(setLoggedIn({
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
}));
|
||||
// Character sync will be triggered by the isLoggedIn effect
|
||||
};
|
||||
|
||||
return { isLoggedIn, isAuthenticating, login, logout };
|
||||
const openAuthModal = () => {
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
const closeAuthModal = () => {
|
||||
setAuthModalOpen(false);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const result = await logoutApi();
|
||||
if (result.success) {
|
||||
dispatch(setLoggedOut());
|
||||
resetState(dispatch, true);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isAuthenticating: isChecking,
|
||||
username,
|
||||
userEmail,
|
||||
userRole,
|
||||
isAdmin,
|
||||
authModalOpen,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
handleAuthSuccess,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
334
os-league-tools-master/src/pages/Admin.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import useAccount from '../hooks/useAccount';
|
||||
import {
|
||||
getAdminStats,
|
||||
getUsers,
|
||||
updateUser,
|
||||
resetUserPassword,
|
||||
deleteUser,
|
||||
invalidateUserSessions,
|
||||
} from '../client/admin-client';
|
||||
|
||||
function StatCard({ label, value, icon }) {
|
||||
return (
|
||||
<div className='bg-secondary p-4 rounded-lg'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='icon-lg text-primary-alt'>{icon}</span>
|
||||
<div>
|
||||
<p className='text-2xl font-bold text-accent'>{value}</p>
|
||||
<p className='text-sm text-secondary'>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ user, onUpdate, onDelete }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editRole, setEditRole] = useState(user.role);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
setLoading(true);
|
||||
const result = await updateUser(user.id, { role: editRole });
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onUpdate();
|
||||
setIsEditing(false);
|
||||
setMessage({ type: 'success', text: 'Role updated' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handlePasswordReset = async () => {
|
||||
if (newPassword.length < 8) {
|
||||
setMessage({ type: 'error', text: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await resetUserPassword(user.id, newPassword);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setShowPasswordReset(false);
|
||||
setNewPassword('');
|
||||
setMessage({ type: 'success', text: 'Password reset and sessions invalidated' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleInvalidateSessions = async () => {
|
||||
setLoading(true);
|
||||
const result = await invalidateUserSessions(user.id);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.value.message });
|
||||
onUpdate();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Are you sure you want to delete user "${user.username}"?`)) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await deleteUser(user.id);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onDelete();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className='border-b border-primary'>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='font-medium text-accent'>{user.username}</div>
|
||||
<div className='text-xs text-secondary'>{user.email}</div>
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={e => setEditRole(e.target.value)}
|
||||
className='input-primary text-sm'
|
||||
>
|
||||
<option value='USER'>USER</option>
|
||||
<option value='ADMIN'>ADMIN</option>
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.role === 'ADMIN' ? 'bg-accent text-black' : 'bg-secondary text-primary'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-secondary'>{user.sessionCount}</td>
|
||||
<td className='py-3 px-4 text-sm text-secondary'>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRoleChange}
|
||||
disabled={loading}
|
||||
className='btn-primary text-xs px-2 py-1'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditRole(user.role);
|
||||
}}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsEditing(true)}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Edit role'
|
||||
>
|
||||
<span className='icon-sm'>edit</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPasswordReset(!showPasswordReset)}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Reset password'
|
||||
>
|
||||
<span className='icon-sm'>key</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleInvalidateSessions}
|
||||
disabled={loading}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Invalidate sessions'
|
||||
>
|
||||
<span className='icon-sm'>logout</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className='btn-danger text-xs px-2 py-1'
|
||||
title='Delete user'
|
||||
>
|
||||
<span className='icon-sm'>delete</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showPasswordReset && (
|
||||
<div className='mt-2 flex gap-2'>
|
||||
<input
|
||||
type='password'
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder='New password'
|
||||
className='input-primary text-sm flex-1'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePasswordReset}
|
||||
disabled={loading}
|
||||
className='btn-primary text-xs px-2 py-1'
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={`mt-2 text-xs ${message.type === 'error' ? 'text-error' : 'text-success'}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { isLoggedIn, isAuthenticating, isAdmin } = useAccount();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const [statsResult, usersResult] = await Promise.all([getAdminStats(), getUsers()]);
|
||||
|
||||
if (statsResult.success) {
|
||||
setStats(statsResult.value);
|
||||
} else {
|
||||
setError(statsResult.error);
|
||||
}
|
||||
|
||||
if (usersResult.success) {
|
||||
setUsers(usersResult.value.users);
|
||||
} else {
|
||||
setError(usersResult.error);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// Redirect non-admins
|
||||
if (!isAuthenticating && (!isLoggedIn || !isAdmin)) {
|
||||
return <Navigate to='/' replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className='max-w-6xl mx-auto p-4'>
|
||||
<h1 className='text-2xl font-bold text-accent mb-6 flex items-center gap-2'>
|
||||
<span className='icon-lg'>admin_panel_settings</span>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
|
||||
{loading && (
|
||||
<div className='text-center py-8'>
|
||||
<span className='icon-lg animate-spin'>sync</span>
|
||||
<p className='text-secondary mt-2'>Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className='bg-error/20 border border-error rounded p-4 mb-6'>
|
||||
<p className='text-error'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && stats && (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className='grid grid-cols-2 md:grid-cols-4 gap-4 mb-8'>
|
||||
<StatCard label='Users' value={stats.users} icon='people' />
|
||||
<StatCard label='Active Sessions' value={stats.activeSessions} icon='key' />
|
||||
<StatCard label='Groups' value={stats.groups} icon='groups' />
|
||||
<StatCard label='Members' value={stats.members} icon='person' />
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className='bg-secondary rounded-lg overflow-hidden'>
|
||||
<div className='p-4 border-b border-primary'>
|
||||
<h2 className='text-lg font-semibold text-primary flex items-center gap-2'>
|
||||
<span className='icon-base'>people</span>
|
||||
Users ({users.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full'>
|
||||
<thead className='bg-secondary-alt'>
|
||||
<tr>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
User
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Role
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Sessions
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Created
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onUpdate={fetchData}
|
||||
onDelete={fetchData}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -79,16 +79,31 @@ export default function Homepage() {
|
||||
|
||||
|
||||
<p className='text-3xl small-caps ml-1 mt-6'>Updates</p>
|
||||
{newsPosts.map(newsPost => (
|
||||
<NewsCard
|
||||
key={newsPost.title}
|
||||
title={newsPost.title}
|
||||
date={newsPost.date}
|
||||
coverImg={newsPost.thumbnail}
|
||||
leadText={newsPost.leadText}
|
||||
htmlContent={newsPost.htmlContent}
|
||||
/>
|
||||
))}
|
||||
{newsPosts.map((newsPost, index) =>
|
||||
newsPost.type === 'separator' ? (
|
||||
<div key={`separator-${index}`} className='my-10 mx-auto max-w-5xl'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex-1 h-px bg-gray-600' />
|
||||
<div className='text-center'>
|
||||
<p className='text-xl font-semibold text-gray-400'>{newsPost.title}</p>
|
||||
{newsPost.subtitle && (
|
||||
<p className='text-sm text-gray-500 italic'>{newsPost.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 h-px bg-gray-600' />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NewsCard
|
||||
key={newsPost.title}
|
||||
title={newsPost.title}
|
||||
date={newsPost.date}
|
||||
coverImg={newsPost.thumbnail}
|
||||
leadText={newsPost.leadText}
|
||||
htmlContent={newsPost.htmlContent}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
412
os-league-tools-master/src/pages/Planner.js
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import Card from '../components/common/Card';
|
||||
import images from '../assets/images';
|
||||
import plannerRegions from '../data/planner/plannerRegions.json';
|
||||
import plannerTasks from '../data/planner/plannerTasks.json';
|
||||
import plannerItems from '../data/planner/plannerItems.json';
|
||||
import plannerRelics from '../data/planner/plannerRelics.json';
|
||||
|
||||
// Constants
|
||||
const MAX_REGIONS = 5;
|
||||
|
||||
// Map region names to their badge image keys
|
||||
const getRegionBadge = regionName => {
|
||||
const badgeKey = `${regionName}_Area_Badge.png`;
|
||||
return images[badgeKey];
|
||||
};
|
||||
|
||||
// Get default region IDs
|
||||
const DEFAULT_REGION_IDS = new Set(plannerRegions.regions.filter(r => r.isDefault).map(r => r.id));
|
||||
|
||||
export default function Planner() {
|
||||
// Region selection state - default regions are pre-selected
|
||||
const [selectedRegions, setSelectedRegions] = useState(() => {
|
||||
const defaults = plannerRegions.regions
|
||||
.filter(r => r.isDefault)
|
||||
.map(r => r.id);
|
||||
return new Set(defaults);
|
||||
});
|
||||
|
||||
// Task completion state
|
||||
const [completedTasks, setCompletedTasks] = useState(new Set());
|
||||
|
||||
// Toast notification state
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Auto-hide toast after 3 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [toast]);
|
||||
|
||||
// Show toast notification
|
||||
const showToast = useCallback((message, type = 'error') => {
|
||||
setToast({ message, type, key: Date.now() });
|
||||
}, []);
|
||||
|
||||
// Toggle region selection
|
||||
const toggleRegion = regionId => {
|
||||
// Prevent removing default regions
|
||||
if (DEFAULT_REGION_IDS.has(regionId) && selectedRegions.has(regionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to add beyond max
|
||||
if (!selectedRegions.has(regionId) && selectedRegions.size >= MAX_REGIONS) {
|
||||
showToast(`You cannot select more than ${MAX_REGIONS} regions!`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRegions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(regionId)) {
|
||||
next.delete(regionId);
|
||||
} else {
|
||||
next.add(regionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Get region names for filtering
|
||||
const selectedRegionNames = useMemo(() => {
|
||||
const names = new Set(['Global']);
|
||||
plannerRegions.regions.forEach(region => {
|
||||
if (selectedRegions.has(region.id)) {
|
||||
names.add(region.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}, [selectedRegions]);
|
||||
|
||||
// Filter tasks based on selected regions
|
||||
const availableTasks = useMemo(
|
||||
() => plannerTasks.tasks.filter(task => task.regions.some(region => selectedRegionNames.has(region))),
|
||||
[selectedRegionNames]
|
||||
);
|
||||
|
||||
// Filter items based on selected regions
|
||||
const availableItems = useMemo(
|
||||
() => plannerItems.items.filter(item => item.regions.some(region => selectedRegionNames.has(region))),
|
||||
[selectedRegionNames]
|
||||
);
|
||||
|
||||
// Handle task completion with cascading logic
|
||||
const toggleTaskCompletion = useCallback(taskId => {
|
||||
setCompletedTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
const task = plannerTasks.tasks.find(t => t.id === taskId);
|
||||
|
||||
if (!task) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (next.has(taskId)) {
|
||||
// Uncompleting a task - just remove it
|
||||
next.delete(taskId);
|
||||
} else {
|
||||
// Completing a task - also complete any tasks in autoCompletes
|
||||
next.add(taskId);
|
||||
if (task.autoCompletes && task.autoCompletes.length > 0) {
|
||||
task.autoCompletes.forEach(autoId => {
|
||||
next.add(autoId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check if a task is locked (prerequisites not met)
|
||||
const isTaskLocked = useCallback(task => {
|
||||
if (!task.prerequisites || task.prerequisites.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return task.prerequisites.some(prereqId => !completedTasks.has(prereqId));
|
||||
}, [completedTasks]);
|
||||
|
||||
// Calculate total points from completed tasks
|
||||
const totalPoints = useMemo(
|
||||
() => availableTasks.filter(task => completedTasks.has(task.id)).reduce((sum, task) => sum + task.points, 0),
|
||||
[availableTasks, completedTasks]
|
||||
);
|
||||
|
||||
// Get difficulty color class
|
||||
const getDifficultyColor = difficulty => {
|
||||
const colors = {
|
||||
Easy: 'text-green-400',
|
||||
Medium: 'text-yellow-400',
|
||||
Hard: 'text-orange-400',
|
||||
Elite: 'text-red-400',
|
||||
Master: 'text-purple-400',
|
||||
};
|
||||
return colors[difficulty] || 'text-gray-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.key}
|
||||
className='fixed top-4 right-4 z-50 animate-jiggle'
|
||||
style={{
|
||||
animation: 'jiggle 0.5s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div className='bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2'>
|
||||
<span className='text-lg'>⚠️</span>
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jiggle animation styles */}
|
||||
<style>{`
|
||||
@keyframes jiggle {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className='p-4'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-bold mb-2'>League Planner</h1>
|
||||
<p className='text-sm text-secondary'>
|
||||
Select regions to see available tasks and items. Completing certain tasks will automatically mark related tasks as complete.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Points Display */}
|
||||
<div className='mb-6 p-4 bg-secondary rounded-lg'>
|
||||
<div className='text-lg font-semibold'>
|
||||
Total Points: <span className='text-accent'>{totalPoints}</span>
|
||||
</div>
|
||||
<div className='text-sm text-secondary'>
|
||||
Tasks Completed: {completedTasks.size} / {availableTasks.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region Selector - Right anchored card with badges, 1 per row */}
|
||||
<div className='w-full flex justify-end mb-6'>
|
||||
<div className='bg-secondary rounded-lg p-3 border border-gray-700'>
|
||||
<div className='text-xs text-secondary mb-2 text-right'>Select Regions</div>
|
||||
<div className='text-xs text-secondary mb-1 text-right'>
|
||||
{selectedRegions.size} / {MAX_REGIONS} selected
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{plannerRegions.regions.map(region => {
|
||||
const isSelected = selectedRegions.has(region.id);
|
||||
const { isDefault } = region;
|
||||
const badge = getRegionBadge(region.name);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={region.id}
|
||||
type='button'
|
||||
onClick={() => toggleRegion(region.id)}
|
||||
className={`flex items-center justify-end gap-2 px-2 py-1 rounded transition-all duration-200 ${
|
||||
isDefault ? 'cursor-default' : 'hover:bg-gray-700 cursor-pointer'
|
||||
} ${isSelected ? 'opacity-100' : 'opacity-40'}`}
|
||||
title={isDefault ? 'Default region (cannot be removed)' : region.name}
|
||||
>
|
||||
<span className={`text-sm ${isSelected ? 'text-white' : 'text-gray-400'}`}>
|
||||
{region.name}
|
||||
{isDefault && <span className='ml-1 text-yellow-400' title='Default region'>🔒</span>}
|
||||
</span>
|
||||
<img
|
||||
src={badge}
|
||||
alt={region.name}
|
||||
width='20'
|
||||
height='30'
|
||||
decoding='async'
|
||||
loading='lazy'
|
||||
className={`${isSelected ? '' : 'grayscale'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||
{/* Available Tasks */}
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold mb-3'>
|
||||
Available Tasks ({availableTasks.length})
|
||||
</h2>
|
||||
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
|
||||
{availableTasks.map(task => {
|
||||
const isCompleted = completedTasks.has(task.id);
|
||||
const isLocked = isTaskLocked(task);
|
||||
const hasAutoCompletes = task.autoCompletes && task.autoCompletes.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
isCompleted
|
||||
? 'bg-green-900/30 border-green-700'
|
||||
: isLocked
|
||||
? 'bg-gray-800/50 border-gray-700 opacity-50'
|
||||
: 'bg-gray-800 border-gray-700 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isCompleted}
|
||||
disabled={isLocked}
|
||||
onChange={() => toggleTaskCompletion(task.id)}
|
||||
className='mt-1 h-4 w-4 rounded'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<span className={`font-medium ${isCompleted ? 'line-through opacity-70' : ''}`}>
|
||||
{task.name}
|
||||
</span>
|
||||
<span className={`text-xs ${getDifficultyColor(task.difficulty)}`}>
|
||||
{task.difficulty}
|
||||
</span>
|
||||
<span className='text-xs text-accent'>
|
||||
{task.points} pts
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-secondary mt-1'>{task.description}</p>
|
||||
{hasAutoCompletes && (
|
||||
<p className='text-xs text-blue-400 mt-1'>
|
||||
⚡ Auto-completes {task.autoCompletes.length} other task(s)
|
||||
</p>
|
||||
)}
|
||||
{isLocked && (
|
||||
<p className='text-xs text-red-400 mt-1'>
|
||||
🔒 Requires prerequisite tasks
|
||||
</p>
|
||||
)}
|
||||
<div className='flex gap-1 mt-1 flex-wrap'>
|
||||
{task.regions.map(region => (
|
||||
<span
|
||||
key={region}
|
||||
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
|
||||
>
|
||||
{region}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Items */}
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold mb-3'>
|
||||
Available Items ({availableItems.length})
|
||||
</h2>
|
||||
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
|
||||
{availableItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='p-3 rounded-lg bg-gray-800 border border-gray-700'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium'>{item.name}</span>
|
||||
<span className='text-xs px-2 py-0.5 bg-gray-700 rounded'>
|
||||
{item.slot}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-secondary mt-1'>
|
||||
{item.obtainedFrom}
|
||||
</p>
|
||||
{item.requirements.length > 0 && (
|
||||
<div className='flex gap-1 mt-1 flex-wrap'>
|
||||
{item.requirements.map((req, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className='text-xs px-1.5 py-0.5 bg-blue-900/50 rounded'
|
||||
>
|
||||
{req.skill} {req.level}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.relicUnlock && (
|
||||
<p className='text-xs text-purple-400 mt-1'>
|
||||
🔮 Requires relic unlock
|
||||
</p>
|
||||
)}
|
||||
<div className='flex gap-1 mt-1'>
|
||||
{item.regions.map(region => (
|
||||
<span
|
||||
key={region}
|
||||
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
|
||||
>
|
||||
{region}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relics Section */}
|
||||
<div className='mt-6'>
|
||||
<h2 className='text-lg font-semibold mb-3'>Relic Tiers</h2>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3'>
|
||||
{plannerRelics.relicTiers.map(tier => {
|
||||
const relicsInTier = plannerRelics.relics.filter(r => r.tier === tier.tier);
|
||||
const isUnlocked = totalPoints >= tier.pointsRequired;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.tier}
|
||||
className={`p-3 rounded-lg border ${
|
||||
isUnlocked
|
||||
? 'bg-purple-900/30 border-purple-700'
|
||||
: 'bg-gray-800/50 border-gray-700 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<span className='font-medium'>Tier {tier.tier}</span>
|
||||
<span className='text-xs text-accent'>
|
||||
{tier.pointsRequired} pts
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-secondary mb-2'>{tier.name}</p>
|
||||
<div className='space-y-1'>
|
||||
{relicsInTier.slice(0, 3).map(relic => (
|
||||
<div key={relic.id} className='text-xs text-gray-400'>
|
||||
• {relic.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isUnlocked && (
|
||||
<p className='text-xs text-yellow-500 mt-2'>
|
||||
Need {tier.pointsRequired - totalPoints} more points
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
184
os-league-tools-master/src/pages/ResetPassword.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import Card from '../components/common/Card';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import Spinner from '../components/common/Spinner';
|
||||
import { resetPassword } from '../client/auth-client';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await resetPassword(token, password);
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(result.error || 'Failed to reset password');
|
||||
}
|
||||
};
|
||||
|
||||
// No token provided
|
||||
if (!token) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className='container lg:max-w-[500px] mx-auto mt-8'>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
|
||||
Invalid Reset Link
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p className='text-center text-secondary text-sm p-4'>
|
||||
This password reset link is invalid or has expired.
|
||||
Please request a new password reset from the login page.
|
||||
</p>
|
||||
<div className='flex justify-center pb-4'>
|
||||
<button
|
||||
type='button'
|
||||
className='button-filled px-6 py-2'
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Go to Homepage
|
||||
</button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className='container lg:max-w-[500px] mx-auto mt-8'>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
|
||||
Password Reset
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-success text-sm mb-4'>
|
||||
Your password has been reset successfully!
|
||||
</p>
|
||||
<p className='text-secondary text-sm mb-4'>
|
||||
You can now log in with your new password.
|
||||
</p>
|
||||
<button
|
||||
type='button'
|
||||
className='button-filled px-6 py-2'
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Go to Homepage
|
||||
</button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className='container lg:max-w-[500px] mx-auto mt-8'>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
|
||||
Reset Password
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<form onSubmit={handleSubmit} className='p-4 flex flex-col gap-4'>
|
||||
<p className='text-secondary text-sm text-center'>
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
<label htmlFor='new-password' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>New Password</span>
|
||||
<input
|
||||
id='new-password'
|
||||
name='new-password'
|
||||
type='password'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Enter new password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor='confirm-password' className='flex flex-col gap-1'>
|
||||
<span className='text-secondary text-xs'>Confirm Password</span>
|
||||
<input
|
||||
id='confirm-password'
|
||||
name='confirm-password'
|
||||
type='password'
|
||||
className='input-primary text-sm form-input'
|
||||
placeholder='Confirm new password'
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoComplete='new-password'
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className='text-error text-xs text-center'>{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
className='button-filled py-2'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center justify-center gap-2'>
|
||||
<Spinner size={Spinner.SIZE.sm} /> Resetting...
|
||||
</span>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
15
os-league-tools-master/src/setupProxy.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
// Proxy API requests to the Node.js backend
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_RELDO_URL || 'http://localhost:3003',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// Pass cookies for session auth
|
||||
cookieDomainRewrite: 'localhost',
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,49 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { LOCALSTORAGE_KEYS } from '../client/localstorage-client';
|
||||
import { updateCharacter } from '../client/character-client';
|
||||
|
||||
// Debounce server sync to avoid too many requests
|
||||
let syncTimeout = null;
|
||||
const SYNC_DEBOUNCE_MS = 2000;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export default function updateWithUserDataStorage(wrappedDispatchFn, wrappedFnProps, localstorageKey, stateKey) {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(wrappedDispatchFn(wrappedFnProps));
|
||||
|
||||
// TODO re-enable user login
|
||||
// const dataKey = (() => {
|
||||
// switch (localstorageKey) {
|
||||
// case LOCALSTORAGE_KEYS.UNLOCKS:
|
||||
// case LOCALSTORAGE_KEYS.TASKS: {
|
||||
// const state = getState();
|
||||
// return `${localstorageKey}_${state.character.characters[state.character.activeCharacter] ?? 'DEFAULT'}`;
|
||||
// }
|
||||
// default: {
|
||||
// return localstorageKey;
|
||||
// }
|
||||
// }
|
||||
// })();
|
||||
const state = getState();
|
||||
const { isLoggedIn } = state.account.accountCache;
|
||||
|
||||
// const { isLoggedIn, userEmail, accessToken } = getState().account.accountCache;
|
||||
// if (isLoggedIn && !wrappedFnProps.skipDbUpdate) {
|
||||
// putUserData(userEmail, dataKey, getState()[stateKey], accessToken);
|
||||
// }
|
||||
// Sync to server if logged in and not skipping DB update
|
||||
if (isLoggedIn && !wrappedFnProps?.skipDbUpdate) {
|
||||
// Only sync tasks and unlocks data
|
||||
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS || localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
|
||||
const activeRsn = state.character.characters[state.character.activeCharacter];
|
||||
const characterId = state.character.characterIds[activeRsn];
|
||||
|
||||
if (characterId) {
|
||||
// Debounce the sync to avoid too many requests
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout);
|
||||
}
|
||||
|
||||
syncTimeout = setTimeout(async () => {
|
||||
const currentState = getState();
|
||||
const updateData = {};
|
||||
|
||||
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS) {
|
||||
updateData.tasksData = currentState.tasks;
|
||||
} else if (localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
|
||||
updateData.unlocksData = currentState.unlocks;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCharacter(characterId, updateData);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync data to server:', error);
|
||||
}
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const CURRENT_VERSION = 3;
|
||||
export const CURRENT_VERSION = 5;
|
||||
|
||||
const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
accountCache: {
|
||||
isLoggedIn: false,
|
||||
isChecking: true,
|
||||
username: undefined,
|
||||
userEmail: undefined,
|
||||
accessToken: undefined,
|
||||
userRole: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,18 +19,30 @@ export const accountSlice = createSlice({
|
||||
name: 'account',
|
||||
initialState: INITIAL_STATE,
|
||||
reducers: {
|
||||
updateAccountCache: (state, action) => {
|
||||
state.accountCache.isLoggedIn = action.payload.isAuthenticated;
|
||||
state.accountCache.userEmail = action.payload.isAuthenticated ? action.payload.user.email : undefined;
|
||||
state.accountCache.accessToken = action.payload.isAuthenticated ? action.payload.accessToken : undefined;
|
||||
setAuthChecking: (state, action) => {
|
||||
state.accountCache.isChecking = action.payload;
|
||||
},
|
||||
setLoggedIn: (state, action) => {
|
||||
state.accountCache.isLoggedIn = true;
|
||||
state.accountCache.isChecking = false;
|
||||
state.accountCache.username = action.payload.username;
|
||||
state.accountCache.userEmail = action.payload.email;
|
||||
state.accountCache.userRole = action.payload.role;
|
||||
},
|
||||
setLoggedOut: state => {
|
||||
state.accountCache.isLoggedIn = false;
|
||||
state.accountCache.isChecking = false;
|
||||
state.accountCache.username = undefined;
|
||||
state.accountCache.userEmail = undefined;
|
||||
state.accountCache.userRole = undefined;
|
||||
},
|
||||
reset: () => INITIAL_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
// Don't cache anything across sessions, let auth0 handle it
|
||||
// Don't cache anything across sessions, session cookie handles it
|
||||
export const loadState = () => INITIAL_STATE;
|
||||
|
||||
export const { updateAccountCache, reset } = accountSlice.actions;
|
||||
export const { setAuthChecking, setLoggedIn, setLoggedOut, reset } = accountSlice.actions;
|
||||
|
||||
export default accountSlice.reducer;
|
||||
|
||||
@@ -16,11 +16,18 @@ export const characterSlice = createSlice({
|
||||
},
|
||||
addCharacter: (state, action) => {
|
||||
state.characters.push(action.payload.rsn);
|
||||
if (action.payload.id) {
|
||||
state.characterIds[action.payload.rsn] = action.payload.id;
|
||||
}
|
||||
if (action.payload.setActive) {
|
||||
state.activeCharacter = state.characters.length - 1;
|
||||
}
|
||||
},
|
||||
deleteCharacter: (state, action) => {
|
||||
const rsn = state.characters[action.payload];
|
||||
if (rsn && state.characterIds[rsn]) {
|
||||
delete state.characterIds[rsn];
|
||||
}
|
||||
state.characters.splice(action.payload, 1);
|
||||
if (state.activeCharacter === action.payload) {
|
||||
state.activeCharacter = 0;
|
||||
@@ -29,8 +36,28 @@ export const characterSlice = createSlice({
|
||||
}
|
||||
},
|
||||
renameCharacter: (state, action) => {
|
||||
const oldRsn = state.characters[action.payload.index];
|
||||
const serverId = state.characterIds[oldRsn];
|
||||
if (serverId) {
|
||||
delete state.characterIds[oldRsn];
|
||||
state.characterIds[action.payload.rsn] = serverId;
|
||||
}
|
||||
state.characters[action.payload.index] = action.payload.rsn;
|
||||
},
|
||||
// Sync characters from server (replaces local state)
|
||||
syncFromServer: (state, action) => {
|
||||
const { characters, activeIndex } = action.payload;
|
||||
state.characters = characters.map(c => c.rsn);
|
||||
state.characterIds = {};
|
||||
characters.forEach(c => {
|
||||
state.characterIds[c.rsn] = c.id;
|
||||
});
|
||||
state.activeCharacter = activeIndex ?? 0;
|
||||
},
|
||||
// Set server ID for a character
|
||||
setCharacterId: (state, action) => {
|
||||
state.characterIds[action.payload.rsn] = action.payload.id;
|
||||
},
|
||||
updateHiscores: (state, action) => {
|
||||
switch (action.payload.type) {
|
||||
case 'LOADING':
|
||||
@@ -128,6 +155,6 @@ export function reset(props) {
|
||||
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.CHARACTER, 'character');
|
||||
}
|
||||
|
||||
export const { updateHiscores } = characterSlice.actions;
|
||||
export const { updateHiscores, syncFromServer, setCharacterId } = characterSlice.actions;
|
||||
|
||||
export default characterSlice.reducer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CURRENT_VERSION = 2;
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
export const HISCORES_TTL = 1800000; // 30 min in ms
|
||||
|
||||
@@ -13,5 +13,7 @@ export const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
activeCharacter: 0,
|
||||
characters: [],
|
||||
// Map of RSN -> server character ID (for sync)
|
||||
characterIds: {},
|
||||
hiscoresCache: INITIAL_HISCORES_STATE,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CURRENT_VERSION } from './constants';
|
||||
|
||||
const versionUpdaters = {
|
||||
2: updateToV2,
|
||||
3: updateToV3,
|
||||
};
|
||||
|
||||
export default function updateCharacterVersion(state) {
|
||||
@@ -27,3 +28,12 @@ function updateToV2(prevState) {
|
||||
characters: [prevState.username],
|
||||
};
|
||||
}
|
||||
|
||||
function updateToV3(prevState) {
|
||||
// V3 adds characterIds map for server sync
|
||||
return {
|
||||
...prevState,
|
||||
version: 3,
|
||||
characterIds: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -947,6 +947,16 @@ body:is(.dark *) {
|
||||
border-color: rgb(113 113 122 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-error {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(220 38 38 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-error:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
--tw-shadow-color: rgb(0 0 0 / 0.1);
|
||||
--tw-shadow: var(--tw-shadow-colored);
|
||||
@@ -2660,9 +2670,15 @@ select[multiple]:focus option:checked {
|
||||
.right-1 {
|
||||
right: 0.25rem;
|
||||
}
|
||||
.right-4 {
|
||||
right: 1rem;
|
||||
}
|
||||
.top-0 {
|
||||
top: 0px;
|
||||
}
|
||||
.top-4 {
|
||||
top: 1rem;
|
||||
}
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -2709,6 +2725,10 @@ select[multiple]:focus option:checked {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.my-10 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.my-2 {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -2732,6 +2752,9 @@ select[multiple]:focus option:checked {
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.mb-auto {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
@@ -2882,6 +2905,9 @@ select[multiple]:focus option:checked {
|
||||
.max-h-6 {
|
||||
max-height: 1.5rem;
|
||||
}
|
||||
.max-h-96 {
|
||||
max-height: 24rem;
|
||||
}
|
||||
.max-h-\[200px\] {
|
||||
max-height: 200px;
|
||||
}
|
||||
@@ -2951,6 +2977,9 @@ select[multiple]:focus option:checked {
|
||||
.w-px {
|
||||
width: 1px;
|
||||
}
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
.min-w-\[100px\] {
|
||||
min-width: 100px;
|
||||
}
|
||||
@@ -2960,6 +2989,9 @@ select[multiple]:focus option:checked {
|
||||
.max-w-5xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
.max-w-\[3\.5rem\] {
|
||||
max-width: 3.5rem;
|
||||
}
|
||||
@@ -3036,6 +3068,9 @@ select[multiple]:focus option:checked {
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -3098,6 +3133,9 @@ select[multiple]:focus option:checked {
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -3128,6 +3166,9 @@ select[multiple]:focus option:checked {
|
||||
.gap-5 {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.gap-px {
|
||||
gap: 1px;
|
||||
}
|
||||
@@ -3160,6 +3201,12 @@ select[multiple]:focus option:checked {
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.overflow-y-scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
@@ -3267,6 +3314,14 @@ select[multiple]:focus option:checked {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-green-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(21 128 61 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-purple-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(126 34 206 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-red-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
||||
@@ -3275,6 +3330,13 @@ select[multiple]:focus option:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-blue-900\/50 {
|
||||
background-color: rgb(30 58 138 / 0.5);
|
||||
}
|
||||
.bg-gray-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||
@@ -3283,6 +3345,9 @@ select[multiple]:focus option:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-800\/50 {
|
||||
background-color: rgb(31 41 55 / 0.5);
|
||||
}
|
||||
.bg-gray-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
|
||||
@@ -3291,10 +3356,20 @@ select[multiple]:focus option:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-green-900\/30 {
|
||||
background-color: rgb(20 83 45 / 0.3);
|
||||
}
|
||||
.bg-purple-900\/30 {
|
||||
background-color: rgb(88 28 135 / 0.3);
|
||||
}
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-900\/90 {
|
||||
background-color: rgb(127 29 29 / 0.9);
|
||||
}
|
||||
@@ -3332,6 +3407,10 @@ select[multiple]:focus option:checked {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.px-1\.5 {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
}
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
@@ -3348,6 +3427,10 @@ select[multiple]:focus option:checked {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
@@ -3405,6 +3488,9 @@ select[multiple]:focus option:checked {
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.indent-8 {
|
||||
text-indent: 2rem;
|
||||
}
|
||||
@@ -3461,6 +3547,9 @@ select[multiple]:focus option:checked {
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -3491,6 +3580,14 @@ select[multiple]:focus option:checked {
|
||||
.tracking-widest {
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.text-black {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-blue-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
||||
@@ -3499,6 +3596,10 @@ select[multiple]:focus option:checked {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
@@ -3507,6 +3608,14 @@ select[multiple]:focus option:checked {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-orange-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-purple-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(192 132 252 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-red-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 226 226 / var(--tw-text-opacity, 1));
|
||||
@@ -3523,16 +3632,42 @@ select[multiple]:focus option:checked {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-yellow-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-yellow-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.placeholder-gray-400::placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
|
||||
}
|
||||
.opacity-100 {
|
||||
opacity: 1;
|
||||
}
|
||||
.opacity-25 {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.opacity-60 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.opacity-70 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -3556,6 +3691,10 @@ select[multiple]:focus option:checked {
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
.grayscale {
|
||||
--tw-grayscale: grayscale(100%);
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
@@ -3564,11 +3703,22 @@ select[multiple]:focus option:checked {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.ease-in-out {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.odd\:bg-primary:nth-child(odd) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
@@ -3634,6 +3784,10 @@ select[multiple]:focus option:checked {
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
.hover\:border-gray-500:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.hover\:bg-blue-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
|
||||
@@ -3642,6 +3796,10 @@ select[multiple]:focus option:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-800:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
|
||||
@@ -3697,6 +3855,8 @@ select[multiple]:focus option:checked {
|
||||
max-width: 75%;
|
||||
}.md\:flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}.md\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}.md\:flex-row {
|
||||
@@ -3733,6 +3893,8 @@ select[multiple]:focus option:checked {
|
||||
flex-basis: 50%;
|
||||
}.lg\:basis-1\/4 {
|
||||
flex-basis: 25%;
|
||||
}.lg\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}.lg\:grid-cols-4 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=OS League Tools - OSRS Leagues Hub
|
||||
Description=OS League Tools - Prod Environment
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
@@ -13,7 +13,7 @@ WorkingDirectory=/home/sonder/leagues-tools/os-league-tools-master
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="PORT=3000"
|
||||
# Uncomment and set if you have a backend API:
|
||||
# Environment="REACT_APP_RELDO_URL=http://localhost:8080"
|
||||
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
|
||||
|
||||
# Start the application (serves the pre-built static files)
|
||||
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3000
|
||||
|
||||
1672
package-lock.json
generated
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "leagues-tools-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "npx pm2 start ecosystem.config.js",
|
||||
"stop": "npx pm2 stop all",
|
||||
"restart": "npx pm2 restart all",
|
||||
"status": "npx pm2 status",
|
||||
"logs": "npx pm2 logs",
|
||||
"logs:prod": "npx pm2 logs api-prod",
|
||||
"logs:dev": "npx pm2 logs api-dev",
|
||||
"logs:frontend": "npx pm2 logs frontend-dev",
|
||||
"kill": "npx pm2 kill"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pm2": "^6.0.14"
|
||||
}
|
||||
}
|
||||
23
server/.env
Normal file
@@ -0,0 +1,23 @@
|
||||
# Development environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (separate dev database)
|
||||
DATABASE_URL="file:./data-dev.db"
|
||||
|
||||
# Server
|
||||
PORT=3003
|
||||
|
||||
# Security
|
||||
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
|
||||
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
|
||||
|
||||
# CORS - allow React dev server
|
||||
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
|
||||
|
||||
# Captcha (disabled for dev)
|
||||
CAPTCHA_ENABLED=false
|
||||
CAPTCHA_SITEKEY=""
|
||||
CAPTCHA_SECRET=""
|
||||
|
||||
# Frontend build path (not used in dev - React dev server handles it)
|
||||
FRONTEND_BUILD_PATH="../os-league-tools-master/build"
|
||||
23
server/.env.development
Normal file
@@ -0,0 +1,23 @@
|
||||
# Development environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (separate dev database)
|
||||
DATABASE_URL="file:./data-dev.db"
|
||||
|
||||
# Server
|
||||
PORT=3003
|
||||
|
||||
# Security
|
||||
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
|
||||
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
|
||||
|
||||
# CORS - allow React dev server
|
||||
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
|
||||
|
||||
# Captcha (disabled for dev)
|
||||
CAPTCHA_ENABLED=false
|
||||
CAPTCHA_SITEKEY=""
|
||||
CAPTCHA_SECRET=""
|
||||
|
||||
# Frontend build path (not used in dev - React dev server handles it)
|
||||
FRONTEND_BUILD_PATH="../os-league-tools-master/build"
|
||||
28
server/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Environment
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
18
server/leagues-tools-dev.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Leagues Tools Development Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sonder
|
||||
WorkingDirectory=/home/sonder/leagues-tools-dev/server
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=leagues-tools-dev
|
||||
Environment=NODE_ENV=development
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
server/leagues-tools-prod.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Leagues Tools Production Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sonder
|
||||
WorkingDirectory=/home/sonder/leagues-tools-dev/server
|
||||
ExecStart=/usr/bin/npm run prod
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=leagues-tools-prod
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
1729
server/package-lock.json
generated
Normal file
37
server/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "leagues-tools-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cp .env.development .env && tsx watch src/index.ts",
|
||||
"prod": "cp .env.production .env && tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"start:dev": "cp .env.development .env && node dist/index.js",
|
||||
"start:prod": "cp .env.production .env && node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:push:dev": "cp .env.development .env && prisma db push",
|
||||
"db:push:prod": "cp .env.production .env && prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@prisma/client": "^6.2.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"blakejs": "^1.2.1",
|
||||
"hono": "^4.6.16",
|
||||
"nodemailer": "^7.0.13",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.2.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
230
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,230 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// User roles
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
// User authentication
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role Role @default(USER)
|
||||
resetToken String? // Password reset token
|
||||
resetTokenExpiry DateTime? // Token expiration time
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
characters Character[]
|
||||
ownedGroups Group[] @relation("GroupOwner")
|
||||
}
|
||||
|
||||
// User's OSRS characters (for task/unlock tracking)
|
||||
model Character {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
rsn String // RuneScape Name
|
||||
isActive Boolean @default(false)
|
||||
|
||||
// Synced data (stored as JSON)
|
||||
tasksData String? // JSON - task completion status
|
||||
unlocksData String? // JSON - unlock status
|
||||
notesData String? // JSON - user notes/planner data
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, rsn])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Group tracking (for RuneLite plugin)
|
||||
model Group {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
tokenHash String // Blake2b-256 hash of token
|
||||
version Int @default(1)
|
||||
ownerId Int? // Optional - user who owns/manages this group
|
||||
owner User? @relation("GroupOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
members Member[]
|
||||
|
||||
@@unique([name, tokenHash])
|
||||
@@index([tokenHash])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model Member {
|
||||
id Int @id @default(autoincrement())
|
||||
groupId Int
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
|
||||
// Stats (HP, Prayer, Energy, World, etc.) - JSON array of 7 integers
|
||||
stats String? // JSON
|
||||
statsLastUpdate DateTime?
|
||||
|
||||
// Coordinates (x, y, plane) - JSON array of 3 integers
|
||||
coordinates String? // JSON
|
||||
coordinatesLastUpdate DateTime?
|
||||
|
||||
// Skills (24 skills) - JSON array of 24 integers
|
||||
skills String? // JSON
|
||||
skillsLastUpdate DateTime?
|
||||
|
||||
// Quests - binary blob
|
||||
quests Bytes?
|
||||
questsLastUpdate DateTime?
|
||||
|
||||
// Inventory (56 items) - JSON array of 56 integers
|
||||
inventory String? // JSON
|
||||
inventoryLastUpdate DateTime?
|
||||
|
||||
// Equipment (28 slots) - JSON array of 28 integers
|
||||
equipment String? // JSON
|
||||
equipmentLastUpdate DateTime?
|
||||
|
||||
// Rune pouch (8 runes) - JSON array of 8 integers
|
||||
runePouch String? // JSON
|
||||
runePouchLastUpdate DateTime?
|
||||
|
||||
// Bank - JSON array (variable length)
|
||||
bank String? // JSON
|
||||
bankLastUpdate DateTime?
|
||||
|
||||
// Seed vault - JSON array (variable length)
|
||||
seedVault String? // JSON
|
||||
seedVaultLastUpdate DateTime?
|
||||
|
||||
// Interacting NPC
|
||||
interacting String?
|
||||
interactingLastUpdate DateTime?
|
||||
|
||||
// Diary vars (62 integers) - JSON array
|
||||
diaryVars String? // JSON
|
||||
diaryVarsLastUpdate DateTime?
|
||||
|
||||
// Overall last update
|
||||
lastUpdated DateTime?
|
||||
|
||||
// Skills aggregation
|
||||
skillsDay SkillsDay[]
|
||||
skillsMonth SkillsMonth[]
|
||||
skillsYear SkillsYear[]
|
||||
|
||||
// Collection log
|
||||
collectionLogs CollectionLog[]
|
||||
collectionLogsNew CollectionLogNew[]
|
||||
|
||||
@@unique([groupId, name])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
// Skills aggregation tables
|
||||
model SkillsDay {
|
||||
memberId Int
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
time DateTime
|
||||
skills String // JSON array of 24 integers
|
||||
|
||||
@@id([memberId, time])
|
||||
}
|
||||
|
||||
model SkillsMonth {
|
||||
memberId Int
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
time DateTime
|
||||
skills String // JSON array of 24 integers
|
||||
|
||||
@@id([memberId, time])
|
||||
}
|
||||
|
||||
model SkillsYear {
|
||||
memberId Int
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
time DateTime
|
||||
skills String // JSON array of 24 integers
|
||||
|
||||
@@id([memberId, time])
|
||||
}
|
||||
|
||||
// Aggregation tracking
|
||||
model AggregationInfo {
|
||||
type String @id
|
||||
lastAggregation DateTime @default(dbgenerated("'2000-01-01 00:00:00'"))
|
||||
}
|
||||
|
||||
// Collection log tables
|
||||
model CollectionTab {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
pages CollectionPage[]
|
||||
}
|
||||
|
||||
model CollectionPage {
|
||||
id Int @id @default(autoincrement())
|
||||
tabId Int
|
||||
tab CollectionTab @relation(fields: [tabId], references: [id], onDelete: Cascade)
|
||||
pageName String
|
||||
collectionLogs CollectionLog[]
|
||||
collectionLogsNew CollectionLogNew[]
|
||||
|
||||
@@unique([tabId, pageName])
|
||||
}
|
||||
|
||||
model CollectionLog {
|
||||
memberId Int
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
pageId Int
|
||||
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
items String? // JSON array of item IDs
|
||||
counts String? // JSON array of completion counts
|
||||
lastUpdated DateTime?
|
||||
|
||||
@@id([memberId, pageId])
|
||||
}
|
||||
|
||||
model CollectionLogNew {
|
||||
memberId Int
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
pageId Int
|
||||
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
newItems String? // JSON array of new item IDs
|
||||
lastUpdated DateTime?
|
||||
|
||||
@@id([memberId, pageId])
|
||||
}
|
||||
|
||||
// Hiscores cache - stores fetched hiscores data
|
||||
model HiscoresCache {
|
||||
rsn String @id // RuneScape Name (lowercase for lookup)
|
||||
displayRsn String // Original case RSN for display
|
||||
skills String // JSON - skill data
|
||||
clues String // JSON - clue scroll data
|
||||
activities String // JSON - activities/bosses data
|
||||
leaguePoints Int @default(0)
|
||||
fetchedAt DateTime @default(now())
|
||||
|
||||
@@index([fetchedAt])
|
||||
}
|
||||
62
server/src/app.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { sessionMiddleware } from './middleware/session';
|
||||
import authRoutes from './routes/auth';
|
||||
import publicRoutes from './routes/public';
|
||||
import groupRoutes from './routes/groups';
|
||||
import memberRoutes from './routes/members';
|
||||
import adminRoutes from './routes/admin';
|
||||
import characterRoutes from './routes/characters';
|
||||
import hiscoresRoutes from './routes/hiscores';
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
|
||||
// Middleware
|
||||
app.use('*', logger());
|
||||
|
||||
// CORS - configured via CORS_ORIGINS env var
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',')
|
||||
: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:4000'];
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
maxAge: 3600,
|
||||
})
|
||||
);
|
||||
|
||||
// Session middleware for all routes
|
||||
app.use('*', sessionMiddleware);
|
||||
|
||||
// API Routes
|
||||
app.route('/api', authRoutes);
|
||||
app.route('/api', publicRoutes);
|
||||
app.route('/api/group', groupRoutes);
|
||||
app.route('/api/group', memberRoutes);
|
||||
app.route('/api/admin', adminRoutes);
|
||||
app.route('/api/characters', characterRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// Hiscores proxy - under /api so nginx routes correctly
|
||||
app.route('/api/hiscores', hiscoresRoutes);
|
||||
|
||||
// Serve static files from React build
|
||||
const frontendPath = process.env.FRONTEND_BUILD_PATH || '../os-league-tools-master/build';
|
||||
|
||||
app.use('/*', serveStatic({ root: frontendPath }));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.get('*', serveStatic({ path: `${frontendPath}/index.html` }));
|
||||
|
||||
return app;
|
||||
}
|
||||
17
server/src/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
export async function connectDatabase() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('Database connected');
|
||||
} catch (error) {
|
||||
console.error('Database connection failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
39
server/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app';
|
||||
import { connectDatabase, disconnectDatabase } from './db';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
async function main() {
|
||||
// Connect to database
|
||||
await connectDatabase();
|
||||
|
||||
// Create app
|
||||
const app = createApp();
|
||||
|
||||
// Start server
|
||||
console.log(`Server starting on port ${PORT}...`);
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
console.log(`Server running at http://localhost:${PORT}`);
|
||||
console.log(`API available at http://localhost:${PORT}/api`);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await disconnectDatabase();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
52
server/src/middleware/groupAuth.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
import { hashToken } from '../utils/blake2';
|
||||
|
||||
// Extend Hono context with group data
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
groupId: number | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group token authentication middleware.
|
||||
* Validates the Authorization header token against the group.
|
||||
*
|
||||
* Expected header format: Authorization: {token}
|
||||
* Group name is extracted from the URL path parameter.
|
||||
*/
|
||||
export async function groupAuthMiddleware(c: Context, next: Next) {
|
||||
const groupName = c.req.param('group_name');
|
||||
const token = c.req.header('Authorization');
|
||||
|
||||
if (!groupName) {
|
||||
return c.json({ error: 'Group name required' }, 400);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: 'Authorization token required' }, 401);
|
||||
}
|
||||
|
||||
// Hash the token with the group name as salt
|
||||
const tokenHash = hashToken(token, groupName);
|
||||
|
||||
// Find the group with matching name and token hash
|
||||
const group = await prisma.group.findFirst({
|
||||
where: {
|
||||
name: groupName,
|
||||
tokenHash: tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
return c.json({ error: 'Invalid token or group not found' }, 401);
|
||||
}
|
||||
|
||||
// Set group info in context
|
||||
c.set('groupId', group.id);
|
||||
c.set('groupName', group.name);
|
||||
|
||||
await next();
|
||||
}
|
||||
129
server/src/middleware/session.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||
import { Role } from '@prisma/client';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'session_id';
|
||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
// Extend Hono context with session data
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: SessionUser | null;
|
||||
sessionId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session middleware - validates session cookie and sets user in context
|
||||
*/
|
||||
export async function sessionMiddleware(c: Context, next: Next) {
|
||||
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
||||
|
||||
if (sessionId) {
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
c.set('user', {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
email: session.user.email,
|
||||
role: session.user.role,
|
||||
});
|
||||
c.set('sessionId', sessionId);
|
||||
} else if (session) {
|
||||
// Session expired, clean it up
|
||||
await prisma.session.delete({ where: { id: sessionId } });
|
||||
deleteCookie(c, SESSION_COOKIE_NAME);
|
||||
c.set('user', null);
|
||||
c.set('sessionId', null);
|
||||
} else {
|
||||
c.set('user', null);
|
||||
c.set('sessionId', null);
|
||||
}
|
||||
} else {
|
||||
c.set('user', null);
|
||||
c.set('sessionId', null);
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
*/
|
||||
export async function createSession(c: Context, userId: number): Promise<string> {
|
||||
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE);
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
setCookie(c, SESSION_COOKIE_NAME, session.id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
maxAge: SESSION_MAX_AGE / 1000,
|
||||
});
|
||||
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current session
|
||||
*/
|
||||
export async function destroySession(c: Context): Promise<void> {
|
||||
const sessionId = c.get('sessionId');
|
||||
|
||||
if (sessionId) {
|
||||
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
|
||||
}
|
||||
|
||||
deleteCookie(c, SESSION_COOKIE_NAME);
|
||||
c.set('user', null);
|
||||
c.set('sessionId', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require authentication
|
||||
*/
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require admin role
|
||||
*/
|
||||
export async function requireAdmin(c: Context, next: Next) {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
if (user.role !== 'ADMIN') {
|
||||
return c.json({ error: 'Forbidden: Admin access required' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
221
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
import { requireAdmin } from '../middleware/session';
|
||||
import { hashPassword } from '../utils/password';
|
||||
|
||||
const admin = new Hono();
|
||||
|
||||
// All admin routes require admin role
|
||||
admin.use('/*', requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* List all users
|
||||
*/
|
||||
admin.get('/users', async (c) => {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: { sessions: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return c.json({
|
||||
users: users.map((u) => ({
|
||||
...u,
|
||||
sessionCount: u._count.sessions,
|
||||
_count: undefined,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:id
|
||||
* Get single user details
|
||||
*/
|
||||
admin.get('/users/:id', async (c) => {
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
sessions: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:id
|
||||
* Update user (role, email, etc.)
|
||||
*/
|
||||
admin.patch('/users/:id', async (c) => {
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
const body = await c.req.json();
|
||||
const { role, email, username } = body;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (role && ['USER', 'ADMIN'].includes(role)) {
|
||||
updateData.role = role;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
// Check if email is already taken
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: { email, NOT: { id } },
|
||||
});
|
||||
if (existing) {
|
||||
return c.json({ error: 'Email already in use' }, 400);
|
||||
}
|
||||
updateData.email = email;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
// Check if username is already taken
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: { username, NOT: { id } },
|
||||
});
|
||||
if (existing) {
|
||||
return c.json({ error: 'Username already in use' }, 400);
|
||||
}
|
||||
updateData.username = username;
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ user: updated });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:id/password
|
||||
* Reset user password
|
||||
*/
|
||||
admin.patch('/users/:id/password', async (c) => {
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
const body = await c.req.json();
|
||||
const { password } = body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
// Invalidate all sessions for this user
|
||||
await prisma.session.deleteMany({ where: { userId: id } });
|
||||
|
||||
return c.json({ success: true, message: 'Password reset and sessions invalidated' });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:id
|
||||
* Delete user
|
||||
*/
|
||||
admin.delete('/users/:id', async (c) => {
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
const currentUser = c.get('user')!;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (currentUser.id === id) {
|
||||
return c.json({ error: 'Cannot delete your own account' }, 400);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
|
||||
return c.json({ success: true, message: 'User deleted' });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:id/sessions
|
||||
* Invalidate all sessions for a user
|
||||
*/
|
||||
admin.delete('/users/:id/sessions', async (c) => {
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const result = await prisma.session.deleteMany({ where: { userId: id } });
|
||||
|
||||
return c.json({ success: true, message: `${result.count} sessions invalidated` });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
* Get admin dashboard stats
|
||||
*/
|
||||
admin.get('/stats', async (c) => {
|
||||
const [userCount, sessionCount, groupCount, memberCount] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.session.count(),
|
||||
prisma.group.count(),
|
||||
prisma.member.count(),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
users: userCount,
|
||||
activeSessions: sessionCount,
|
||||
groups: groupCount,
|
||||
members: memberCount,
|
||||
});
|
||||
});
|
||||
|
||||
export default admin;
|
||||
233
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Hono } from 'hono';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { prisma } from '../db';
|
||||
import { hashPassword, verifyPassword } from '../utils/password';
|
||||
import { createSession, destroySession, requireAuth } from '../middleware/session';
|
||||
import { sendPasswordResetEmail, isEmailConfigured } from '../utils/email';
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
/**
|
||||
* POST /api/register
|
||||
* Create a new user account
|
||||
*/
|
||||
auth.post('/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { username, email, password } = body;
|
||||
|
||||
// Validation
|
||||
if (!username || !email || !password) {
|
||||
return c.json({ error: 'Username, email, and password are required' }, 400);
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 30) {
|
||||
return c.json({ error: 'Username must be 3-30 characters' }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
// Check if username or email already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.username === username) {
|
||||
return c.json({ error: 'Username already taken' }, 400);
|
||||
}
|
||||
return c.json({ error: 'Email already registered' }, 400);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Create session
|
||||
await createSession(c, user.id);
|
||||
|
||||
return c.json({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/login
|
||||
* Authenticate and create session
|
||||
*/
|
||||
auth.post('/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return c.json({ error: 'Username and password are required' }, 400);
|
||||
}
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { email: username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Invalid username or password' }, 401);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return c.json({ error: 'Invalid username or password' }, 401);
|
||||
}
|
||||
|
||||
// Create session
|
||||
await createSession(c, user.id);
|
||||
|
||||
return c.json({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/logout
|
||||
* Destroy session
|
||||
*/
|
||||
auth.post('/logout', async (c) => {
|
||||
await destroySession(c);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
auth.get('/auth/status', (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json({
|
||||
authenticated: !!user,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/me
|
||||
* Get current user info
|
||||
*/
|
||||
auth.get('/me', requireAuth, (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json({
|
||||
username: user!.username,
|
||||
email: user!.email,
|
||||
role: user!.role,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/forgot-password
|
||||
* Request a password reset
|
||||
*/
|
||||
auth.post('/forgot-password', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return c.json({ error: 'Email is required' }, 400);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
|
||||
}
|
||||
|
||||
// Generate secure token
|
||||
const resetToken = randomBytes(32).toString('hex');
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Store token in database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken,
|
||||
resetTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email with reset link
|
||||
const baseUrl = process.env.APP_URL || 'https://leagues.tools';
|
||||
|
||||
if (isEmailConfigured()) {
|
||||
const emailResult = await sendPasswordResetEmail(email, resetToken, baseUrl);
|
||||
if (!emailResult.success) {
|
||||
console.error(`Failed to send password reset email to ${email}:`, emailResult.error);
|
||||
}
|
||||
} else {
|
||||
// Log token in development when email is not configured
|
||||
console.log(`Password reset requested for ${email}. Token: ${resetToken}`);
|
||||
console.log(`Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/reset-password
|
||||
* Reset password using token
|
||||
*/
|
||||
auth.post('/reset-password', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { token, password } = body;
|
||||
|
||||
if (!token || !password) {
|
||||
return c.json({ error: 'Token and password are required' }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
// Find user with this token
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: {
|
||||
gt: new Date(), // Token must not be expired
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Invalid or expired reset token' }, 400);
|
||||
}
|
||||
|
||||
// Update password and clear token
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ message: 'Password has been reset successfully. You can now log in.' });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
334
server/src/routes/characters.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
import { requireAuth } from '../middleware/session';
|
||||
|
||||
const characters = new Hono();
|
||||
|
||||
// All character routes require authentication
|
||||
characters.use('/*', requireAuth);
|
||||
|
||||
/**
|
||||
* GET /api/characters
|
||||
* Get all characters for the authenticated user
|
||||
*/
|
||||
characters.get('/', async (c) => {
|
||||
const user = c.get('user')!;
|
||||
|
||||
const userCharacters = await prisma.character.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return c.json({
|
||||
characters: userCharacters.map((char) => ({
|
||||
id: char.id,
|
||||
rsn: char.rsn,
|
||||
isActive: char.isActive,
|
||||
tasksData: char.tasksData ? JSON.parse(char.tasksData) : null,
|
||||
unlocksData: char.unlocksData ? JSON.parse(char.unlocksData) : null,
|
||||
notesData: char.notesData ? JSON.parse(char.notesData) : null,
|
||||
createdAt: char.createdAt,
|
||||
updatedAt: char.updatedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/characters
|
||||
* Create a new character
|
||||
*/
|
||||
characters.post('/', async (c) => {
|
||||
const user = c.get('user')!;
|
||||
const body = await c.req.json();
|
||||
const { rsn, setActive } = body;
|
||||
|
||||
if (!rsn || typeof rsn !== 'string') {
|
||||
return c.json({ error: 'RSN is required' }, 400);
|
||||
}
|
||||
|
||||
const trimmedRsn = rsn.trim();
|
||||
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
|
||||
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
|
||||
}
|
||||
|
||||
// Check if character already exists for this user
|
||||
const existing = await prisma.character.findUnique({
|
||||
where: { userId_rsn: { userId: user.id, rsn: trimmedRsn } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: 'Character already exists' }, 400);
|
||||
}
|
||||
|
||||
// If setActive, deactivate all other characters first
|
||||
if (setActive) {
|
||||
await prisma.character.updateMany({
|
||||
where: { userId: user.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
const character = await prisma.character.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
rsn: trimmedRsn,
|
||||
isActive: setActive || false,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
character: {
|
||||
id: character.id,
|
||||
rsn: character.rsn,
|
||||
isActive: character.isActive,
|
||||
tasksData: null,
|
||||
unlocksData: null,
|
||||
notesData: null,
|
||||
createdAt: character.createdAt,
|
||||
updatedAt: character.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/characters/:id
|
||||
* Update a character (rename, set active, sync data)
|
||||
*/
|
||||
characters.patch('/:id', async (c) => {
|
||||
const user = c.get('user')!;
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
const body = await c.req.json();
|
||||
|
||||
const character = await prisma.character.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
return c.json({ error: 'Character not found' }, 404);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
// Rename
|
||||
if (body.rsn !== undefined) {
|
||||
const trimmedRsn = body.rsn.trim();
|
||||
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
|
||||
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
|
||||
}
|
||||
// Check if new name already exists
|
||||
const existing = await prisma.character.findFirst({
|
||||
where: { userId: user.id, rsn: trimmedRsn, NOT: { id } },
|
||||
});
|
||||
if (existing) {
|
||||
return c.json({ error: 'Character with that name already exists' }, 400);
|
||||
}
|
||||
updateData.rsn = trimmedRsn;
|
||||
}
|
||||
|
||||
// Set active
|
||||
if (body.isActive !== undefined) {
|
||||
if (body.isActive) {
|
||||
// Deactivate all other characters
|
||||
await prisma.character.updateMany({
|
||||
where: { userId: user.id, NOT: { id } },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
updateData.isActive = body.isActive;
|
||||
}
|
||||
|
||||
// Sync tasks data
|
||||
if (body.tasksData !== undefined) {
|
||||
updateData.tasksData = body.tasksData ? JSON.stringify(body.tasksData) : null;
|
||||
}
|
||||
|
||||
// Sync unlocks data
|
||||
if (body.unlocksData !== undefined) {
|
||||
updateData.unlocksData = body.unlocksData ? JSON.stringify(body.unlocksData) : null;
|
||||
}
|
||||
|
||||
// Sync notes data
|
||||
if (body.notesData !== undefined) {
|
||||
updateData.notesData = body.notesData ? JSON.stringify(body.notesData) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
character: {
|
||||
id: updated.id,
|
||||
rsn: updated.rsn,
|
||||
isActive: updated.isActive,
|
||||
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
|
||||
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
|
||||
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/characters/:id
|
||||
* Delete a character
|
||||
*/
|
||||
characters.delete('/:id', async (c) => {
|
||||
const user = c.get('user')!;
|
||||
const id = parseInt(c.req.param('id'), 10);
|
||||
|
||||
const character = await prisma.character.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
|
||||
if (!character) {
|
||||
return c.json({ error: 'Character not found' }, 404);
|
||||
}
|
||||
|
||||
await prisma.character.delete({ where: { id } });
|
||||
|
||||
// If deleted character was active, activate the first remaining character
|
||||
if (character.isActive) {
|
||||
const firstChar = await prisma.character.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (firstChar) {
|
||||
await prisma.character.update({
|
||||
where: { id: firstChar.id },
|
||||
data: { isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/characters/sync
|
||||
* Bulk sync all characters (used on login to merge local data)
|
||||
*/
|
||||
characters.post('/sync', async (c) => {
|
||||
const user = c.get('user')!;
|
||||
const body = await c.req.json();
|
||||
const { characters: localCharacters, activeIndex } = body;
|
||||
|
||||
if (!Array.isArray(localCharacters)) {
|
||||
return c.json({ error: 'characters must be an array' }, 400);
|
||||
}
|
||||
|
||||
// Get existing server characters
|
||||
const serverCharacters = await prisma.character.findMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
const serverRsnMap = new Map(serverCharacters.map((c) => [c.rsn.toLowerCase(), c]));
|
||||
const result: Array<{
|
||||
id: number;
|
||||
rsn: string;
|
||||
isActive: boolean;
|
||||
tasksData: unknown;
|
||||
unlocksData: unknown;
|
||||
notesData: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
|
||||
// Process each local character
|
||||
for (let i = 0; i < localCharacters.length; i++) {
|
||||
const local = localCharacters[i];
|
||||
const rsn = typeof local === 'string' ? local : local.rsn;
|
||||
const tasksData = typeof local === 'object' ? local.tasksData : null;
|
||||
const unlocksData = typeof local === 'object' ? local.unlocksData : null;
|
||||
const notesData = typeof local === 'object' ? local.notesData : null;
|
||||
const isActive = i === activeIndex;
|
||||
|
||||
const existing = serverRsnMap.get(rsn.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
// Update existing character if local data is newer/present
|
||||
const updateData: Record<string, unknown> = { isActive };
|
||||
|
||||
// Merge data - prefer local if it exists and server doesn't have it
|
||||
if (tasksData && !existing.tasksData) {
|
||||
updateData.tasksData = JSON.stringify(tasksData);
|
||||
}
|
||||
if (unlocksData && !existing.unlocksData) {
|
||||
updateData.unlocksData = JSON.stringify(unlocksData);
|
||||
}
|
||||
if (notesData && !existing.notesData) {
|
||||
updateData.notesData = JSON.stringify(notesData);
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: updated.id,
|
||||
rsn: updated.rsn,
|
||||
isActive: updated.isActive,
|
||||
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
|
||||
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
|
||||
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
|
||||
serverRsnMap.delete(rsn.toLowerCase());
|
||||
} else {
|
||||
// Create new character
|
||||
const created = await prisma.character.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
rsn,
|
||||
isActive,
|
||||
tasksData: tasksData ? JSON.stringify(tasksData) : null,
|
||||
unlocksData: unlocksData ? JSON.stringify(unlocksData) : null,
|
||||
notesData: notesData ? JSON.stringify(notesData) : null,
|
||||
},
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: created.id,
|
||||
rsn: created.rsn,
|
||||
isActive: created.isActive,
|
||||
tasksData: tasksData,
|
||||
unlocksData: unlocksData,
|
||||
notesData: notesData,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining server characters that weren't in local
|
||||
for (const serverChar of serverRsnMap.values()) {
|
||||
// Deactivate if there was an active local character
|
||||
if (serverChar.isActive && activeIndex !== undefined) {
|
||||
await prisma.character.update({
|
||||
where: { id: serverChar.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
serverChar.isActive = false;
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: serverChar.id,
|
||||
rsn: serverChar.rsn,
|
||||
isActive: serverChar.isActive,
|
||||
tasksData: serverChar.tasksData ? JSON.parse(serverChar.tasksData) : null,
|
||||
unlocksData: serverChar.unlocksData ? JSON.parse(serverChar.unlocksData) : null,
|
||||
notesData: serverChar.notesData ? JSON.parse(serverChar.notesData) : null,
|
||||
createdAt: serverChar.createdAt,
|
||||
updatedAt: serverChar.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ characters: result });
|
||||
});
|
||||
|
||||
export default characters;
|
||||
124
server/src/routes/groups.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
import { groupAuthMiddleware } from '../middleware/groupAuth';
|
||||
|
||||
const groups = new Hono();
|
||||
|
||||
// Apply group token auth to all routes
|
||||
groups.use('/*', groupAuthMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/group/:group_name/get-group-data
|
||||
* Get all members with optional delta updates
|
||||
*/
|
||||
groups.get('/:group_name/get-group-data', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const fromTimeParam = c.req.query('from_time');
|
||||
|
||||
let fromTimestamp: Date | undefined;
|
||||
if (fromTimeParam) {
|
||||
// Try parsing as epoch milliseconds first, then ISO string
|
||||
const epochMs = parseInt(fromTimeParam, 10);
|
||||
if (!isNaN(epochMs)) {
|
||||
fromTimestamp = new Date(epochMs);
|
||||
} else {
|
||||
fromTimestamp = new Date(fromTimeParam);
|
||||
}
|
||||
}
|
||||
|
||||
const members = await prisma.member.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
...(fromTimestamp && {
|
||||
lastUpdated: { gt: fromTimestamp },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Transform to API response format
|
||||
const response = members.map((member) => ({
|
||||
name: member.name,
|
||||
stats: member.stats ? JSON.parse(member.stats) : null,
|
||||
statsLastUpdate: member.statsLastUpdate?.getTime() || null,
|
||||
coordinates: member.coordinates ? JSON.parse(member.coordinates) : null,
|
||||
coordinatesLastUpdate: member.coordinatesLastUpdate?.getTime() || null,
|
||||
skills: member.skills ? JSON.parse(member.skills) : null,
|
||||
skillsLastUpdate: member.skillsLastUpdate?.getTime() || null,
|
||||
quests: member.quests ? Array.from(member.quests) : null,
|
||||
questsLastUpdate: member.questsLastUpdate?.getTime() || null,
|
||||
inventory: member.inventory ? JSON.parse(member.inventory) : null,
|
||||
inventoryLastUpdate: member.inventoryLastUpdate?.getTime() || null,
|
||||
equipment: member.equipment ? JSON.parse(member.equipment) : null,
|
||||
equipmentLastUpdate: member.equipmentLastUpdate?.getTime() || null,
|
||||
runePouch: member.runePouch ? JSON.parse(member.runePouch) : null,
|
||||
runePouchLastUpdate: member.runePouchLastUpdate?.getTime() || null,
|
||||
bank: member.bank ? JSON.parse(member.bank) : null,
|
||||
bankLastUpdate: member.bankLastUpdate?.getTime() || null,
|
||||
seedVault: member.seedVault ? JSON.parse(member.seedVault) : null,
|
||||
seedVaultLastUpdate: member.seedVaultLastUpdate?.getTime() || null,
|
||||
interacting: member.interacting,
|
||||
interactingLastUpdate: member.interactingLastUpdate?.getTime() || null,
|
||||
diaryVars: member.diaryVars ? JSON.parse(member.diaryVars) : null,
|
||||
diaryVarsLastUpdate: member.diaryVarsLastUpdate?.getTime() || null,
|
||||
lastUpdated: member.lastUpdated?.getTime() || null,
|
||||
}));
|
||||
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/group/:group_name/am-i-logged-in
|
||||
* Check if authenticated (if this is reached, auth succeeded)
|
||||
*/
|
||||
groups.get('/:group_name/am-i-logged-in', (c) => {
|
||||
return c.json({ authenticated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/group/:group_name/am-i-in-group
|
||||
* Check if a member exists in the group
|
||||
*/
|
||||
groups.get('/:group_name/am-i-in-group', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const memberName = c.req.query('member_name');
|
||||
|
||||
if (!memberName) {
|
||||
return c.json({ error: 'member_name is required' }, 400);
|
||||
}
|
||||
|
||||
const member = await prisma.member.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
name: memberName,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ in_group: !!member });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/group/:group_name/get-skill-data
|
||||
* Get skill aggregation data
|
||||
* TODO: Implement skill aggregation
|
||||
*/
|
||||
groups.get('/:group_name/get-skill-data', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const period = c.req.query('period'); // day, month, year
|
||||
|
||||
// TODO: Implement skill aggregation service
|
||||
return c.json([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/group/:group_name/collection-log
|
||||
* Get collection log data
|
||||
* TODO: Implement collection log
|
||||
*/
|
||||
groups.get('/:group_name/collection-log', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
|
||||
// TODO: Implement collection log service
|
||||
return c.json([]);
|
||||
});
|
||||
|
||||
export default groups;
|
||||
205
server/src/routes/hiscores.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const hiscores = new Hono();
|
||||
|
||||
// Cache TTL in milliseconds (5 minutes)
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Skill names in order returned by the hiscores API
|
||||
const SKILL_NAMES = [
|
||||
'overall',
|
||||
'attack',
|
||||
'defence',
|
||||
'strength',
|
||||
'hitpoints',
|
||||
'ranged',
|
||||
'prayer',
|
||||
'magic',
|
||||
'cooking',
|
||||
'woodcutting',
|
||||
'fletching',
|
||||
'fishing',
|
||||
'firemaking',
|
||||
'crafting',
|
||||
'smithing',
|
||||
'mining',
|
||||
'herblore',
|
||||
'agility',
|
||||
'thieving',
|
||||
'slayer',
|
||||
'farming',
|
||||
'runecraft',
|
||||
'hunter',
|
||||
'construction',
|
||||
];
|
||||
|
||||
// Activity/minigame names (after skills in the CSV)
|
||||
const ACTIVITY_NAMES = [
|
||||
'league_points',
|
||||
'deadman_points',
|
||||
'bounty_hunter_hunter',
|
||||
'bounty_hunter_rogue',
|
||||
'bounty_hunter_hunter_legacy',
|
||||
'bounty_hunter_rogue_legacy',
|
||||
'clue_scrolls_all',
|
||||
'clue_scrolls_beginner',
|
||||
'clue_scrolls_easy',
|
||||
'clue_scrolls_medium',
|
||||
'clue_scrolls_hard',
|
||||
'clue_scrolls_elite',
|
||||
'clue_scrolls_master',
|
||||
'lms_rank',
|
||||
'pvp_arena_rank',
|
||||
'soul_wars_zeal',
|
||||
'rifts_closed',
|
||||
'colosseum_glory',
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/hiscores/:rsn
|
||||
* Fetch hiscores from cache or Jagex seasonal (Leagues) hiscores
|
||||
*/
|
||||
hiscores.get('/:rsn', async (c) => {
|
||||
const rsn = c.req.param('rsn');
|
||||
|
||||
if (!rsn) {
|
||||
return c.json({ error: 'RSN is required' }, 400);
|
||||
}
|
||||
|
||||
const rsnLower = rsn.toLowerCase();
|
||||
|
||||
// Check cache first
|
||||
const cached = await prisma.hiscoresCache.findUnique({
|
||||
where: { rsn: rsnLower },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
if (cached && (now.getTime() - cached.fetchedAt.getTime()) < CACHE_TTL_MS) {
|
||||
// Return cached data
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from Jagex API
|
||||
const url = `https://secure.runescape.com/m=hiscore_oldschool_seasonal/index_lite.ws?player=${encodeURIComponent(rsn)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'LeaguesTools/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return c.json({ status: 404, error: 'Player not found' }, 404);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// If fetch fails but we have stale cache, return it
|
||||
if (cached) {
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
stale: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
console.error(`Hiscores API error: ${response.status}`);
|
||||
return c.json({ error: 'Failed to fetch hiscores' }, 502);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.trim().split('\n');
|
||||
|
||||
// Parse skills (first 24 lines)
|
||||
const skills: Record<string, { rank: number; level: number; xp: number }> = {};
|
||||
for (let i = 0; i < SKILL_NAMES.length && i < lines.length; i++) {
|
||||
const [rank, level, xp] = lines[i].split(',').map(Number);
|
||||
skills[SKILL_NAMES[i]] = { rank, level, xp };
|
||||
}
|
||||
|
||||
// Parse activities/minigames (remaining lines)
|
||||
const activities: Record<string, { rank: number; score: number }> = {};
|
||||
for (let i = SKILL_NAMES.length; i < lines.length; i++) {
|
||||
const activityIndex = i - SKILL_NAMES.length;
|
||||
if (activityIndex < ACTIVITY_NAMES.length) {
|
||||
const [rank, score] = lines[i].split(',').map(Number);
|
||||
activities[ACTIVITY_NAMES[activityIndex]] = { rank, score };
|
||||
}
|
||||
}
|
||||
|
||||
// Build clues object for frontend compatibility
|
||||
const clues = {
|
||||
all: { rank: activities.clue_scrolls_all?.rank ?? -1, score: activities.clue_scrolls_all?.score ?? -1 },
|
||||
beginner: { rank: activities.clue_scrolls_beginner?.rank ?? -1, score: activities.clue_scrolls_beginner?.score ?? -1 },
|
||||
easy: { rank: activities.clue_scrolls_easy?.rank ?? -1, score: activities.clue_scrolls_easy?.score ?? -1 },
|
||||
medium: { rank: activities.clue_scrolls_medium?.rank ?? -1, score: activities.clue_scrolls_medium?.score ?? -1 },
|
||||
hard: { rank: activities.clue_scrolls_hard?.rank ?? -1, score: activities.clue_scrolls_hard?.score ?? -1 },
|
||||
elite: { rank: activities.clue_scrolls_elite?.rank ?? -1, score: activities.clue_scrolls_elite?.score ?? -1 },
|
||||
master: { rank: activities.clue_scrolls_master?.rank ?? -1, score: activities.clue_scrolls_master?.score ?? -1 },
|
||||
};
|
||||
|
||||
const leaguePoints = activities.league_points?.score || 0;
|
||||
|
||||
// Cache the result
|
||||
await prisma.hiscoresCache.upsert({
|
||||
where: { rsn: rsnLower },
|
||||
update: {
|
||||
displayRsn: rsn,
|
||||
skills: JSON.stringify(skills),
|
||||
clues: JSON.stringify(clues),
|
||||
activities: JSON.stringify(activities),
|
||||
leaguePoints,
|
||||
fetchedAt: now,
|
||||
},
|
||||
create: {
|
||||
rsn: rsnLower,
|
||||
displayRsn: rsn,
|
||||
skills: JSON.stringify(skills),
|
||||
clues: JSON.stringify(clues),
|
||||
activities: JSON.stringify(activities),
|
||||
leaguePoints,
|
||||
fetchedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
rsn,
|
||||
skills,
|
||||
clues,
|
||||
activities,
|
||||
leaguePoints,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// If fetch fails but we have stale cache, return it
|
||||
if (cached) {
|
||||
return c.json({
|
||||
rsn: cached.displayRsn,
|
||||
skills: JSON.parse(cached.skills),
|
||||
clues: JSON.parse(cached.clues),
|
||||
activities: JSON.parse(cached.activities),
|
||||
leaguePoints: cached.leaguePoints,
|
||||
cached: true,
|
||||
stale: true,
|
||||
cachedAt: cached.fetchedAt,
|
||||
});
|
||||
}
|
||||
console.error('Hiscores fetch error:', error);
|
||||
return c.json({ error: 'Failed to fetch hiscores' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default hiscores;
|
||||
211
server/src/routes/members.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Hono } from 'hono';
|
||||
import { prisma } from '../db';
|
||||
import { groupAuthMiddleware } from '../middleware/groupAuth';
|
||||
|
||||
const members = new Hono();
|
||||
|
||||
// Apply group token auth to all routes
|
||||
members.use('/*', groupAuthMiddleware);
|
||||
|
||||
/**
|
||||
* POST /api/group/:group_name/update-group-member
|
||||
* Update member data (main RuneLite plugin endpoint)
|
||||
* Only non-null fields are updated
|
||||
*/
|
||||
members.post('/:group_name/update-group-member', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const body = await c.req.json();
|
||||
const { name, ...data } = body;
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Member name is required' }, 400);
|
||||
}
|
||||
|
||||
// Find or create member
|
||||
let member = await prisma.member.findFirst({
|
||||
where: { groupId, name },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
// Auto-create member if doesn't exist
|
||||
member = await prisma.member.create({
|
||||
data: { groupId, name },
|
||||
});
|
||||
}
|
||||
|
||||
// Build update object with only provided fields
|
||||
const now = new Date();
|
||||
const updateData: Record<string, unknown> = {
|
||||
lastUpdated: now,
|
||||
};
|
||||
|
||||
if (data.stats !== undefined) {
|
||||
updateData.stats = JSON.stringify(data.stats);
|
||||
updateData.statsLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.coordinates !== undefined) {
|
||||
updateData.coordinates = JSON.stringify(data.coordinates);
|
||||
updateData.coordinatesLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.skills !== undefined) {
|
||||
updateData.skills = JSON.stringify(data.skills);
|
||||
updateData.skillsLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.quests !== undefined) {
|
||||
updateData.quests = Buffer.from(data.quests);
|
||||
updateData.questsLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.inventory !== undefined) {
|
||||
updateData.inventory = JSON.stringify(data.inventory);
|
||||
updateData.inventoryLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.equipment !== undefined) {
|
||||
updateData.equipment = JSON.stringify(data.equipment);
|
||||
updateData.equipmentLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.runePouch !== undefined || data.rune_pouch !== undefined) {
|
||||
updateData.runePouch = JSON.stringify(data.runePouch || data.rune_pouch);
|
||||
updateData.runePouchLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.bank !== undefined) {
|
||||
updateData.bank = JSON.stringify(data.bank);
|
||||
updateData.bankLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.seedVault !== undefined || data.seed_vault !== undefined) {
|
||||
updateData.seedVault = JSON.stringify(data.seedVault || data.seed_vault);
|
||||
updateData.seedVaultLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.interacting !== undefined) {
|
||||
updateData.interacting = data.interacting;
|
||||
updateData.interactingLastUpdate = now;
|
||||
}
|
||||
|
||||
if (data.diaryVars !== undefined || data.diary_vars !== undefined) {
|
||||
updateData.diaryVars = JSON.stringify(data.diaryVars || data.diary_vars);
|
||||
updateData.diaryVarsLastUpdate = now;
|
||||
}
|
||||
|
||||
// Update member
|
||||
await prisma.member.update({
|
||||
where: { id: member.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return c.json({ status: 'success' });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/group/:group_name/add-group-member
|
||||
* Add a new member to the group
|
||||
*/
|
||||
members.post('/:group_name/add-group-member', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const body = await c.req.json();
|
||||
const { name } = body;
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Member name is required' }, 400);
|
||||
}
|
||||
|
||||
// Check if member already exists
|
||||
const existing = await prisma.member.findFirst({
|
||||
where: { groupId, name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: 'Member already exists in group' }, 400);
|
||||
}
|
||||
|
||||
// Create member
|
||||
await prisma.member.create({
|
||||
data: { groupId, name },
|
||||
});
|
||||
|
||||
console.log(`Added member '${name}' to group_id: ${groupId}`);
|
||||
|
||||
return c.json({ status: 'success' });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/group/:group_name/delete-group-member
|
||||
* Delete a member from the group
|
||||
*/
|
||||
members.delete('/:group_name/delete-group-member', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const body = await c.req.json();
|
||||
const { name } = body;
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Member name is required' }, 400);
|
||||
}
|
||||
|
||||
const member = await prisma.member.findFirst({
|
||||
where: { groupId, name },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return c.json({ error: 'Member not found' }, 404);
|
||||
}
|
||||
|
||||
await prisma.member.delete({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
console.log(`Deleted member '${name}' from group_id: ${groupId}`);
|
||||
|
||||
return c.json({ status: 'success' });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/group/:group_name/rename-group-member
|
||||
* Rename a group member
|
||||
*/
|
||||
members.put('/:group_name/rename-group-member', async (c) => {
|
||||
const groupId = c.get('groupId')!;
|
||||
const body = await c.req.json();
|
||||
const { originalName, newName, original_name, new_name } = body;
|
||||
|
||||
const oldName = originalName || original_name;
|
||||
const targetName = newName || new_name;
|
||||
|
||||
if (!oldName || !targetName) {
|
||||
return c.json({ error: 'originalName and newName are required' }, 400);
|
||||
}
|
||||
|
||||
const member = await prisma.member.findFirst({
|
||||
where: { groupId, name: oldName },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return c.json({ error: 'Member not found' }, 404);
|
||||
}
|
||||
|
||||
// Check if new name already exists
|
||||
const existing = await prisma.member.findFirst({
|
||||
where: { groupId, name: targetName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: 'A member with that name already exists' }, 400);
|
||||
}
|
||||
|
||||
await prisma.member.update({
|
||||
where: { id: member.id },
|
||||
data: { name: targetName },
|
||||
});
|
||||
|
||||
console.log(`Renamed member '${oldName}' -> '${targetName}' in group_id: ${groupId}`);
|
||||
|
||||
return c.json({ status: 'success' });
|
||||
});
|
||||
|
||||
export default members;
|
||||
121
server/src/routes/public.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Hono } from 'hono';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../db';
|
||||
import { hashToken } from '../utils/blake2';
|
||||
|
||||
const publicRoutes = new Hono();
|
||||
|
||||
// In-memory cache for GE prices
|
||||
let gePricesCache: Record<string, number> = {};
|
||||
let gePricesCacheTime = 0;
|
||||
const GE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const CAPTCHA_ENABLED = process.env.CAPTCHA_ENABLED === 'true';
|
||||
const CAPTCHA_SITEKEY = process.env.CAPTCHA_SITEKEY || '';
|
||||
|
||||
/**
|
||||
* POST /api/create-group
|
||||
* Create a new group with a token
|
||||
*/
|
||||
publicRoutes.post('/create-group', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, captchaResponse } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return c.json({ error: 'Group name is required' }, 400);
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
||||
return c.json({ error: 'Group name must be 1-100 characters' }, 400);
|
||||
}
|
||||
|
||||
// TODO: Implement captcha validation if enabled
|
||||
// if (CAPTCHA_ENABLED && !verifyCaptcha(captchaResponse)) {
|
||||
// return c.json({ error: 'Invalid captcha' }, 400);
|
||||
// }
|
||||
|
||||
// Generate token
|
||||
const token = uuidv4();
|
||||
const tokenHash = hashToken(token, trimmedName);
|
||||
|
||||
// Check if group name + token combination already exists
|
||||
const existingGroup = await prisma.group.findFirst({
|
||||
where: {
|
||||
name: trimmedName,
|
||||
tokenHash: tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
return c.json({ error: 'Group already exists' }, 400);
|
||||
}
|
||||
|
||||
// Create group
|
||||
const group = await prisma.group.create({
|
||||
data: {
|
||||
name: trimmedName,
|
||||
tokenHash: tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Group created: ${group.name} with token (first 8 chars): ${token.substring(0, 8)}...`);
|
||||
|
||||
return c.json({
|
||||
name: group.name,
|
||||
token: token, // Return unhashed token to user
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ge-prices
|
||||
* Get cached Grand Exchange prices
|
||||
*/
|
||||
publicRoutes.get('/ge-prices', async (c) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Refresh cache if expired
|
||||
if (now - gePricesCacheTime > GE_CACHE_TTL) {
|
||||
try {
|
||||
const response = await fetch('https://prices.runescape.wiki/api/v1/osrs/latest');
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: Record<string, { high?: number; low?: number }> };
|
||||
// Transform to simple id -> price mapping
|
||||
gePricesCache = {};
|
||||
for (const [id, item] of Object.entries(data.data)) {
|
||||
// Use high price if available, otherwise low
|
||||
const price = item.high ?? item.low ?? 0;
|
||||
gePricesCache[id] = price;
|
||||
}
|
||||
gePricesCacheTime = now;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GE prices:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ prices: gePricesCache });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/captcha-enabled
|
||||
* Get captcha configuration
|
||||
*/
|
||||
publicRoutes.get('/captcha-enabled', (c) => {
|
||||
return c.json({
|
||||
enabled: CAPTCHA_ENABLED,
|
||||
sitekey: CAPTCHA_SITEKEY,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/collection-log-info
|
||||
* Get collection log metadata
|
||||
* TODO: Load from JSON file
|
||||
*/
|
||||
publicRoutes.get('/collection-log-info', (c) => {
|
||||
// TODO: Load from collection_log_info.json
|
||||
return c.json({});
|
||||
});
|
||||
|
||||
export default publicRoutes;
|
||||
35
server/src/utils/blake2.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import blake2b from 'blakejs';
|
||||
|
||||
const BACKEND_SECRET = process.env.BACKEND_SECRET || 'changeme_secret_key_for_production';
|
||||
|
||||
/**
|
||||
* Hash a token with Blake2b-256 (2 iterations).
|
||||
* This must match the Spring/Rust implementation exactly:
|
||||
* - Uses Blake2b-256 (32 bytes output)
|
||||
* - 2 iterations of hashing
|
||||
* - Combines token + secret + salt (group name)
|
||||
* - Returns hex-encoded hash (64 characters)
|
||||
*/
|
||||
export function hashToken(token: string, salt: string): string {
|
||||
// First iteration: hash(token + secret + salt)
|
||||
const input1 = token + BACKEND_SECRET + salt;
|
||||
const hash1 = blake2b.blake2b(input1, undefined, 32);
|
||||
|
||||
// Second iteration: hash(hash1)
|
||||
const hash2 = blake2b.blake2b(hash1, undefined, 32);
|
||||
|
||||
// Return hex-encoded (lowercase)
|
||||
return Buffer.from(hash2).toString('hex').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a token matches the stored hash.
|
||||
*/
|
||||
export function verifyToken(token: string, salt: string, storedHash: string): boolean {
|
||||
try {
|
||||
const computedHash = hashToken(token, salt);
|
||||
return computedHash === storedHash.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
123
server/src/utils/email.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
// SMTP configuration from environment variables
|
||||
const smtpConfig = {
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Default sender
|
||||
const defaultFrom = process.env.SMTP_FROM || 'noreply@leagues.tools';
|
||||
|
||||
// Create reusable transporter
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport(smtpConfig);
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Check if email is configured
|
||||
export function isEmailConfigured(): boolean {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
|
||||
}
|
||||
|
||||
// Send email
|
||||
export async function sendEmail(options: {
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||
if (!isEmailConfigured()) {
|
||||
console.warn('Email not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.');
|
||||
return { success: false, error: 'Email service not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getTransporter().sendMail({
|
||||
from: defaultFrom,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html,
|
||||
});
|
||||
|
||||
console.log('Email sent:', info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to send email' };
|
||||
}
|
||||
}
|
||||
|
||||
// Send password reset email
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
resetToken: string,
|
||||
baseUrl: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`;
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject: 'Password Reset Request - Leagues Tools',
|
||||
text: `
|
||||
You requested a password reset for your Leagues Tools account.
|
||||
|
||||
Click the link below to reset your password:
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
`.trim(),
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #1a1a2e; color: #e94560; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f5f5f5; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #e94560; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Leagues Tools</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested a password reset for your Leagues Tools account.</p>
|
||||
<p>Click the button below to reset your password:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${resetUrl}" class="button">Reset Password</a>
|
||||
</p>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 12px; color: #666;">${resetUrl}</p>
|
||||
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Leagues Tools - OSRS League Tracker</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
11
server/src/utils/password.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
18
server/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||