Initial commit
This commit is contained in:
commit
558f03cb70
299 changed files with 73007 additions and 0 deletions
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["next"]
|
||||
}
|
||||
41
.github/workflows/build.yml
vendored
Normal file
41
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "newgui" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: false
|
||||
NEXT_PUBLIC_API_URL: api.worldguessr.com
|
||||
NEXT_PUBLIC_WS_HOST: server.worldguessr.com
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: 471080734176-vm588te8pig8tnmvi00b5hr143d64qjk.apps.googleusercontent.com
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
# - name: Use dev env on newgui
|
||||
# if: github.ref_name == 'newgui'
|
||||
# run: |
|
||||
# echo "NEXT_PUBLIC_API_URL=devapi.worldguessr.com" >> $GITHUB_ENV
|
||||
# echo "NEXT_PUBLIC_WS_HOST=devserver.worldguessr.com" >> $GITHUB_ENV
|
||||
|
||||
- name: Install and Build
|
||||
run: |
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./out
|
||||
publish_branch: ${{ github.ref_name == 'master' && 'gh-pages' || 'gh-pages-dev' }}
|
||||
52
.github/workflows/coolmath-build.yml
vendored
Normal file
52
.github/workflows/coolmath-build.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: CoolMath Games Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
- 'gh-pages-dev'
|
||||
- 'coolmath-build'
|
||||
- 'coolmath-build-*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-coolmath:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: false
|
||||
NEXT_PUBLIC_COOLMATH: true
|
||||
NEXT_PUBLIC_API_URL: api.worldguessr.com
|
||||
NEXT_PUBLIC_WS_HOST: server.worldguessr.com
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: 471080734176-vm588te8pig8tnmvi00b5hr143d64qjk.apps.googleusercontent.com
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Build CoolMath Games version
|
||||
run: npm run build
|
||||
|
||||
- name: Get branch name
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH_NAME="${GITHUB_REF_NAME}"
|
||||
# Sanitize branch name for use in git branch name
|
||||
SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/-/g')
|
||||
echo "name=$SAFE_BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
echo "Building CoolMath version for branch: $BRANCH_NAME"
|
||||
|
||||
- name: Deploy to CoolMath build branch
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./out
|
||||
publish_branch: coolmath-build-${{ steps.branch.outputs.name }}
|
||||
commit_message: "CoolMath build from ${{ github.sha }}"
|
||||
|
||||
53
.github/workflows/gamedistribution-build.yml
vendored
Normal file
53
.github/workflows/gamedistribution-build.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: GameDistribution Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
- 'gh-pages-dev'
|
||||
- 'coolmath-build'
|
||||
- 'coolmath-build-*'
|
||||
- 'gamedistribution-build'
|
||||
- 'gamedistribution-build-*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-gamedistribution:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: false
|
||||
NEXT_PUBLIC_GAMEDISTRIBUTION: true
|
||||
NEXT_PUBLIC_API_URL: api.worldguessr.com
|
||||
NEXT_PUBLIC_WS_HOST: server.worldguessr.com
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: 471080734176-vm588te8pig8tnmvi00b5hr143d64qjk.apps.googleusercontent.com
|
||||
NEXT_PUBLIC_BASE_PATH: https://revision.gamedistribution.com/fef00656129743768437b7589b7c48b1/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Build GameDistribution version
|
||||
run: npm run build
|
||||
|
||||
- name: Get branch name
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH_NAME="${GITHUB_REF_NAME}"
|
||||
# Sanitize branch name for use in git branch name
|
||||
SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/-/g')
|
||||
echo "name=$SAFE_BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
echo "Building GameDistribution version for branch: $BRANCH_NAME"
|
||||
|
||||
- name: Deploy to GameDistribution build branch
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./out
|
||||
publish_branch: gamedistribution-build-${{ steps.branch.outputs.name }}
|
||||
commit_message: "GameDistribution build from ${{ github.sha }}"
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
*.heapsnapshot
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
tmpclaude*
|
||||
logs
|
||||
|
||||
#visual studio
|
||||
.vs
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
public/wiki
|
||||
|
||||
/scripts/*.json
|
||||
/.claude
|
||||
vali-data/*
|
||||
mapgens/*.txt
|
||||
mapgens/*-locations.json
|
||||
|
||||
# Partytown (generated from node_modules)
|
||||
public/~partytown/
|
||||
mobile/
|
||||
25
.replit
Normal file
25
.replit
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
modules = ["nodejs-20:v8-20230920-bd784b9"]
|
||||
hidden = [".config", "package-lock.json"]
|
||||
run = "npm run dev"
|
||||
|
||||
[gitHubImport]
|
||||
requiredFiles = [".replit", "replit.nix", "package.json", "package-lock.json"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-23_05"
|
||||
|
||||
[unitTest]
|
||||
language = "nodejs"
|
||||
|
||||
[deployment]
|
||||
run = ["sh", "-c", "npm run start"]
|
||||
deploymentTarget = "cloudrun"
|
||||
ignorePorts = false
|
||||
|
||||
[[ports]]
|
||||
localPort = 3000
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 3001
|
||||
externalPort = 3001
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# ── Stage 1: Build static frontend ──
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_WS_HOST
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_HOST=$NEXT_PUBLIC_WS_HOST
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
RUN pnpm build
|
||||
|
||||
# ── Stage 2: Frontend (nginx serving static files) ──
|
||||
FROM nginx:alpine AS frontend
|
||||
COPY --from=builder /app/out /usr/share/nginx/html
|
||||
|
||||
# ── Stage 3: Backend (API + WS + Cron) ──
|
||||
FROM node:20-slim AS backend
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm concurrently
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY . .
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001 3002 3003
|
||||
CMD ["npx", "concurrently", "node server.js", "node ws/ws.js", "node cron.js"]
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Gautam Anand
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1
Procfile
Normal file
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: npm start
|
||||
120
README.md
Normal file
120
README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
|
||||
<a href="https://worldguessr.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/codergautam/worldguessr/master/public/logo-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/codergautam/worldguessr/master/public/logo-readme-light.png">
|
||||
<img alt="WorldGuessr" src="https://raw.githubusercontent.com/codergautam/worldguessr/master/public/logo-readme-light.png">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
A free and open-source version of the popular geography game inspired by GeoGuessr. This React based project aims to provide a fun and educational way to explore the world through Google Street View imagery.
|
||||
|
||||
### Play now [here](https://worldguessr.com)!
|
||||
#### [Join the Discord community](https://discord.gg/yenVspFmkB)
|
||||
|
||||
## Features
|
||||
|
||||
- **Random Street Views:** Experience a new location anywhere in the world on each game.
|
||||
- **Multiplayer Mode:** Challenge your friends or play against random opponents in real-time.
|
||||
- **Country Streaks:** Test your knowledge and see how many countries you can guess in a row.
|
||||
- **Free to run:** The project is open-source and free to run on your own server. Uses the [Google Maps Streetview Embed API](https://developers.google.com/streetview/web), which is completely free compared to the costly SDK used by GeoGuessr.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [Leaflet](https://leafletjs.com/) for the minimap display.
|
||||
- [Google Maps API](https://developers.google.com/maps) for the generous free-tier on street view imagery.
|
||||
- [Vali](https://github.com/slashP/Vali) by @SlashP for generating balanced locations distributions for all countries.
|
||||
- [Next.js](https://nextjs.org/) for the web application.
|
||||
- All contributors who helped bring this project to life!
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before you start, ensure you have the following installed:
|
||||
- [Node.js](https://nodejs.org/en/) (v12.x or later)
|
||||
- [npm](https://www.npmjs.com/) (v6.x or later)
|
||||
- [pnpm](https://pnpm.io/) (v8.x or later)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/codergautam/worldguessr.git
|
||||
cd worldguessr
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Run the development server:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## Deploying to a VPS / External Server
|
||||
|
||||
If you're deploying WorldGuessr on a VPS or any server with an external IP (not localhost), you **must** configure these environment variables in your `.env` file:
|
||||
|
||||
```bash
|
||||
# Replace YOUR_IP with your server's IP address or domain
|
||||
NEXT_PUBLIC_API_URL=YOUR_IP:3001
|
||||
NEXT_PUBLIC_WS_HOST=YOUR_IP:3002
|
||||
```
|
||||
|
||||
**Example with IP:**
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=123.45.67.89:3001
|
||||
NEXT_PUBLIC_WS_HOST=123.45.67.89:3002
|
||||
```
|
||||
|
||||
**Example with domain (after setting up nginx):**
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=api.yourdomain.com
|
||||
NEXT_PUBLIC_WS_HOST=ws.yourdomain.com
|
||||
```
|
||||
|
||||
### Quick Setup Checklist
|
||||
|
||||
1. **MongoDB** - Create a cluster on [MongoDB Atlas](https://www.mongodb.com/atlas) (free tier available) and add the connection string:
|
||||
```bash
|
||||
MONGODB=mongodb+srv://username:password@cluster.mongodb.net/worldguessr
|
||||
```
|
||||
|
||||
2. **Google OAuth** - Create credentials at [Google Cloud Console](https://console.cloud.google.com/):
|
||||
```bash
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
3. **API/WS URLs** - Point to your external IP or domain (see above)
|
||||
|
||||
For detailed environment variable documentation, see [docs/environment-variables.md](docs/environment-variables.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
|
||||
Don't forget to give the project a star! Thanks again!
|
||||
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the MIT License. You are free to use, modify, and distribute this project for personal or commercial use. See `LICENSE.md` for more information.
|
||||
|
||||
## Community
|
||||
|
||||
Join the Discord community [here](https://discord.gg/yenVspFmkB) to discuss new features, report bugs, talk to the developers and connect with other players.
|
||||
|
||||
You can email me privately at gautam@worldguessr.com
|
||||
32
api/checkIfNameChangeProgress.js
Normal file
32
api/checkIfNameChangeProgress.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// pages/api/checkNameChange.js
|
||||
import User from '../models/User.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Extract the token from the request body
|
||||
const { token } = req.body;
|
||||
if (typeof token !== 'string' || !token) {
|
||||
return res.status(400).json({ name: null });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by the provided token
|
||||
const user = await User.findOne({ secret: token });
|
||||
if (!user) {
|
||||
return res.status(404).json({ name: null });
|
||||
}
|
||||
|
||||
// Check if the username was changed within the last 24 hours
|
||||
if (user.lastNameChange && Date.now() - new Date(user.lastNameChange).getTime() < 24 * 60 * 60 * 1000) {
|
||||
return res.status(200).json({ name: user.username});
|
||||
}
|
||||
|
||||
res.status(200).json({ name: null });
|
||||
} catch (error) {
|
||||
res.status(500).json({ name: null });
|
||||
}
|
||||
}
|
||||
69
api/checkNameChangeStatus.js
Normal file
69
api/checkNameChangeStatus.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import User from '../models/User.js';
|
||||
import NameChangeRequest from '../models/NameChangeRequest.js';
|
||||
|
||||
/**
|
||||
* Check Name Change Status API
|
||||
*
|
||||
* Returns the status of the user's pending name change request
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret } = req.body;
|
||||
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if user has a pending name change requirement
|
||||
if (!user.pendingNameChange) {
|
||||
return res.status(200).json({
|
||||
hasPendingRequest: false,
|
||||
pendingNameChange: false
|
||||
});
|
||||
}
|
||||
|
||||
// Check for existing request (pending or rejected)
|
||||
const existingRequest = await NameChangeRequest.findOne({
|
||||
'user.accountId': user._id.toString(),
|
||||
status: { $in: ['pending', 'rejected'] }
|
||||
}).sort({ createdAt: -1 }).lean();
|
||||
|
||||
if (existingRequest) {
|
||||
return res.status(200).json({
|
||||
hasPendingRequest: existingRequest.status === 'pending',
|
||||
pendingNameChange: true,
|
||||
request: {
|
||||
requestedUsername: existingRequest.requestedUsername,
|
||||
status: existingRequest.status,
|
||||
rejectionReason: existingRequest.rejectionReason,
|
||||
rejectionCount: existingRequest.rejectionCount,
|
||||
createdAt: existingRequest.createdAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// User needs to change name but hasn't submitted a request yet
|
||||
return res.status(200).json({
|
||||
hasPendingRequest: false,
|
||||
pendingNameChange: true,
|
||||
request: null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Check name change status error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
56
api/clues/getClue.js
Normal file
56
api/clues/getClue.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Clue from '../../models/Clue.js';
|
||||
import User from '../../models/User.js';
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { lat, lng } = req.query;
|
||||
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ message: 'Missing lat or lng query parameters' });
|
||||
}
|
||||
|
||||
const latitude = parseFloat(lat);
|
||||
const longitude = parseFloat(lng);
|
||||
|
||||
if (isNaN(latitude) || isNaN(longitude)) {
|
||||
return res.status(400).json({ message: 'Invalid lat or lng values' });
|
||||
}
|
||||
|
||||
// Fetch clues from the database
|
||||
const clues = await Clue.find({ lat: latitude, lng: longitude });
|
||||
|
||||
if(!clues) {
|
||||
return res.status(200).json({ error: 'notfound' });
|
||||
}
|
||||
|
||||
// Fetch user data and map to the clue results
|
||||
let cluesWithUsernames = await Promise.all(clues.map(async (clue) => {
|
||||
const user = await User.findById(clue.created_by);
|
||||
return {
|
||||
id: clue._id,
|
||||
cluetext: clue.clue,
|
||||
// rating is a decimal128, convert to number
|
||||
rating: clue.rating ? parseFloat(clue.rating.toString()) : 0,
|
||||
ratingcount: clue.ratingCnt,
|
||||
created_by_name: user ? user.username : 'Unknown',
|
||||
created_at: new Date() - clue.created_at.getTime(), // Convert to relative time in milliseconds
|
||||
};
|
||||
}));
|
||||
|
||||
// sort by highest rating
|
||||
cluesWithUsernames.sort((a, b) => b.rating - a.rating);
|
||||
|
||||
res.status(200).json(cluesWithUsernames);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error fetching clues' });
|
||||
}
|
||||
} else {
|
||||
// Handle any non-GET requests
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export default handler;
|
||||
39
api/clues/getCluesCount.js
Normal file
39
api/clues/getCluesCount.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Clue from '../../models/Clue.js';
|
||||
|
||||
let clueCountCache = {
|
||||
count: 0,
|
||||
timestamp: 0
|
||||
};
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Check if the cached value is valid (1 hour = 3600000 milliseconds)
|
||||
if (currentTime - clueCountCache.timestamp < 3600000) {
|
||||
return res.status(200).json({ count: clueCountCache.count });
|
||||
}
|
||||
|
||||
// Fetch clue count from the database
|
||||
const count = await Clue.countDocuments();
|
||||
|
||||
// Update the cache
|
||||
clueCountCache = {
|
||||
count,
|
||||
timestamp: currentTime
|
||||
};
|
||||
|
||||
res.status(200).json({ count });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error fetching clue count' });
|
||||
}
|
||||
} else {
|
||||
// Handle any non-GET requests
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export default handler;
|
||||
125
api/clues/makeClue.js
Normal file
125
api/clues/makeClue.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import formidable from 'formidable';
|
||||
// import { OpenAI } from 'openai';
|
||||
import fs from 'fs';
|
||||
import User from '../../models/User.js';
|
||||
import Clue from '../../models/Clue.js';
|
||||
|
||||
// const openai = new OpenAI({
|
||||
// apiKey: process.env.OPENAI_API_KEY,
|
||||
// });
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method === 'POST') {
|
||||
// Parse the incoming form data
|
||||
const form = formidable({});
|
||||
let fields;
|
||||
let files;
|
||||
try {
|
||||
[fields, files] = await form.parse(req);
|
||||
const secret = fields.secret[0];
|
||||
if(!secret) {
|
||||
return res.status(400).json({ message: 'Missing secret' });
|
||||
}
|
||||
// secret must be string
|
||||
if(typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
// get user from secret
|
||||
const user = await User.findOne({
|
||||
secret: secret
|
||||
});
|
||||
if(!user) {
|
||||
return res.status(400).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if(!files.screenImage) {
|
||||
|
||||
if(!user.canMakeClues) {
|
||||
return res.status(403).json({ message: 'User not authorized' });
|
||||
}
|
||||
// save a clue
|
||||
let { lat,lng, clueText } = fields;
|
||||
lat = parseFloat(lat[0]);
|
||||
lng = parseFloat(lng[0]);
|
||||
clueText = clueText[0];
|
||||
|
||||
if(clueText && clueText.length > 1000) {
|
||||
return res.status(400).json({ message: 'Text too long' });
|
||||
}
|
||||
if(clueText && clueText.length < 100) {
|
||||
return res.status(400).json({ message: 'Text too short' });
|
||||
}
|
||||
if(!lat || !lng || !clueText) {
|
||||
return res.status(400).json({ message: 'Missing latLong or clueText' });
|
||||
}
|
||||
|
||||
// make sure user doesnt have a clue in the same location
|
||||
const existingClue = await Clue.findOne({
|
||||
lat,
|
||||
lng,
|
||||
created_by: user._id
|
||||
});
|
||||
|
||||
if(existingClue) {
|
||||
return res.status(400).json({ message: 'You already made an explanation here' });
|
||||
}
|
||||
|
||||
// save the clue
|
||||
const clue = new Clue({
|
||||
lat,
|
||||
lng,
|
||||
clue: clueText,
|
||||
created_by: user._id
|
||||
});
|
||||
await clue.save();
|
||||
|
||||
return res.status(200).json({ message: 'Clue saved' });
|
||||
|
||||
|
||||
} else {
|
||||
// if(!user.staff) {
|
||||
return res.status(403).json({ message: 'User not authorized' });
|
||||
// }
|
||||
|
||||
// const filePath = files.screenImage[0].filepath
|
||||
// const base64 = fs.readFileSync(filePath).toString('base64');
|
||||
// // make the request to OpenAI
|
||||
// const response = await openai.chat.completions.create({
|
||||
// model: "gpt-4o-mini",
|
||||
// messages: [
|
||||
// {
|
||||
// role: "user",
|
||||
// content: [
|
||||
// { type: "text", text: "Based on the given street view image, discuss specific unique aspects of this place leading to what country it may be. Only mention direct and obvious aspects that are clearly in the image, mention what you looked at in your clues that directly narrow down the country/region. At the end, reveal the country."+(fields.country ? ` The country is ${fields.country}.` : "") },
|
||||
// {
|
||||
// type: "image_url",
|
||||
// image_url: {
|
||||
// "url": `data:image/png;base64,${base64}`,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
// res.status(200).json({ message: response.choices[0].message.content[1].text });
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Error parsing form data' });
|
||||
}
|
||||
} else {
|
||||
// Handle any non-POST requests
|
||||
res.setHeader('Allow', ['POST']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
64
api/clues/rateClue.js
Normal file
64
api/clues/rateClue.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Clue from '../../models/Clue.js';
|
||||
import User from '../../models/User.js';
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const { clueId, rating, secret } = req.body;
|
||||
|
||||
// secret must be string
|
||||
if (typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
if (!clueId || !rating || !secret) {
|
||||
return res.status(400).json({ message: 'Missing clueId, rating, or secret' });
|
||||
}
|
||||
|
||||
if (rating < 1 || rating > 5) {
|
||||
return res.status(400).json({ message: 'Rating must be an integer between 1 and 5' });
|
||||
}
|
||||
|
||||
// Find the user by secret
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if(!user.rated_clues) {
|
||||
user.rated_clues = new Map();
|
||||
}
|
||||
|
||||
// Check if the user has already rated this clue
|
||||
if (user.rated_clues.has(clueId)) {
|
||||
return res.status(400).json({ message: 'You have already rated this clue' });
|
||||
}
|
||||
|
||||
// Find the clue by ID
|
||||
const clue = await Clue.findById(clueId);
|
||||
if (!clue) {
|
||||
return res.status(404).json({ message: 'Clue not found' });
|
||||
}
|
||||
|
||||
// Update the clue's rating and rating count
|
||||
clue.rating = ((clue.rating * clue.ratingCnt) + rating) / (clue.ratingCnt + 1);
|
||||
clue.ratingCnt += 1;
|
||||
await clue.save();
|
||||
|
||||
// Update the user's rated_clues
|
||||
user.rated_clues.set(clueId, rating);
|
||||
await user.save();
|
||||
|
||||
res.status(200).json({ message: 'Clue rated successfully' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error rating clue' });
|
||||
}
|
||||
} else {
|
||||
// Handle any non-POST requests
|
||||
res.setHeader('Allow', ['POST']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export default handler;
|
||||
15
api/country.js
Normal file
15
api/country.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
// xml 2 json npm
|
||||
import lookup from "coordinate_to_country"
|
||||
export default async function handler(req, res) {
|
||||
|
||||
const { lat, lon } = req.query;
|
||||
if(!lat || !lon) return res.status(400).json({address: {country: null}});
|
||||
const output = lookup(parseFloat(lat), parseFloat(lon), true);
|
||||
if(output && output.length > 0) {
|
||||
res.status(200).json({address: {country: output[0]}});
|
||||
} else {
|
||||
res.status(200).json({address: {country: null}});
|
||||
}
|
||||
|
||||
}
|
||||
215
api/crazyAuth.js
Normal file
215
api/crazyAuth.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
const { verify } = jwt;
|
||||
import axios from "axios";
|
||||
import { createUUID } from "../components/createUUID.js";
|
||||
import User, { USERNAME_COLLATION } from "../models/User.js";
|
||||
import timezoneToCountry from "../serverUtils/timezoneToCountry.js";
|
||||
import cachegoose from 'recachegoose';
|
||||
import { getLeague } from '../components/utils/leagues.js';
|
||||
|
||||
const USERNAME_CHANGE_COOLDOWN = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
// In-memory cache for CrazyGames public key (1 hour TTL)
|
||||
let cachedPublicKey = null;
|
||||
let publicKeyCacheTime = 0;
|
||||
const PUBLIC_KEY_CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
async function getCrazyGamesPublicKey() {
|
||||
const now = Date.now();
|
||||
if (cachedPublicKey && (now - publicKeyCacheTime) < PUBLIC_KEY_CACHE_TTL) {
|
||||
return cachedPublicKey;
|
||||
}
|
||||
const resp = await axios.get("https://sdk.crazygames.com/publicKey.json");
|
||||
cachedPublicKey = resp.data["publicKey"];
|
||||
publicKeyCacheTime = now;
|
||||
return cachedPublicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extended user data (publicAccount + eloRank data) for combined response
|
||||
*/
|
||||
async function getExtendedUserData(user, timings = {}) {
|
||||
const startExtended = Date.now();
|
||||
|
||||
// publicAccount data
|
||||
const lastNameChange = user.lastNameChange ? new Date(user.lastNameChange).getTime() : 0;
|
||||
const publicData = {
|
||||
totalXp: user.totalXp || 0,
|
||||
createdAt: user.created_at,
|
||||
gamesLen: user.totalGamesPlayed || 0,
|
||||
lastLogin: user.lastLogin || user.created_at,
|
||||
canChangeUsername: !user.lastNameChange || Date.now() - lastNameChange > USERNAME_CHANGE_COOLDOWN,
|
||||
daysUntilNameChange: lastNameChange ? Math.max(0, Math.ceil((lastNameChange + USERNAME_CHANGE_COOLDOWN - Date.now()) / (24 * 60 * 60 * 1000))) : 0,
|
||||
recentChange: user.lastNameChange ? Date.now() - lastNameChange < 24 * 60 * 60 * 1000 : false,
|
||||
};
|
||||
|
||||
// eloRank data
|
||||
const startRank = Date.now();
|
||||
const rank = (await User.countDocuments({
|
||||
elo: { $gt: user.elo || 1000 },
|
||||
banned: false
|
||||
}).cache(2000)) + 1;
|
||||
timings.rankQuery = Date.now() - startRank;
|
||||
|
||||
const eloData = {
|
||||
elo: user.elo || 1000,
|
||||
rank,
|
||||
league: getLeague(user.elo || 1000),
|
||||
duels_wins: user.duels_wins || 0,
|
||||
duels_losses: user.duels_losses || 0,
|
||||
duels_tied: user.duels_tied || 0,
|
||||
win_rate: (user.duels_wins || 0) / ((user.duels_wins || 0) + (user.duels_losses || 0) + (user.duels_tied || 0)) || 0
|
||||
};
|
||||
|
||||
timings.extendedData = Date.now() - startExtended;
|
||||
|
||||
return { ...publicData, ...eloData };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const timings = {};
|
||||
const startTotal = Date.now();
|
||||
// only accept post
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
const { token, username } = req.body;
|
||||
if (!token || !username) {
|
||||
return res.status(400).json({ error: 'Invalid input' });
|
||||
}
|
||||
|
||||
// make sure they are strings
|
||||
if (typeof token !== 'string' || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid input' });
|
||||
}
|
||||
|
||||
let decodedToken;
|
||||
try {
|
||||
const publicKey = await getCrazyGamesPublicKey();
|
||||
decodedToken = verify(token, publicKey, { algorithms: ["RS256"] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const { userId } = decodedToken;
|
||||
|
||||
// check if userId exists
|
||||
timings.authType = 'existing_user_check';
|
||||
const startUserLookup = Date.now();
|
||||
const user = await User.findOne({ crazyGamesId: userId }).cache(120, `crazyAuth_${userId}`);
|
||||
timings.userLookup = Date.now() - startUserLookup;
|
||||
|
||||
if (user) {
|
||||
// Auto-assign country code from timezone if not set (lazy migration)
|
||||
// Use == null to catch both null and undefined (for users without the field)
|
||||
if (user.countryCode == null && user.timeZone) {
|
||||
const countryCode = timezoneToCountry(user.timeZone);
|
||||
if (countryCode) {
|
||||
await User.findByIdAndUpdate(user._id, { countryCode });
|
||||
user.countryCode = countryCode;
|
||||
|
||||
// Clear auth cache to ensure fresh data on next request
|
||||
cachegoose.clearCache(`crazyAuth_${userId}`, (error) => {
|
||||
if (error) {
|
||||
console.error('Error clearing auth cache after country code update:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get extended user data (publicAccount + eloRank)
|
||||
const extendedData = await getExtendedUserData(user, timings);
|
||||
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[crazyAuth] Timings (ms):', JSON.stringify(timings));
|
||||
|
||||
return res.status(200).json({
|
||||
secret: user.secret,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
staff: user.staff,
|
||||
canMakeClues: user.canMakeClues,
|
||||
supporter: user.supporter,
|
||||
accountId: user._id,
|
||||
countryCode: user.countryCode || null,
|
||||
banned: user.banned || false,
|
||||
banType: user.banType || 'none',
|
||||
banExpiresAt: user.banExpiresAt || null,
|
||||
banPublicNote: user.banPublicNote || null,
|
||||
pendingNameChange: user.pendingNameChange || false,
|
||||
pendingNameChangePublicNote: user.pendingNameChangePublicNote || null,
|
||||
// Extended data (publicAccount + eloRank combined)
|
||||
...extendedData
|
||||
});
|
||||
}
|
||||
|
||||
// check if username is taken
|
||||
let newUsername = username.substring(0, 30).replace(/[^a-zA-Z0-9_]/g, '');
|
||||
let finalUsername = newUsername;
|
||||
let taken = true;
|
||||
let trial = 0;
|
||||
while (taken) {
|
||||
const existing = await User.findOne({ username: finalUsername }).collation(USERNAME_COLLATION);
|
||||
if (!existing) {
|
||||
taken = false;
|
||||
} else {
|
||||
trial++;
|
||||
finalUsername = `${newUsername}${trial}`;
|
||||
}
|
||||
}
|
||||
|
||||
// create new user
|
||||
timings.isNewUser = true;
|
||||
// Note: countryCode is left as null (schema default) for new users.
|
||||
// We don't auto-assign based on timeZone here because timeZone defaults to
|
||||
// 'America/Los_Angeles', which would incorrectly assign all new users to 'US'.
|
||||
// Users can manually set their country flag later in their profile.
|
||||
const secret = createUUID();
|
||||
const newUser = new User({ crazyGamesId: userId, username: finalUsername, secret });
|
||||
|
||||
const startSave = Date.now();
|
||||
await newUser.save();
|
||||
timings.newUserCreate = Date.now() - startSave;
|
||||
|
||||
// Default extended data for new users
|
||||
// Rank = count of users with elo > 1000 (starting elo) + 1
|
||||
const startRank = Date.now();
|
||||
const usersAbove = await User.countDocuments({ elo: { $gt: 1000 }, banned: false }).cache(2000);
|
||||
timings.rankQuery = Date.now() - startRank;
|
||||
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[crazyAuth] Timings (ms):', JSON.stringify(timings));
|
||||
|
||||
return res.status(200).json({
|
||||
secret: newUser.secret,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
staff: newUser.staff || false,
|
||||
canMakeClues: newUser.canMakeClues || false,
|
||||
supporter: newUser.supporter || false,
|
||||
accountId: newUser._id,
|
||||
countryCode: null,
|
||||
banned: false,
|
||||
banType: 'none',
|
||||
banExpiresAt: null,
|
||||
banPublicNote: null,
|
||||
pendingNameChange: false,
|
||||
pendingNameChangePublicNote: null,
|
||||
// Extended data defaults for new users
|
||||
totalXp: 0,
|
||||
createdAt: newUser.created_at,
|
||||
gamesLen: 0,
|
||||
lastLogin: newUser.created_at,
|
||||
canChangeUsername: true,
|
||||
daysUntilNameChange: 0,
|
||||
recentChange: false,
|
||||
elo: 1000,
|
||||
rank: usersAbove + 1,
|
||||
league: getLeague(1000),
|
||||
duels_wins: 0,
|
||||
duels_losses: 0,
|
||||
duels_tied: 0,
|
||||
win_rate: 0
|
||||
});
|
||||
}
|
||||
94
api/eloRank.js
Normal file
94
api/eloRank.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import mongoose from 'mongoose';
|
||||
import User, { USERNAME_COLLATION } from '../models/User.js';
|
||||
import { getLeague } from '../components/utils/leagues.js';
|
||||
import { rateLimit } from '../utils/rateLimit.js';
|
||||
|
||||
// given a username return the elo and the rank of the user
|
||||
export default async function handler(req, res) {
|
||||
const { username, secret } = req.query;
|
||||
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[API] eloRank: ${username || '(by secret)'} | IP: ${ip}`);
|
||||
|
||||
// Only allow GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Rate limiting: 30 requests per minute per IP
|
||||
const limiter = rateLimit({ max: 30, windowMs: 60000 });
|
||||
if (!limiter(req, res)) {
|
||||
console.log(`[API] eloRank: RATE LIMITED | IP: ${ip}`);
|
||||
return; // Rate limit exceeded, response already sent
|
||||
}
|
||||
|
||||
// Connect to MongoDB
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Database connection failed', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
let user;
|
||||
let foundBySecret = false;
|
||||
|
||||
if(secret && typeof secret === 'string') {
|
||||
// Prevent NoSQL injection - secret must be a string
|
||||
user = await User.findOne({ secret }).cache(120);
|
||||
if (user) foundBySecret = true;
|
||||
} else if(username && typeof username === 'string') {
|
||||
// Prevent NoSQL injection - username must be a string
|
||||
user = await User.findOne({ username: username }).collation(USERNAME_COLLATION).cache(120);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// If not found by their own secret, hide banned or pending-name-change users
|
||||
if (!foundBySecret && (user.banned || user.pendingNameChange)) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
const rank = (await User.countDocuments({
|
||||
elo: { $gt: user.elo },
|
||||
banned: false
|
||||
}).cache(2000)) + 1;
|
||||
|
||||
// Return the user's elo and rank
|
||||
return res.status(200).json({
|
||||
id: user._id,
|
||||
elo: user.elo,
|
||||
rank,
|
||||
league: getLeague(user.elo),
|
||||
duels_wins: user.duels_wins,
|
||||
duels_losses: user.duels_losses,
|
||||
duels_tied: user.duels_tied,
|
||||
win_rate: user.duels_wins / (user.duels_wins + user.duels_losses + user.duels_tied)
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'An error occurred', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setElo(accountId, newElo, gameData) {
|
||||
|
||||
// gamedata -> {draw:true|false, winner: true|false}
|
||||
try {
|
||||
|
||||
|
||||
await User.updateOne({ _id: accountId }, { elo: newElo,
|
||||
$inc: { duels_played: 1, duels_wins: gameData.winner ? 1 : 0, duels_losses: gameData.winner ? 0 : 1, duels_tied: gameData.draw ? 1 : 0,
|
||||
elo_today: newElo - gameData.oldElo,
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting elo:', error.message);
|
||||
}
|
||||
|
||||
}
|
||||
125
api/gameDetails.js
Normal file
125
api/gameDetails.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import Game from '../models/Game.js';
|
||||
import User from '../models/User.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, gameId } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
if (!gameId || typeof gameId !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid gameId' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify user exists
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
const isMod = user.staff === true;
|
||||
|
||||
// Fetch the specific game
|
||||
// Mods can access any game, regular users can only access games they participated in
|
||||
const query = isMod
|
||||
? { gameId: gameId }
|
||||
: { gameId: gameId, 'players.accountId': user._id };
|
||||
|
||||
const game = await Game.findOne(query).lean();
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ message: 'Game not found or access denied' });
|
||||
}
|
||||
|
||||
// Format the game data for roundOverScreen
|
||||
const formattedGame = {
|
||||
gameId: game.gameId,
|
||||
gameType: game.gameType,
|
||||
startedAt: game.startedAt,
|
||||
endedAt: game.endedAt,
|
||||
totalDuration: game.totalDuration,
|
||||
|
||||
// Game settings
|
||||
settings: game.settings,
|
||||
|
||||
// All rounds with locations and guesses
|
||||
rounds: game.rounds.map((round, index) => {
|
||||
// Find user's guess for this round
|
||||
const userGuess = round.playerGuesses.find(guess => guess.accountId === user._id.toString());
|
||||
|
||||
return {
|
||||
roundNumber: round.roundNumber,
|
||||
location: round.location,
|
||||
|
||||
// User's guess data
|
||||
guess: userGuess ? {
|
||||
guessLat: userGuess.guessLat,
|
||||
guessLong: userGuess.guessLong,
|
||||
points: userGuess.points,
|
||||
timeTaken: userGuess.timeTaken,
|
||||
xpEarned: userGuess.xpEarned,
|
||||
usedHint: userGuess.usedHint,
|
||||
guessedAt: userGuess.guessedAt
|
||||
} : null,
|
||||
|
||||
// All player guesses (for multiplayer games)
|
||||
allGuesses: round.playerGuesses.map(guess => ({
|
||||
playerId: guess.accountId, // Use accountId instead of playerId to avoid exposing secrets
|
||||
username: guess.username,
|
||||
guessLat: guess.guessLat,
|
||||
guessLong: guess.guessLong,
|
||||
points: guess.points,
|
||||
timeTaken: guess.timeTaken,
|
||||
xpEarned: guess.xpEarned,
|
||||
usedHint: guess.usedHint
|
||||
})),
|
||||
|
||||
startedAt: round.startedAt,
|
||||
endedAt: round.endedAt,
|
||||
roundTimeLimit: round.roundTimeLimit
|
||||
};
|
||||
}),
|
||||
|
||||
// All players (for multiplayer games)
|
||||
players: game.players.map(player => ({
|
||||
playerId: player.accountId, // Use accountId instead of playerId to avoid exposing secrets
|
||||
username: player.username,
|
||||
accountId: player.accountId,
|
||||
totalPoints: player.totalPoints,
|
||||
totalXp: player.totalXp,
|
||||
averageTimePerRound: player.averageTimePerRound,
|
||||
finalRank: player.finalRank,
|
||||
elo: player.elo
|
||||
})),
|
||||
|
||||
// Game result
|
||||
result: game.result,
|
||||
|
||||
// Multiplayer info
|
||||
multiplayer: game.multiplayer,
|
||||
|
||||
// Find the requesting user's player data
|
||||
userPlayer: game.players.find(player => player.accountId === user._id.toString()),
|
||||
|
||||
// Add user's _id for frontend comparisons
|
||||
currentUserId: user._id.toString()
|
||||
};
|
||||
|
||||
return res.status(200).json({ game: formattedGame });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Game details error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching game details',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
132
api/gameHistory.js
Normal file
132
api/gameHistory.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import Game from '../models/Game.js';
|
||||
import User from '../models/User.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, page = 1, limit = 10 } = req.body;
|
||||
|
||||
// Validate secret
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify user exists
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const pageNum = Math.max(1, parseInt(page));
|
||||
const limitNum = Math.min(50, Math.max(1, parseInt(limit))); // Max 50 games per page
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
// Fetch user's games with pagination
|
||||
const games = await Game.find({
|
||||
'players.accountId': user._id
|
||||
})
|
||||
.sort({ endedAt: -1 }) // Most recent first
|
||||
.skip(skip)
|
||||
.limit(limitNum)
|
||||
.lean();
|
||||
|
||||
// Get total count for pagination
|
||||
const totalGames = await Game.countDocuments({
|
||||
'players.accountId': user._id
|
||||
});
|
||||
|
||||
// Calculate pagination info
|
||||
const totalPages = Math.ceil(totalGames / limitNum);
|
||||
const hasNextPage = pageNum < totalPages;
|
||||
const hasPrevPage = pageNum > 1;
|
||||
|
||||
// Format games for frontend
|
||||
const formattedGames = games.map(game => {
|
||||
// Find the user's player data
|
||||
const userPlayer = game.players.find(player => player.accountId === user._id.toString() || player.accountId === secret);
|
||||
|
||||
// For ranked duels, find opponent data
|
||||
let opponentPlayer = null;
|
||||
if (game.gameType === 'ranked_duel') {
|
||||
opponentPlayer = game.players.find(player =>
|
||||
player.accountId !== user._id.toString() && player.accountId !== secret
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
gameId: game.gameId,
|
||||
gameType: game.gameType,
|
||||
startedAt: game.startedAt,
|
||||
endedAt: game.endedAt,
|
||||
totalDuration: game.totalDuration,
|
||||
|
||||
// User's performance
|
||||
userStats: {
|
||||
totalPoints: userPlayer?.totalPoints || 0,
|
||||
totalXp: userPlayer?.totalXp || 0,
|
||||
averageTimePerRound: userPlayer?.averageTimePerRound || 0,
|
||||
finalRank: userPlayer?.finalRank || 1,
|
||||
elo: userPlayer?.elo || null
|
||||
},
|
||||
|
||||
// Game settings
|
||||
settings: {
|
||||
location: game.settings?.location || 'all',
|
||||
rounds: game.settings?.rounds || 5,
|
||||
maxDist: game.settings?.maxDist || 20000,
|
||||
timePerRound: game.settings?.timePerRound,
|
||||
official: game.settings?.official ?? true
|
||||
},
|
||||
|
||||
// Game result
|
||||
result: {
|
||||
maxPossiblePoints: game.result?.maxPossiblePoints || (game.settings?.rounds || 5) * 5000,
|
||||
winner: game.result?.winner,
|
||||
isDraw: game.result?.isDraw || false
|
||||
},
|
||||
|
||||
// Multiplayer info (if applicable)
|
||||
multiplayer: game.gameType !== 'singleplayer' ? {
|
||||
isPublic: game.multiplayer?.isPublic || false,
|
||||
playerCount: game.players?.length || 1,
|
||||
gameCode: game.multiplayer?.gameCode
|
||||
} : null,
|
||||
|
||||
// Opponent info (for ranked duels)
|
||||
opponent: opponentPlayer ? {
|
||||
username: opponentPlayer.username,
|
||||
totalPoints: opponentPlayer.totalPoints || 0,
|
||||
finalRank: opponentPlayer.finalRank || 2,
|
||||
elo: opponentPlayer.elo || null
|
||||
} : null,
|
||||
|
||||
// Round count for display
|
||||
roundsPlayed: game.rounds?.length || 0
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
games: formattedGames,
|
||||
pagination: {
|
||||
currentPage: pageNum,
|
||||
totalPages,
|
||||
totalGames,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
limit: limitNum
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Game history error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching game history',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
16
api/getCountries.js
Normal file
16
api/getCountries.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// import { promises as fs } from 'fs';
|
||||
// import path from 'path';
|
||||
// import geolib, { getDistance } from 'geolib';
|
||||
|
||||
import countries from '../public/countries.json' with { type: "json" };
|
||||
import countryMaxDists from '../public/countryMaxDists.json' with { type: "json" };
|
||||
|
||||
async function getCountries(req, res) {
|
||||
const out = {};
|
||||
for (const country of countries) {
|
||||
out[country] = countryMaxDists[country];
|
||||
}
|
||||
res.json(out);
|
||||
}
|
||||
|
||||
export default getCountries;
|
||||
376
api/googleAuth.js
Normal file
376
api/googleAuth.js
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import { createUUID } from "../components/createUUID.js";
|
||||
import User from "../models/User.js";
|
||||
import { Webhook } from "discord-webhook-node";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import timezoneToCountry from "../serverUtils/timezoneToCountry.js";
|
||||
import cachegoose from 'recachegoose';
|
||||
import { getLeague } from '../components/utils/leagues.js';
|
||||
|
||||
const USERNAME_CHANGE_COOLDOWN = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
const client = new OAuth2Client(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'postmessage');
|
||||
|
||||
/**
|
||||
* Check and handle temp ban expiration
|
||||
* Also handles migration of legacy banned users (banned: true but no banType)
|
||||
* Returns the user with updated ban status if expired
|
||||
*/
|
||||
async function checkTempBanExpiration(user) {
|
||||
const userObj = user.toObject ? user.toObject() : user;
|
||||
|
||||
// Handle legacy banned users - if banned is true but banType is missing/none,
|
||||
// treat as permanent ban (migration from old system)
|
||||
if (userObj.banned && (!userObj.banType || userObj.banType === 'none')) {
|
||||
// Migrate to new system - mark as permanent ban
|
||||
await User.findByIdAndUpdate(user._id, {
|
||||
banType: 'permanent'
|
||||
});
|
||||
return {
|
||||
...userObj,
|
||||
banType: 'permanent'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if temp ban has expired
|
||||
if (userObj.banned && userObj.banType === 'temporary' && userObj.banExpiresAt) {
|
||||
const now = new Date();
|
||||
if (now >= new Date(userObj.banExpiresAt)) {
|
||||
// Temp ban has expired - auto unban
|
||||
await User.findByIdAndUpdate(user._id, {
|
||||
banned: false,
|
||||
banType: 'none',
|
||||
banExpiresAt: null
|
||||
});
|
||||
// Return updated status
|
||||
return {
|
||||
...userObj,
|
||||
banned: false,
|
||||
banType: 'none',
|
||||
banExpiresAt: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return userObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extended user data (publicAccount + eloRank data) for combined response
|
||||
* This eliminates the need for separate publicAccount and eloRank API calls
|
||||
*/
|
||||
async function getExtendedUserData(user, timings) {
|
||||
const startExtended = Date.now();
|
||||
|
||||
// publicAccount data
|
||||
const lastNameChange = user.lastNameChange ? new Date(user.lastNameChange).getTime() : 0;
|
||||
const publicData = {
|
||||
totalXp: user.totalXp || 0,
|
||||
createdAt: user.created_at,
|
||||
gamesLen: user.totalGamesPlayed || 0,
|
||||
lastLogin: user.lastLogin || user.created_at,
|
||||
canChangeUsername: !user.lastNameChange || Date.now() - lastNameChange > USERNAME_CHANGE_COOLDOWN,
|
||||
daysUntilNameChange: lastNameChange ? Math.max(0, Math.ceil((lastNameChange + USERNAME_CHANGE_COOLDOWN - Date.now()) / (24 * 60 * 60 * 1000))) : 0,
|
||||
recentChange: user.lastNameChange ? Date.now() - lastNameChange < 24 * 60 * 60 * 1000 : false,
|
||||
};
|
||||
|
||||
// eloRank data
|
||||
const startRank = Date.now();
|
||||
const rank = (await User.countDocuments({
|
||||
elo: { $gt: user.elo || 1000 },
|
||||
banned: false
|
||||
}).cache(2000)) + 1;
|
||||
timings.rankQuery = Date.now() - startRank;
|
||||
|
||||
const eloData = {
|
||||
elo: user.elo || 1000,
|
||||
rank,
|
||||
league: getLeague(user.elo || 1000),
|
||||
duels_wins: user.duels_wins || 0,
|
||||
duels_losses: user.duels_losses || 0,
|
||||
duels_tied: user.duels_tied || 0,
|
||||
win_rate: (user.duels_wins || 0) / ((user.duels_wins || 0) + (user.duels_losses || 0) + (user.duels_tied || 0)) || 0
|
||||
};
|
||||
|
||||
timings.extendedData = Date.now() - startExtended;
|
||||
|
||||
return { ...publicData, ...eloData };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const timings = {};
|
||||
const startTotal = Date.now();
|
||||
let output = {};
|
||||
// only accept post
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
const { code, secret, redirect_uri } = req.body;
|
||||
if (!code) {
|
||||
// Prevent NoSQL injection - secret must be a string
|
||||
if(!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid' });
|
||||
}
|
||||
|
||||
timings.authType = 'secret';
|
||||
const startUserLookup = Date.now();
|
||||
const userDb = await User.findOne({
|
||||
secret,
|
||||
}).select("_id secret username email staff canMakeClues supporter banned banType banExpiresAt banPublicNote pendingNameChange pendingNameChangePublicNote timeZone countryCode totalXp created_at totalGamesPlayed lastLogin lastNameChange elo duels_wins duels_losses duels_tied").cache(120, `userAuth_${secret}`);
|
||||
timings.userLookup = Date.now() - startUserLookup;
|
||||
|
||||
if (userDb) {
|
||||
// Check if temp ban has expired
|
||||
const startBanCheck = Date.now();
|
||||
const checkedUser = await checkTempBanExpiration(userDb);
|
||||
timings.banCheck = Date.now() - startBanCheck;
|
||||
|
||||
// Auto-assign country code from timezone if not set (lazy migration)
|
||||
// Use == null to catch both null and undefined (for users without the field)
|
||||
if (checkedUser.countryCode == null && checkedUser.timeZone) {
|
||||
const startCountryMigration = Date.now();
|
||||
const countryCode = timezoneToCountry(checkedUser.timeZone);
|
||||
if (countryCode) {
|
||||
await User.findByIdAndUpdate(checkedUser._id, { countryCode });
|
||||
checkedUser.countryCode = countryCode;
|
||||
|
||||
// Clear auth cache to ensure fresh data on next request
|
||||
cachegoose.clearCache(`userAuth_${secret}`, (error) => {
|
||||
if (error) {
|
||||
console.error('Error clearing auth cache after country code update:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
timings.countryMigration = Date.now() - startCountryMigration;
|
||||
}
|
||||
|
||||
// Get extended user data (publicAccount + eloRank)
|
||||
const extendedData = await getExtendedUserData(checkedUser, timings);
|
||||
|
||||
output = {
|
||||
secret: checkedUser.secret,
|
||||
username: checkedUser.username,
|
||||
email: checkedUser.email,
|
||||
staff: checkedUser.staff,
|
||||
canMakeClues: checkedUser.canMakeClues,
|
||||
supporter: checkedUser.supporter,
|
||||
accountId: checkedUser._id,
|
||||
countryCode: checkedUser.countryCode || null,
|
||||
// Ban info (public note only - internal reason never exposed)
|
||||
banned: checkedUser.banned,
|
||||
banType: checkedUser.banType || 'none',
|
||||
banExpiresAt: checkedUser.banExpiresAt,
|
||||
banPublicNote: checkedUser.banPublicNote || null,
|
||||
// Pending name change (public note only - internal reason never exposed)
|
||||
pendingNameChange: checkedUser.pendingNameChange,
|
||||
pendingNameChangePublicNote: checkedUser.pendingNameChangePublicNote || null,
|
||||
// Extended data (publicAccount + eloRank combined)
|
||||
...extendedData
|
||||
};
|
||||
|
||||
if(!checkedUser.username || checkedUser.username.length < 1) {
|
||||
// try again without cache, to prevent new users getting stuck with no username
|
||||
timings.retryWithoutCache = true;
|
||||
const startRetry = Date.now();
|
||||
const userDb2 = await User.findOne({
|
||||
secret,
|
||||
}).select("_id secret username email staff canMakeClues supporter banned banType banExpiresAt banPublicNote pendingNameChange pendingNameChangePublicNote timeZone countryCode totalXp created_at totalGamesPlayed lastLogin lastNameChange elo duels_wins duels_losses duels_tied");
|
||||
timings.retryLookup = Date.now() - startRetry;
|
||||
|
||||
if(userDb2) {
|
||||
const checkedUser2 = await checkTempBanExpiration(userDb2);
|
||||
|
||||
// Auto-assign country code from timezone if not set (lazy migration)
|
||||
// Use == null to catch both null and undefined (for users without the field)
|
||||
if (checkedUser2.countryCode == null && checkedUser2.timeZone) {
|
||||
const countryCode = timezoneToCountry(checkedUser2.timeZone);
|
||||
if (countryCode) {
|
||||
await User.findByIdAndUpdate(checkedUser2._id, { countryCode });
|
||||
checkedUser2.countryCode = countryCode;
|
||||
|
||||
// Clear auth cache to ensure fresh data on next request
|
||||
cachegoose.clearCache(`userAuth_${secret}`, (error) => {
|
||||
if (error) {
|
||||
console.error('Error clearing auth cache after country code update:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get extended user data (publicAccount + eloRank)
|
||||
const extendedData2 = await getExtendedUserData(checkedUser2, timings);
|
||||
|
||||
output = {
|
||||
secret: checkedUser2.secret,
|
||||
username: checkedUser2.username,
|
||||
email: checkedUser2.email,
|
||||
staff: checkedUser2.staff,
|
||||
canMakeClues: checkedUser2.canMakeClues,
|
||||
supporter: checkedUser2.supporter,
|
||||
accountId: checkedUser2._id,
|
||||
countryCode: checkedUser2.countryCode || null,
|
||||
banned: checkedUser2.banned,
|
||||
banType: checkedUser2.banType || 'none',
|
||||
banExpiresAt: checkedUser2.banExpiresAt,
|
||||
banPublicNote: checkedUser2.banPublicNote || null,
|
||||
pendingNameChange: checkedUser2.pendingNameChange,
|
||||
pendingNameChangePublicNote: checkedUser2.pendingNameChangePublicNote || null,
|
||||
// Extended data (publicAccount + eloRank combined)
|
||||
...extendedData2
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
return res.status(200).json(output);
|
||||
} else {
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
return res.status(400).json({ error: 'Invalid' });
|
||||
}
|
||||
|
||||
} else {
|
||||
// first login
|
||||
timings.authType = 'google_oauth';
|
||||
try {
|
||||
// verify the access token
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const startTokenExchange = Date.now();
|
||||
// Use provided redirect_uri for redirect flow (GD), otherwise default client uses 'postmessage' (popup flow)
|
||||
const tokenClient = redirect_uri
|
||||
? new OAuth2Client(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect_uri)
|
||||
: client;
|
||||
const { tokens } = await tokenClient.getToken(code);
|
||||
tokenClient.setCredentials(tokens);
|
||||
timings.tokenExchange = Date.now() - startTokenExchange;
|
||||
|
||||
const startTokenVerify = Date.now();
|
||||
const ticket = await tokenClient.verifyIdToken({
|
||||
idToken: tokens.id_token,
|
||||
audience: clientId,
|
||||
});
|
||||
timings.tokenVerify = Date.now() - startTokenVerify;
|
||||
|
||||
if(!ticket) {
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
return res.status(400).json({ error: 'Invalid token verification' });
|
||||
}
|
||||
|
||||
const email = ticket.getPayload()?.email;
|
||||
|
||||
if (!email) {
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
return res.status(400).json({ error: 'No email in token' });
|
||||
}
|
||||
|
||||
const startEmailLookup = Date.now();
|
||||
const existingUser = await User.findOne({ email });
|
||||
timings.emailLookup = Date.now() - startEmailLookup;
|
||||
let secret = null;
|
||||
if (!existingUser) {
|
||||
timings.isNewUser = true;
|
||||
const startNewUser = Date.now();
|
||||
// Note: countryCode is left as null (schema default) for new users.
|
||||
// We don't auto-assign based on timeZone here because timeZone defaults to
|
||||
// 'America/Los_Angeles', which would incorrectly assign all new users to 'US'.
|
||||
// Users can manually set their country flag later in their profile.
|
||||
secret = createUUID();
|
||||
const newUser = new User({ email, secret });
|
||||
|
||||
await newUser.save();
|
||||
timings.newUserCreate = Date.now() - startNewUser;
|
||||
|
||||
// Default extended data for new users
|
||||
// Rank = count of users with elo > 1000 (starting elo) + 1
|
||||
const startRank = Date.now();
|
||||
const usersAbove = await User.countDocuments({ elo: { $gt: 1000 }, banned: false }).cache(2000);
|
||||
timings.rankQuery = Date.now() - startRank;
|
||||
|
||||
output = {
|
||||
secret: secret,
|
||||
username: undefined,
|
||||
email: email,
|
||||
staff: false,
|
||||
canMakeClues: false,
|
||||
supporter: false,
|
||||
accountId: newUser._id,
|
||||
countryCode: null,
|
||||
banned: false,
|
||||
banType: 'none',
|
||||
banExpiresAt: null,
|
||||
banPublicNote: null,
|
||||
pendingNameChange: false,
|
||||
pendingNameChangePublicNote: null,
|
||||
// Extended data defaults for new users
|
||||
totalXp: 0,
|
||||
createdAt: newUser.created_at,
|
||||
gamesLen: 0,
|
||||
lastLogin: newUser.created_at,
|
||||
canChangeUsername: true,
|
||||
daysUntilNameChange: 0,
|
||||
recentChange: false,
|
||||
elo: 1000,
|
||||
rank: usersAbove + 1,
|
||||
league: getLeague(1000),
|
||||
duels_wins: 0,
|
||||
duels_losses: 0,
|
||||
duels_tied: 0,
|
||||
win_rate: 0
|
||||
};
|
||||
} else {
|
||||
timings.isNewUser = false;
|
||||
// Check if temp ban has expired for existing user
|
||||
const startBanCheck = Date.now();
|
||||
const checkedUser = await checkTempBanExpiration(existingUser);
|
||||
timings.banCheck = Date.now() - startBanCheck;
|
||||
|
||||
// Auto-assign country code from timezone if not set (lazy migration)
|
||||
// Use == null to catch both null and undefined (for users without the field)
|
||||
if (checkedUser.countryCode == null && checkedUser.timeZone) {
|
||||
const countryCode = timezoneToCountry(checkedUser.timeZone);
|
||||
if (countryCode) {
|
||||
await User.findByIdAndUpdate(checkedUser._id, { countryCode });
|
||||
checkedUser.countryCode = countryCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Get extended user data (publicAccount + eloRank)
|
||||
const extendedData = await getExtendedUserData(checkedUser, timings);
|
||||
|
||||
output = {
|
||||
secret: checkedUser.secret,
|
||||
username: checkedUser.username,
|
||||
email: checkedUser.email,
|
||||
staff: checkedUser.staff,
|
||||
canMakeClues: checkedUser.canMakeClues,
|
||||
supporter: checkedUser.supporter,
|
||||
accountId: checkedUser._id,
|
||||
countryCode: checkedUser.countryCode || null,
|
||||
banned: checkedUser.banned,
|
||||
banType: checkedUser.banType || 'none',
|
||||
banExpiresAt: checkedUser.banExpiresAt,
|
||||
banPublicNote: checkedUser.banPublicNote || null,
|
||||
pendingNameChange: checkedUser.pendingNameChange,
|
||||
pendingNameChangePublicNote: checkedUser.pendingNameChangePublicNote || null,
|
||||
// Extended data (publicAccount + eloRank combined)
|
||||
...extendedData
|
||||
};
|
||||
}
|
||||
|
||||
timings.total = Date.now() - startTotal;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
return res.status(200).json(output);
|
||||
} catch (error) {
|
||||
timings.total = Date.now() - startTotal;
|
||||
timings.error = error.message;
|
||||
console.log('[googleAuth] Timings (ms):', JSON.stringify(timings));
|
||||
console.error('Google OAuth error:', error.message);
|
||||
return res.status(400).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
196
api/leaderboard.js
Normal file
196
api/leaderboard.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import User, { USERNAME_COLLATION } from '../models/User.js';
|
||||
import DailyLeaderboard from '../models/DailyLeaderboard.js';
|
||||
|
||||
// Cache for leaderboard data
|
||||
const CACHE_DURATION = 60000; // 1 minute cache
|
||||
const cache = new Map();
|
||||
|
||||
function getCacheKey(mode, pastDay) {
|
||||
return `${mode}_${pastDay ? 'daily' : 'alltime'}`;
|
||||
}
|
||||
|
||||
function getCachedData(key) {
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedData(key, data) {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
function sendableUser(user) {
|
||||
if (!user.username) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
username: user.username,
|
||||
countryCode: user.countryCode || null,
|
||||
totalXp: user.totalXp ?? user.xpGained ?? 0,
|
||||
createdAt: user.created_at,
|
||||
gamesLen: user.totalGamesPlayed ?? 0,
|
||||
elo: user.elo ?? 1000,
|
||||
eloToday: user.elo_today ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Load pre-computed daily leaderboard from DailyLeaderboard collection
|
||||
async function getDailyLeaderboard(isXp = true) {
|
||||
const mode = isXp ? 'xp' : 'elo';
|
||||
const now = new Date();
|
||||
|
||||
// Get today's midnight UTC for consistent lookups
|
||||
const todayMidnight = new Date(now);
|
||||
todayMidnight.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
// Fetch pre-computed leaderboard (fast query with date+mode index)
|
||||
const precomputedLeaderboard = await DailyLeaderboard.findOne({
|
||||
date: todayMidnight,
|
||||
mode: mode
|
||||
}).lean().maxTimeMS(2000);
|
||||
|
||||
if (!precomputedLeaderboard) {
|
||||
console.warn('[LEADERBOARD] Pre-computed daily leaderboard not found');
|
||||
return { leaderboard: [] };
|
||||
}
|
||||
|
||||
// Transform pre-computed data to match expected format (only top 100 for display)
|
||||
const leaderboard = precomputedLeaderboard.leaderboard.slice(0, 100).map(entry => ({
|
||||
username: entry.username,
|
||||
countryCode: entry.countryCode || null,
|
||||
totalXp: isXp ? entry.delta : entry.currentValue,
|
||||
createdAt: null,
|
||||
gamesLen: 0,
|
||||
elo: isXp ? entry.currentValue : entry.delta,
|
||||
eloToday: entry.delta,
|
||||
rank: entry.rank,
|
||||
supporter: entry.supporter || false
|
||||
}));
|
||||
|
||||
return { leaderboard };
|
||||
}
|
||||
|
||||
// Get user's position from pre-computed daily leaderboard (top 50k)
|
||||
async function getUserDailyRank(username, isXp = true) {
|
||||
const user = await User.findOne({ username: username }).collation(USERNAME_COLLATION).maxTimeMS(2000);
|
||||
if (!user) return { rank: null, delta: null };
|
||||
|
||||
const mode = isXp ? 'xp' : 'elo';
|
||||
const now = new Date();
|
||||
|
||||
// Get today's midnight UTC
|
||||
const todayMidnight = new Date(now);
|
||||
todayMidnight.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
// Fetch pre-computed leaderboard (contains top 50k users)
|
||||
const precomputedLeaderboard = await DailyLeaderboard.findOne({
|
||||
date: todayMidnight,
|
||||
mode: mode
|
||||
}).lean().maxTimeMS(2000);
|
||||
|
||||
if (!precomputedLeaderboard) {
|
||||
return { rank: null, delta: null };
|
||||
}
|
||||
|
||||
// Find user in pre-computed leaderboard (searches through top 50k)
|
||||
const userEntry = precomputedLeaderboard.leaderboard.find(
|
||||
entry => entry.userId === user._id.toString()
|
||||
);
|
||||
|
||||
if (userEntry) {
|
||||
return { rank: userEntry.rank, delta: userEntry.delta };
|
||||
}
|
||||
|
||||
// User not in top 50k - no activity or very low delta
|
||||
return { rank: null, delta: null };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const myUsername = req.query.username;
|
||||
const pastDay = req.query.pastDay === 'true';
|
||||
const isXp = req.query.mode === 'xp';
|
||||
console.log(`[API] leaderboard: mode=${isXp ? 'xp' : 'elo'}, pastDay=${pastDay}, user=${myUsername || 'none'}`);
|
||||
|
||||
// Prevent NoSQL injection - username must be a string if provided
|
||||
if (myUsername && typeof myUsername !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid username' });
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = getCacheKey(isXp ? 'xp' : 'elo', pastDay);
|
||||
let leaderboard = getCachedData(cacheKey);
|
||||
let myRank = null;
|
||||
let myScore = null;
|
||||
|
||||
if (!leaderboard) {
|
||||
if (pastDay) {
|
||||
// Daily leaderboard from pre-computed DailyLeaderboard collection
|
||||
const dailyResult = await getDailyLeaderboard(isXp);
|
||||
leaderboard = dailyResult.leaderboard;
|
||||
setCachedData(cacheKey, leaderboard);
|
||||
} else {
|
||||
// All-time leaderboard
|
||||
const sortField = isXp ? 'totalXp' : 'elo';
|
||||
const topUsers = await User.find({
|
||||
banned: false,
|
||||
pendingNameChange: { $ne: true }
|
||||
})
|
||||
.sort({ [sortField]: -1 })
|
||||
.limit(100)
|
||||
.lean()
|
||||
.maxTimeMS(5000);
|
||||
|
||||
leaderboard = topUsers.map(sendableUser).filter(user => user !== null);
|
||||
setCachedData(cacheKey, leaderboard);
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's rank and score
|
||||
let myCountryCode = null;
|
||||
if (myUsername) {
|
||||
if (pastDay) {
|
||||
const userResult = await getUserDailyRank(myUsername, isXp);
|
||||
myRank = userResult.rank;
|
||||
myScore = userResult.delta;
|
||||
const user = await User.findOne({ username: myUsername }).collation(USERNAME_COLLATION).select('countryCode').maxTimeMS(2000);
|
||||
myCountryCode = user?.countryCode || null;
|
||||
} else {
|
||||
// All-time ranking
|
||||
const user = await User.findOne({ username: myUsername }).collation(USERNAME_COLLATION).maxTimeMS(2000);
|
||||
if (user) {
|
||||
myCountryCode = user.countryCode || null;
|
||||
const sortField = isXp ? 'totalXp' : 'elo';
|
||||
myScore = user[sortField];
|
||||
if (myScore) {
|
||||
const betterUsersCount = await User.countDocuments({
|
||||
[sortField]: { $gt: myScore },
|
||||
banned: false
|
||||
}).maxTimeMS(5000);
|
||||
myRank = betterUsersCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseKey = isXp ? 'myXp' : 'myElo';
|
||||
return res.status(200).json({
|
||||
leaderboard,
|
||||
myRank,
|
||||
myCountryCode,
|
||||
[responseKey]: myScore
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Leaderboard API error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
246
api/map/action.js
Normal file
246
api/map/action.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// import mapConst from "@/components/maps/mapConst";
|
||||
// import parseMapData from "@/components/utils/parseMapData";
|
||||
// import generateSlug from "@/components/utils/slugGenerator";
|
||||
// import Map from "@/models/Map";
|
||||
// import User from "@/models/User";
|
||||
|
||||
// import countries from '@/public/countries.json';
|
||||
// import officialCountryMaps from '@/public/officialCountryMaps.json';
|
||||
import mapConst from '../../components/maps/mapConst.js';
|
||||
import parseMapData from '../../components/utils/parseMapData.js';
|
||||
import generateSlug from '../../components/utils/slugGenerator.js';
|
||||
import Map from '../../models/Map.js';
|
||||
import User from '../../models/User.js';
|
||||
import { Filter} from 'bad-words';
|
||||
const filter = new Filter();
|
||||
import countries from '../../public/countries.json' with { type: "json" };
|
||||
import officialCountryMaps from '../../public/officialCountryMaps.json' with { type: "json" };
|
||||
|
||||
// Function to convert latitude and longitude to Cartesian coordinates
|
||||
function latLngToCartesian(lat, lng) {
|
||||
const R = 6371; // Earth radius in km
|
||||
const phi = (lat * Math.PI) / 180;
|
||||
const theta = (lng * Math.PI) / 180;
|
||||
const x = R * Math.cos(phi) * Math.cos(theta);
|
||||
const y = R * Math.cos(phi) * Math.sin(theta);
|
||||
const z = R * Math.sin(phi);
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
// Function to calculate the distance between two Cartesian coordinates
|
||||
function calculateDistance(cart1, cart2) {
|
||||
const dx = cart1.x - cart2.x;
|
||||
const dy = cart1.y - cart2.y;
|
||||
const dz = cart1.z - cart2.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
|
||||
async function validateMap(name, data, description_short, description_long, edit=false, mapId=null) {
|
||||
|
||||
if(!name || !data || !description_short) {
|
||||
return 'Missing name, data, or description_short';
|
||||
}
|
||||
|
||||
name = name.trim();
|
||||
description_short = description_short.trim();
|
||||
description_long = description_long ? description_long.trim() : '';
|
||||
|
||||
// name cannot include crazygamesdue to a url detection bug
|
||||
if(name.toLowerCase().includes('crazygames')) {
|
||||
return 'Name cannot include "CrazyGames"';
|
||||
}
|
||||
|
||||
// validate name
|
||||
if(typeof name !== 'string' || name.length < mapConst.MIN_NAME_LENGTH || name.length > mapConst.MAX_NAME_LENGTH) {
|
||||
// return res.status(400).json({ message: `Name must be between ${mapConst.MIN_NAME_LENGTH} and ${mapConst.MAX_NAME_LENGTH} characters` });
|
||||
return `Name must be between ${mapConst.MIN_NAME_LENGTH} and ${mapConst.MAX_NAME_LENGTH} characters`;
|
||||
}
|
||||
|
||||
// validate short description
|
||||
if(typeof description_short !== 'string' || description_short.length < mapConst.MIN_SHORT_DESCRIPTION_LENGTH || description_short.length > mapConst.MAX_SHORT_DESCRIPTION_LENGTH) {
|
||||
// return res.status(400).json({ message: `Short description must be between ${mapConst.MIN_SHORT_DESCRIPTION_LENGTH} and ${mapConst.MAX_SHORT_DESCRIPTION_LENGTH} characters` });
|
||||
return `Short description must be between ${mapConst.MIN_SHORT_DESCRIPTION_LENGTH} and ${mapConst.MAX_SHORT_DESCRIPTION_LENGTH} characters`;
|
||||
}
|
||||
|
||||
// validate long description (only if provided)
|
||||
if(typeof description_long !== 'string' || description_long.length > mapConst.MAX_LONG_DESCRIPTION_LENGTH) {
|
||||
return `Long description must be under ${mapConst.MAX_LONG_DESCRIPTION_LENGTH} characters`;
|
||||
}
|
||||
|
||||
// if long description is provided, it must meet minimum length
|
||||
if(description_long.length > 0 && description_long.length < mapConst.MIN_LONG_DESCRIPTION_LENGTH) {
|
||||
return `Long description must be at least ${mapConst.MIN_LONG_DESCRIPTION_LENGTH} characters or left empty`;
|
||||
}
|
||||
|
||||
// make sure short and long descriptions are different (only if long description is provided)
|
||||
if(description_long.length > 0 && description_short === description_long) {
|
||||
// return res.status(400).json({ message: 'Short and long descriptions must be different' });
|
||||
return 'Short and long descriptions must be different';
|
||||
}
|
||||
|
||||
const slug = generateSlug(name);
|
||||
if(slug === 'all' || countries.includes(slug.toUpperCase()) || Object.values(officialCountryMaps).find(map => map.slug === slug)) {
|
||||
// return res.status(400).json({ message: 'Please choose a different name' });
|
||||
return 'Please choose a different name';
|
||||
}
|
||||
if(slug.toLowerCase().includes('crazygames') ) {
|
||||
return 'Name cannot include "CrazyGames"';
|
||||
}
|
||||
|
||||
// validate data
|
||||
const locationsData = parseMapData(data);
|
||||
if(!locationsData || locationsData.length < mapConst.MIN_LOCATIONS) {
|
||||
// return res.status(400).json({ message: 'Need at least ' + mapConst.MIN_LOCATIONS + ' valid locations (got ' + (locationsData?.length ?? 0)+ ')' });
|
||||
return 'Need at least ' + mapConst.MIN_LOCATIONS + ' valid locations (got ' + (locationsData?.length ?? 0)+ ')';
|
||||
}
|
||||
if(locationsData.length > mapConst.MAX_LOCATIONS) {
|
||||
// return res.status(400).json({ message: `To make a map with more than ${mapConst.MAX_LOCATIONS} locations, please contact us at gautam@worldguessr.com` });
|
||||
return `To make a map with more than ${mapConst.MAX_LOCATIONS} locations, please contact us at`
|
||||
}
|
||||
|
||||
// Convert all locations to Cartesian coordinates
|
||||
const cartesianLocations = locationsData.map(loc => latLngToCartesian(loc.lat, loc.lng));
|
||||
|
||||
// Sort by x-coordinate (you can choose any dimension)
|
||||
cartesianLocations.sort((a, b) => a.x - b.x);
|
||||
|
||||
// Find the maximum distance between the first and last sorted locations
|
||||
const maxDist = calculateDistance(cartesianLocations[0], cartesianLocations[cartesianLocations.length - 1]);
|
||||
|
||||
// make sure slug or name is not already taken
|
||||
const existing = await Map.findOne({ slug: slug });
|
||||
if(existing && (edit ? existing._id.toString() != mapId : true)) {
|
||||
return 'Name already taken';
|
||||
}
|
||||
const existingName = await Map.findOne({ name: name });
|
||||
if(existingName && (edit ? existingName._id.toString() != mapId : true)) {
|
||||
return 'Name already taken';
|
||||
}
|
||||
|
||||
return { slug, locationsData, maxDist };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
|
||||
// only allow post
|
||||
if(req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
let { action, secret, name, data, description_short, description_long, mapId } = req.body;
|
||||
|
||||
//secret must be string
|
||||
if(typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
if(!action || !secret) {
|
||||
return res.status(400).json({ message: 'Missing action or secret' });
|
||||
}
|
||||
|
||||
// make sure name,short&long desc is appopriate
|
||||
if(filter.isProfane(name) || filter.isProfane(description_short) || filter.isProfane(description_long)) {
|
||||
return res.status(400).json({ message: 'Inappropriate content' });
|
||||
}
|
||||
|
||||
|
||||
// get user from secret
|
||||
const user = await User.findOne({ secret: secret });
|
||||
if(!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// prevent banned users from creating/editing maps
|
||||
if(user.banned) {
|
||||
return res.status(403).json({ message: 'Your account is suspended. You cannot create or edit maps.' });
|
||||
}
|
||||
|
||||
// creating map
|
||||
if(action === 'create') {
|
||||
|
||||
const validation = await validateMap(name, data, description_short, description_long);
|
||||
if(typeof validation === 'string') {
|
||||
return res.status(400).json({ message: validation });
|
||||
}
|
||||
|
||||
// create map
|
||||
const map = await Map.create({
|
||||
slug: validation.slug,
|
||||
name,
|
||||
created_by: user._id,
|
||||
data: validation.locationsData,
|
||||
description_short,
|
||||
description_long,
|
||||
maxDist: validation.maxDist,
|
||||
// in_review: user.instant_accept_maps ? false : true,
|
||||
// accepted: user.instant_accept_maps ? true : false,
|
||||
in_review: false,
|
||||
accepted: true,
|
||||
map_creator_name: user.username,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: 'Map created', map });
|
||||
} else if(action === 'edit') {
|
||||
if(!mapId) {
|
||||
return res.status(400).json({ message: 'Missing mapId' });
|
||||
}
|
||||
|
||||
const map = await Map.findById(mapId);
|
||||
if(!map) {
|
||||
return res.status(404).json({ message: 'Map not found' });
|
||||
}
|
||||
if(!map.resubmittable) {
|
||||
return res.status(400).json({ message: 'This map cannot be edited' });
|
||||
}
|
||||
if(!user.staff && map.created_by.toString() !== user._id.toString()) {
|
||||
return res.status(403).json({ message: 'You do not have permission to edit this map' });
|
||||
}
|
||||
|
||||
const validation = await validateMap(name, data, description_short, description_long, true, mapId);
|
||||
if(typeof validation === 'string') {
|
||||
return res.status(400).json({ message: validation });
|
||||
}
|
||||
|
||||
// map.slug = validation.slug;
|
||||
map.name = name;
|
||||
map.data = validation.locationsData;
|
||||
map.description_short = description_short;
|
||||
map.description_long = description_long;
|
||||
// map.in_review= user.instant_accept_maps ? false : true;
|
||||
map.reject_reason = "";
|
||||
// map.accepted = !map.in_review;
|
||||
|
||||
map.maxDist = validation.maxDist;
|
||||
map.lastUpdated = new Date();
|
||||
|
||||
await map.save();
|
||||
|
||||
return res.status(200).json({ message: 'Map edited', map });
|
||||
} else if(action === 'get') {
|
||||
if(!mapId) {
|
||||
return res.status(400).json({ message: 'Missing mapId' });
|
||||
}
|
||||
|
||||
|
||||
const map = await Map.findById(mapId);
|
||||
|
||||
// make sure staff or owner
|
||||
if(!map || (!user.staff && map.created_by.toString() !== user._id.toString())) {
|
||||
return res.status(404).json({ message: 'Map not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ map });
|
||||
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: 'Invalid action' });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '30mb'
|
||||
}
|
||||
}
|
||||
}
|
||||
61
api/map/approveRejectMap.js
Normal file
61
api/map/approveRejectMap.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Map from "../../models/Map.js";
|
||||
import User from "../../models/User.js";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// only allow POST
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
let { secret, mapId, action, rejectReason, resubmittable } = req.body;
|
||||
// secret must be string
|
||||
if (typeof secret !== 'string' || typeof mapId !== 'string' || typeof action !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
// Validate input
|
||||
if (!secret || !mapId || !action) {
|
||||
return res.status(400).json({ message: 'Missing required fields' });
|
||||
}
|
||||
if(rejectReason && rejectReason.length > 50) {
|
||||
return res.status(400).json({ message: 'Reject reason must be 50 characters or less' });
|
||||
}
|
||||
|
||||
let user = await User.findOne({ secret: secret });
|
||||
|
||||
// Check if user exists and is a staff member
|
||||
if (!user || !user.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
let map = await Map.findById(mapId);
|
||||
|
||||
// Check if map exists and is in review
|
||||
if (!map || !map.in_review) {
|
||||
return res.status(404).json({ message: 'Map not found or not in review' });
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
// Approve the map
|
||||
map.accepted = true;
|
||||
map.in_review = false;
|
||||
map.lastUpdated = new Date();
|
||||
await map.save();
|
||||
return res.status(200).json({ message: 'Map approved successfully' });
|
||||
} else if (action === 'reject') {
|
||||
// Validate reject reason and resubmittable
|
||||
if (!rejectReason || typeof resubmittable !== 'boolean') {
|
||||
return res.status(400).json({ message: 'Reject reason and resubmittable status are required' });
|
||||
}
|
||||
|
||||
// Reject the map
|
||||
map.in_review = false;
|
||||
map.accepted = false;
|
||||
map.reject_reason = rejectReason;
|
||||
map.resubmittable = resubmittable;
|
||||
map.lastUpdated = new Date();
|
||||
await map.save();
|
||||
return res.status(200).json({ message: 'Map rejected successfully with reason: ' + rejectReason });
|
||||
} else {
|
||||
return res.status(400).json({ message: 'Invalid action' });
|
||||
}
|
||||
}
|
||||
47
api/map/delete.js
Normal file
47
api/map/delete.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Map from "../../models/Map.js";
|
||||
import User from "../../models/User.js";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// only allow DELETE
|
||||
if (req.method !== 'DELETE') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, mapId } = req.body;
|
||||
// make sure string for mapId and secret
|
||||
if (typeof mapId !== 'string' || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!secret || !mapId) {
|
||||
return res.status(400).json({ message: 'Missing required fields' });
|
||||
}
|
||||
if (typeof secret !== 'string' || typeof mapId !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
let user = await User.findOne({ secret: secret });
|
||||
|
||||
// Check if user exists
|
||||
if (!user) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
let map = await Map.findById(mapId);
|
||||
|
||||
// Check if map exists
|
||||
if (!map) {
|
||||
return res.status(404).json({ message: 'Map not found' });
|
||||
}
|
||||
|
||||
// Check if the user is either the owner of the map or a staff member
|
||||
if (map.created_by.toString() !== user._id.toString() && !user.staff) {
|
||||
return res.status(403).json({ message: 'You do not have permission to delete this map' });
|
||||
}
|
||||
|
||||
// Delete the map
|
||||
await Map.deleteOne({ _id: mapId });
|
||||
|
||||
return res.status(200).json({ message: 'Map deleted successfully' });
|
||||
}
|
||||
76
api/map/heartMap.js
Normal file
76
api/map/heartMap.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import Map from '../../models/Map.js';
|
||||
import User from '../../models/User.js';
|
||||
|
||||
const HEART_COOLDOWN = 500;
|
||||
let recentHearts = {}
|
||||
|
||||
async function handler(req, res) {
|
||||
if(!recentHearts) {
|
||||
recentHearts = {};
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const { mapId, secret } = req.body;
|
||||
|
||||
// secret must be string
|
||||
if (typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
|
||||
if (!mapId || !secret) {
|
||||
return res.status(400).json({ message: 'Missing values' });
|
||||
}
|
||||
|
||||
// Find the user by secret
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if(recentHearts[user._id] && Date.now() - recentHearts[user._id] < HEART_COOLDOWN) {
|
||||
return res.status(429).json({ message: 'yourTooFastForUs' });
|
||||
}
|
||||
|
||||
if (!user.hearted_maps) {
|
||||
user.hearted_maps = new Map();
|
||||
}
|
||||
|
||||
// Find the map by id
|
||||
const map = await Map.findById(mapId);
|
||||
if (!map) {
|
||||
return res.status(404).json({ message: 'Map not found' });
|
||||
}
|
||||
|
||||
if (user.hearted_maps.has(mapId)) {
|
||||
// If the user has already hearted the map, remove the heart
|
||||
user.hearted_maps.delete(mapId);
|
||||
map.hearts--;
|
||||
if(map.hearts < 0) {
|
||||
map.hearts = 0;
|
||||
}
|
||||
} else {
|
||||
// If the user has not hearted the map, add the heart
|
||||
user.hearted_maps.set(mapId, true);
|
||||
map.hearts++;
|
||||
}
|
||||
|
||||
//save in recentHearts
|
||||
recentHearts[user._id] = Date.now();
|
||||
|
||||
await map.save();
|
||||
await user.save();
|
||||
|
||||
res.status(200).json({ success: true, hearted: user.hearted_maps.has(mapId), hearts: map.hearts });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error toggling map heart' });
|
||||
}
|
||||
} else {
|
||||
// Handle any non-POST requests
|
||||
res.setHeader('Allow', ['POST']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export default handler;
|
||||
255
api/map/mapHome.js
Normal file
255
api/map/mapHome.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import sendableMap from "../../components/utils/sendableMap.js";
|
||||
import Map from "../../models/Map.js";
|
||||
import User from "../../models/User.js";
|
||||
import officialCountryMaps from '../../public/officialCountryMaps.json' with { type: "json" };
|
||||
import shuffle from "../../utils/shuffle.js";
|
||||
|
||||
let mapCache = {
|
||||
popular: {
|
||||
data: [],
|
||||
timeStamp: 0,
|
||||
persist: 9600000
|
||||
},
|
||||
recent: {
|
||||
data: [],
|
||||
timeStamp: 0,
|
||||
persist: 4800000
|
||||
},
|
||||
spotlight: {
|
||||
data: [],
|
||||
timeStamp: 0,
|
||||
persist: 48000000
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const timings = {};
|
||||
const startTotal = Date.now();
|
||||
|
||||
// Allow GET for anonymous requests (cacheable by Cloudflare)
|
||||
const isAnon = req.query.anon === 'true';
|
||||
|
||||
if(req.method === 'GET' && isAnon) {
|
||||
// Anonymous GET request - cacheable, no user lookup
|
||||
} else if(req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
let { secret, inCG } = req.body || {};
|
||||
|
||||
let user;
|
||||
|
||||
// Skip user lookup for anonymous requests
|
||||
if(secret && !isAnon) {
|
||||
// Prevent NoSQL injection - validate secret type BEFORE the query
|
||||
if(typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
const startUser = Date.now();
|
||||
user = await User.findOne({ secret: secret });
|
||||
timings.userLookup = Date.now() - startUser;
|
||||
if(!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
}
|
||||
|
||||
let hearted_maps = user ? user.hearted_maps : null;
|
||||
let response = {};
|
||||
// sections
|
||||
// [reviewQueue (if staff), myMaps (if exists), likedMaps, officialCountryMaps, recent, popular ]
|
||||
|
||||
// if(user?.staff) {
|
||||
// // reviewQueue
|
||||
// console.time('findReviewQueue');
|
||||
// // let queueMaps = await Map.find({ in_review: true });
|
||||
// let queueMaps = [];
|
||||
// console.timeEnd('findReviewQueue');
|
||||
|
||||
// console.time('findReviewQueueOwner');
|
||||
// let queueMapsSendable = await Promise.all(queueMaps.map(async (map) => {
|
||||
// let owner;
|
||||
// if(!map.map_creator_name) {
|
||||
// owner = await User.findById(map.created_by);
|
||||
// // save map creator name
|
||||
// console.log('updating map creator name', map._id, owner.username, map.name);
|
||||
// map.map_creator_name = owner.username;
|
||||
// await map.save();
|
||||
// } else {
|
||||
// owner = { username: map.map_creator_name };
|
||||
// }
|
||||
|
||||
// const isCreator = map.created_by === user._id.toString();
|
||||
// return sendableMap(map, owner, hearted_maps?hearted_maps.has(map._id.toString()):false, true, isCreator);
|
||||
// }));
|
||||
// console.timeEnd('findReviewQueueOwner');
|
||||
|
||||
// // oldest to newest
|
||||
// queueMapsSendable.sort((a,b) => b.created_at - a.created_at);
|
||||
// response.reviewQueue = queueMapsSendable;
|
||||
// }
|
||||
|
||||
// owned maps
|
||||
// find maps made by user
|
||||
if(user) {
|
||||
const startMyMaps = Date.now();
|
||||
// created_at, slug, name, hearts,plays, description_short, map_creator_name, _id, in_review, official, accepted, reject_reason, resubmittable, locationsCnt
|
||||
let myMaps = await Map.find({ created_by: user._id.toString() }).select({
|
||||
created_at: 1,
|
||||
lastUpdated: 1,
|
||||
slug: 1,
|
||||
name: 1,
|
||||
hearts: 1,
|
||||
plays: 1,
|
||||
description_short: 1,
|
||||
map_creator_name: 1,
|
||||
in_review: 1,
|
||||
official: 1,
|
||||
accepted: 1,
|
||||
reject_reason: 1,
|
||||
resubmittable: 1,
|
||||
// count # of data to get locations
|
||||
locationsCnt: { $size: "$data" }
|
||||
}).lean();
|
||||
myMaps = myMaps.map((map) => sendableMap(map, user, hearted_maps?hearted_maps.has(map._id.toString()):false, user.staff, true));
|
||||
myMaps.sort((a,b) => a.created_at - b.created_at);
|
||||
if(myMaps.length > 0) response.myMaps = myMaps;
|
||||
timings.myMaps = Date.now() - startMyMaps;
|
||||
|
||||
// likedMaps
|
||||
// find maps liked by user
|
||||
const startLikedMaps = Date.now();
|
||||
const likedMaps = user.hearted_maps ? await Map.find({ _id: { $in: Array.from(user.hearted_maps.keys()) } }) : [];
|
||||
let likedMapsSendable = await Promise.all(likedMaps.map(async (map) => {
|
||||
let owner;
|
||||
if(!map.map_creator_name) {
|
||||
owner = await User.findById(map.created_by);
|
||||
// save map creator name
|
||||
map.map_creator_name = owner.username;
|
||||
await map.save();
|
||||
|
||||
} else {
|
||||
owner = { username: map.map_creator_name };
|
||||
}
|
||||
return sendableMap(map, owner, true, user.staff, map.created_by === user._id.toString());
|
||||
}));
|
||||
likedMapsSendable.sort((a,b) => b.created_at - a.created_at);
|
||||
if(likedMapsSendable.length > 0) response.likedMaps = likedMapsSendable;
|
||||
timings.likedMaps = Date.now() - startLikedMaps;
|
||||
}
|
||||
|
||||
response.countryMaps = Object.values(officialCountryMaps).map((map) => ({
|
||||
...map,
|
||||
created_by_name: 'WorldGuessr',
|
||||
official: true,
|
||||
countryMap: map.countryCode,
|
||||
description_short: map.shortDescription,
|
||||
})).sort((b,a)=>a.maxDist - b.maxDist);
|
||||
|
||||
const discovery = ["spotlight","popular","recent"];
|
||||
for(const method of discovery) {
|
||||
const startMethod = Date.now();
|
||||
if(mapCache[method].data.length > 0 && Date.now() - mapCache[method].timeStamp < mapCache[method].persist) {
|
||||
// retrieve from cache
|
||||
response[method] = mapCache[method].data;
|
||||
timings[method] = Date.now() - startMethod;
|
||||
timings[method + '_cached'] = true;
|
||||
// check hearted maps
|
||||
response[method].map((map) => {
|
||||
map.hearted = hearted_maps?hearted_maps.has(map.id.toString()):false;
|
||||
return map;
|
||||
});
|
||||
|
||||
// for spotlight randomize the order
|
||||
if(method === "spotlight") {
|
||||
response[method] = shuffle(response[method]);
|
||||
}
|
||||
} else {
|
||||
// retrieve from db
|
||||
let maps = [];
|
||||
if(method === "recent") {
|
||||
maps = await Map.find({ accepted: true }).sort({ lastUpdated: -1 }).limit(100);
|
||||
} else if(method === "popular") {
|
||||
maps = await Map.find({ accepted: true }) .select({
|
||||
locationsCnt: { $size: "$data" },
|
||||
created_at: 1,
|
||||
lastUpdated: 1,
|
||||
slug: 1,
|
||||
name: 1,
|
||||
hearts: 1,
|
||||
plays: 1,
|
||||
description_short: 1,
|
||||
map_creator_name: 1,
|
||||
in_review: 1,
|
||||
official: 1,
|
||||
accepted: 1,
|
||||
reject_reason: 1,
|
||||
resubmittable: 1
|
||||
});
|
||||
|
||||
// sort and limit to 100
|
||||
maps = maps.sort((a,b) => b.hearts - a.hearts).slice(0,100);
|
||||
|
||||
} else if(method === "spotlight") {
|
||||
maps = await Map.find({ accepted: true, spotlight: true }).limit(100).allowDiskUse(true);
|
||||
}
|
||||
|
||||
let sendableMaps = await Promise.all(maps.map(async (map) => {
|
||||
let owner;
|
||||
if(!map.map_creator_name && map.data) {
|
||||
owner = await User.findById(map.created_by);
|
||||
// save map creator name
|
||||
map.map_creator_name = owner.username;
|
||||
await map.save();
|
||||
} else {
|
||||
owner = { username: map.map_creator_name };
|
||||
}
|
||||
return sendableMap(map, owner,hearted_maps?hearted_maps.has(map._id.toString()):false);
|
||||
}));
|
||||
|
||||
response[method] = sendableMaps;
|
||||
// if spotlight, randomize the order
|
||||
if(method === "spotlight") {
|
||||
response[method] = shuffle(response[method]);
|
||||
}
|
||||
|
||||
mapCache[method].data = sendableMaps;
|
||||
// dont store hearted maps in cache
|
||||
mapCache[method].data = sendableMaps.map((map) => {
|
||||
return {
|
||||
...map,
|
||||
hearted: false
|
||||
}
|
||||
});
|
||||
mapCache[method].timeStamp = Date.now();
|
||||
timings[method] = Date.now() - startMethod;
|
||||
timings[method + '_cached'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
timings.total = Date.now() - startTotal;
|
||||
|
||||
// Measure JSON serialization time
|
||||
const serializeStart = Date.now();
|
||||
const jsonResponse = JSON.stringify(response);
|
||||
timings.serialize = Date.now() - serializeStart;
|
||||
timings.responseSize = jsonResponse.length;
|
||||
|
||||
console.log('[mapHome] Timings (ms):', JSON.stringify(timings));
|
||||
|
||||
// Track when response actually finishes sending
|
||||
const sendStart = Date.now();
|
||||
res.on('finish', () => {
|
||||
const sendTime = Date.now() - sendStart;
|
||||
if (sendTime > 100) {
|
||||
console.log(`[mapHome] SLOW SEND: ${sendTime}ms for ${jsonResponse.length} bytes`);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).type('application/json').send(jsonResponse);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
}
|
||||
75
api/map/publicData.js
Normal file
75
api/map/publicData.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { getServerSecret } from "../../components/auth/serverAuth.js";
|
||||
import officialCountryMaps from "../../public/officialCountryMaps.json" with { type: "json" };
|
||||
import Map from "../../models/Map.js";
|
||||
import User from "../../models/User.js";
|
||||
import msToTime from "../../components/msToTime.js";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const slug = req.query.slug;
|
||||
const secret = await getServerSecret(req);
|
||||
const session = {};
|
||||
if(secret) {
|
||||
await User.findOne({ secret }).select("secret staff").then((user) => {
|
||||
session.token = { secret, staff: user.staff };
|
||||
});
|
||||
}
|
||||
|
||||
// Check if map is an official country map
|
||||
const cntryMap = Object.values(officialCountryMaps).find(map => map.slug === slug);
|
||||
if (cntryMap) {
|
||||
return res.json({
|
||||
mapData: {
|
||||
...cntryMap,
|
||||
description_short: cntryMap.shortDescription,
|
||||
description_long: cntryMap.longDescription,
|
||||
created_by: "WorldGuessr",
|
||||
in_review: false,
|
||||
rejected: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If map is not official, check user-created maps
|
||||
const map = await Map.findOne({ slug })
|
||||
.select({ 'data': { $slice: 5 } }) // Slice the data to limit to 5 items - REDUCED FROM 5000 TO REDUCE SERVER OVERHEAD
|
||||
.lean().cache(10000);
|
||||
|
||||
if (!map) {
|
||||
return res.status(404).json({ message: 'Map not found' });
|
||||
}
|
||||
|
||||
|
||||
// if map.created_at is string, convert to Date
|
||||
if (typeof map.created_at === 'string') {
|
||||
map.created_at = new Date(map.created_at);
|
||||
}
|
||||
// Get total location count efficiently without loading the array
|
||||
const countResult = await Map.aggregate([
|
||||
{ $match: { slug } },
|
||||
{ $project: { locationcnt: { $size: { $ifNull: ["$data", []] } } } }
|
||||
]);
|
||||
const locationcnt = countResult[0]?.locationcnt || 0;
|
||||
|
||||
const authorId = map.created_by;
|
||||
const authorUser = await User.findById(authorId).lean();
|
||||
const authorSecret = authorUser?.secret;
|
||||
const staff = session?.token?.staff;
|
||||
|
||||
const isCreatorOrStaff = session && (authorSecret === session?.token?.secret || staff);
|
||||
|
||||
if (!map.accepted && !isCreatorOrStaff) {
|
||||
return res.status(404).json({ message: 'Map not accepted or no permission to view' });
|
||||
}
|
||||
|
||||
// Don't mutate the cached object - create a new response object
|
||||
const responseData = {
|
||||
...map,
|
||||
created_by: authorUser?.username,
|
||||
created_at: msToTime(Date.now() - map.created_at),
|
||||
locationcnt: locationcnt
|
||||
};
|
||||
|
||||
return res.json({
|
||||
mapData: responseData
|
||||
});
|
||||
}
|
||||
109
api/map/searchMap.js
Normal file
109
api/map/searchMap.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
|
||||
import sendableMap from "../../components/utils/sendableMap.js";
|
||||
import Map from "../../models/Map.js";
|
||||
import User from "../../models/User.js";
|
||||
|
||||
export default async function searchMaps(req, res) {
|
||||
// only allow POST
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
let { query, secret } = req.body;
|
||||
console.log("searchMaps", query, secret);
|
||||
|
||||
// return res.status(429).json({ message: 'Temporarily not available' });
|
||||
|
||||
// secret must be string
|
||||
if (secret && typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
let user;
|
||||
|
||||
if(secret) {
|
||||
user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
}
|
||||
|
||||
let hearted_maps = user ? user.hearted_maps : null;
|
||||
|
||||
// Validate the search query
|
||||
if (!query || query.length < 3) {
|
||||
return res.status(400).json({ message: 'Search query must be at least 3 characters long' });
|
||||
}
|
||||
|
||||
// sanitize query
|
||||
query = query.replace(/[^a-zA-Z0-9\s]/g, '');
|
||||
|
||||
try {
|
||||
// Find maps that match the search query in either name, short description, or author name
|
||||
// let maps = await Map.find({
|
||||
// accepted: true,
|
||||
// $or: [
|
||||
// { name: { $regex: query, $options: 'i' } },
|
||||
// { description_short: { $regex: query, $options: 'i' } },
|
||||
// { created_by_name: { $regex: query, $options: 'i' } }
|
||||
// ]
|
||||
// }).sort({ hearts: -1 }).limit(50).cache(10000);
|
||||
|
||||
let maps = await Map.find({
|
||||
accepted: true,
|
||||
$text: { $search: query }
|
||||
}).sort({ hearts: -1 }).limit(50).cache(10000);
|
||||
|
||||
// Re-rank results to prioritize exact and substring matches
|
||||
const queryLower = query.toLowerCase();
|
||||
maps.sort((a, b) => {
|
||||
const aNameLower = a.name.toLowerCase();
|
||||
const bNameLower = b.name.toLowerCase();
|
||||
|
||||
// 1. Exact match (highest priority)
|
||||
const aExact = aNameLower === queryLower;
|
||||
const bExact = bNameLower === queryLower;
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
// 2. Starts with query
|
||||
const aStartsWith = aNameLower.startsWith(queryLower);
|
||||
const bStartsWith = bNameLower.startsWith(queryLower);
|
||||
if (aStartsWith && !bStartsWith) return -1;
|
||||
if (!aStartsWith && bStartsWith) return 1;
|
||||
|
||||
// 3. Contains query as substring
|
||||
const aContains = aNameLower.includes(queryLower);
|
||||
const bContains = bNameLower.includes(queryLower);
|
||||
if (aContains && !bContains) return -1;
|
||||
if (!aContains && bContains) return 1;
|
||||
|
||||
// 4. For equal relevance, sort by hearts (popularity)
|
||||
return b.hearts - a.hearts;
|
||||
});
|
||||
|
||||
// Convert maps to sendable format
|
||||
let sendableMaps = await Promise.all(maps.map(async (map) => {
|
||||
|
||||
let owner;
|
||||
if(!map.map_creator_name) {
|
||||
owner = await User.findById(map.created_by);
|
||||
// if owner is not found, set to null
|
||||
if(!owner) {
|
||||
owner = null;
|
||||
}
|
||||
// save map creator name
|
||||
map.map_creator_name = owner.username;
|
||||
await map.save();
|
||||
} else{
|
||||
owner = { username: map.map_creator_name };
|
||||
}
|
||||
|
||||
return sendableMap(map, owner, hearted_maps?hearted_maps.has(map._id.toString()):false, user?.staff, map.created_by === user?._id.toString());
|
||||
}));
|
||||
|
||||
res.status(200).json(sendableMaps);
|
||||
} catch (error) {
|
||||
console.error('Error searching maps:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
147
api/mod/auditLogs.js
Normal file
147
api/mod/auditLogs.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import User from '../../models/User.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
|
||||
/**
|
||||
* Audit Logs API
|
||||
*
|
||||
* Fetches moderation action logs with optional filtering by moderator
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const {
|
||||
secret,
|
||||
moderatorId, // Optional: filter by specific moderator
|
||||
actionType, // Optional: filter by action type
|
||||
page = 1,
|
||||
limit = 50
|
||||
} = req.body;
|
||||
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized - staff access required' });
|
||||
}
|
||||
|
||||
// Build query - exclude voluntary name changes (name_change_manual) by default
|
||||
const query = {
|
||||
actionType: { $ne: 'name_change_manual' } // Exclude user-initiated name changes
|
||||
};
|
||||
|
||||
if (moderatorId && moderatorId !== 'all') {
|
||||
query['moderator.accountId'] = moderatorId;
|
||||
}
|
||||
|
||||
// If filtering by specific action type, override the exclusion
|
||||
if (actionType && actionType !== 'all') {
|
||||
query.actionType = actionType;
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await ModerationLog.countDocuments(query);
|
||||
|
||||
// Fetch logs with pagination
|
||||
const logs = await ModerationLog.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
// Get list of all moderators who have taken actions (for filter dropdown)
|
||||
// First get unique moderator IDs from logs
|
||||
const allModeratorsFromLogs = await ModerationLog.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$moderator.accountId',
|
||||
username: { $first: '$moderator.username' },
|
||||
actionCount: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { actionCount: -1 } }
|
||||
]);
|
||||
|
||||
// Filter to only include actual staff members (not users who just changed their own name)
|
||||
const staffUserIds = await User.find(
|
||||
{ _id: { $in: allModeratorsFromLogs.map(m => m._id) }, staff: true },
|
||||
{ _id: 1 }
|
||||
).lean();
|
||||
const staffIdSet = new Set(staffUserIds.map(u => u._id.toString()));
|
||||
|
||||
const moderatorsList = allModeratorsFromLogs.filter(m => staffIdSet.has(m._id));
|
||||
|
||||
// Get action type counts for stats (exclude voluntary name changes)
|
||||
const actionTypeCounts = await ModerationLog.aggregate([
|
||||
{
|
||||
$match: { actionType: { $ne: 'name_change_manual' } }
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$actionType',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
// Format logs for response
|
||||
const formattedLogs = logs.map(log => ({
|
||||
_id: log._id,
|
||||
targetUser: {
|
||||
accountId: log.targetUser.accountId,
|
||||
username: log.targetUser.username
|
||||
},
|
||||
moderator: {
|
||||
accountId: log.moderator.accountId,
|
||||
username: log.moderator.username
|
||||
},
|
||||
actionType: log.actionType,
|
||||
reason: log.reason,
|
||||
notes: log.notes,
|
||||
duration: log.duration,
|
||||
durationString: log.durationString,
|
||||
expiresAt: log.expiresAt,
|
||||
nameChange: log.nameChange,
|
||||
eloRefund: log.eloRefund,
|
||||
relatedReports: log.relatedReports?.length || 0,
|
||||
createdAt: log.createdAt
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
logs: formattedLogs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit)
|
||||
},
|
||||
moderators: moderatorsList.map(m => ({
|
||||
accountId: m._id,
|
||||
username: m.username,
|
||||
actionCount: m.actionCount
|
||||
})),
|
||||
actionTypeCounts: actionTypeCounts.reduce((acc, item) => {
|
||||
acc[item._id] = item.count;
|
||||
return acc;
|
||||
}, {}),
|
||||
stats: {
|
||||
totalActions: totalCount,
|
||||
uniqueModerators: moderatorsList.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Audit logs error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching audit logs',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
243
api/mod/deleteUser.js
Normal file
243
api/mod/deleteUser.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import User from '../../models/User.js';
|
||||
import UserStats from '../../models/UserStats.js';
|
||||
import Map from '../../models/Map.js';
|
||||
import Game from '../../models/Game.js';
|
||||
import Report from '../../models/Report.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
import NameChangeRequest from '../../models/NameChangeRequest.js';
|
||||
|
||||
/**
|
||||
* Delete User API - Staff Only
|
||||
*
|
||||
* Permanently deletes a user and all associated data.
|
||||
* This action is IRREVERSIBLE.
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const {
|
||||
secret,
|
||||
targetUserId,
|
||||
confirmUsername, // Must match the target user's username for safety
|
||||
reason
|
||||
} = req.body;
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
|
||||
if (!targetUserId) {
|
||||
return res.status(400).json({ message: 'Target user ID is required' });
|
||||
}
|
||||
|
||||
if (!confirmUsername || typeof confirmUsername !== 'string') {
|
||||
return res.status(400).json({ message: 'Username confirmation is required' });
|
||||
}
|
||||
|
||||
if (!reason || reason.trim().length < 10) {
|
||||
return res.status(400).json({ message: 'Reason is required (minimum 10 characters)' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const moderator = await User.findOne({ secret });
|
||||
if (!moderator || !moderator.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized - staff access required' });
|
||||
}
|
||||
if (moderator.username !== 'codergautam') {
|
||||
return res.status(403).json({ message: 'Unauthorized - admin access required' });
|
||||
}
|
||||
// Find target user
|
||||
const targetUser = await User.findById(targetUserId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: 'Target user not found' });
|
||||
}
|
||||
|
||||
// Don't allow deleting staff accounts
|
||||
if (targetUser.staff) {
|
||||
return res.status(403).json({ message: 'Cannot delete staff accounts' });
|
||||
}
|
||||
|
||||
// Verify username confirmation matches
|
||||
if (confirmUsername.toLowerCase() !== targetUser.username.toLowerCase()) {
|
||||
return res.status(400).json({
|
||||
message: `Username confirmation does not match. Expected "${targetUser.username}"`
|
||||
});
|
||||
}
|
||||
|
||||
// Store user info for logging before deletion
|
||||
const deletedUserInfo = {
|
||||
accountId: targetUser._id.toString(),
|
||||
username: targetUser.username,
|
||||
totalXp: targetUser.totalXp,
|
||||
elo: targetUser.elo,
|
||||
created_at: targetUser.created_at
|
||||
};
|
||||
|
||||
// Count related data for response
|
||||
const counts = {
|
||||
userStats: await UserStats.countDocuments({ userId: targetUser._id }),
|
||||
maps: await Map.countDocuments({ created_by: targetUser._id }),
|
||||
games: await Game.countDocuments({ 'players.accountId': targetUser._id }),
|
||||
friendsOf: await User.countDocuments({ friends: targetUser._id }),
|
||||
sentRequests: await User.countDocuments({ receivedReq: targetUser._id }),
|
||||
receivedRequests: await User.countDocuments({ sentReq: targetUser._id }),
|
||||
reportsMade: await Report.countDocuments({ 'reportedBy.accountId': targetUser._id.toString() }),
|
||||
reportsAgainst: await Report.countDocuments({ 'reportedUser.accountId': targetUser._id.toString() })
|
||||
};
|
||||
|
||||
// Start deletion process
|
||||
const deletionStats = {
|
||||
userStatsDeleted: 0,
|
||||
mapsDeleted: 0,
|
||||
gamesAnonymized: 0,
|
||||
friendListsCleaned: 0,
|
||||
sentRequestsCleaned: 0,
|
||||
receivedRequestsCleaned: 0,
|
||||
reportsMadeAnonymized: 0,
|
||||
reportsAgainstAnonymized: 0,
|
||||
userAccountDeleted: 0
|
||||
};
|
||||
|
||||
// 1. Delete UserStats
|
||||
if (counts.userStats > 0) {
|
||||
const result = await UserStats.deleteMany({ userId: targetUser._id });
|
||||
deletionStats.userStatsDeleted = result.deletedCount;
|
||||
}
|
||||
|
||||
// 2. Delete Maps created by user
|
||||
if (counts.maps > 0) {
|
||||
const result = await Map.deleteMany({ created_by: targetUser._id });
|
||||
deletionStats.mapsDeleted = result.deletedCount;
|
||||
}
|
||||
|
||||
// 3. Anonymize user data in Games
|
||||
if (counts.games > 0) {
|
||||
// Anonymize player summary data
|
||||
await Game.updateMany(
|
||||
{ 'players.accountId': targetUser._id },
|
||||
{
|
||||
$set: {
|
||||
'players.$[elem].username': '[Deleted User]',
|
||||
'players.$[elem].accountId': null
|
||||
}
|
||||
},
|
||||
{ arrayFilters: [{ 'elem.accountId': targetUser._id }] }
|
||||
);
|
||||
|
||||
// Anonymize round guess data
|
||||
const roundResult = await Game.updateMany(
|
||||
{ 'rounds.playerGuesses.accountId': targetUser._id },
|
||||
{
|
||||
$set: {
|
||||
'rounds.$[].playerGuesses.$[guess].username': '[Deleted User]',
|
||||
'rounds.$[].playerGuesses.$[guess].accountId': null
|
||||
}
|
||||
},
|
||||
{ arrayFilters: [{ 'guess.accountId': targetUser._id }] }
|
||||
);
|
||||
deletionStats.gamesAnonymized = roundResult.modifiedCount;
|
||||
}
|
||||
|
||||
// 4. Remove user from friend lists
|
||||
if (counts.friendsOf > 0) {
|
||||
const result = await User.updateMany(
|
||||
{ friends: targetUser._id },
|
||||
{ $pull: { friends: targetUser._id } }
|
||||
);
|
||||
deletionStats.friendListsCleaned = result.modifiedCount;
|
||||
}
|
||||
|
||||
// 5. Remove sent friend requests
|
||||
if (counts.sentRequests > 0) {
|
||||
const result = await User.updateMany(
|
||||
{ receivedReq: targetUser._id },
|
||||
{ $pull: { receivedReq: targetUser._id } }
|
||||
);
|
||||
deletionStats.sentRequestsCleaned = result.modifiedCount;
|
||||
}
|
||||
|
||||
// 6. Remove received friend requests
|
||||
if (counts.receivedRequests > 0) {
|
||||
const result = await User.updateMany(
|
||||
{ sentReq: targetUser._id },
|
||||
{ $pull: { sentReq: targetUser._id } }
|
||||
);
|
||||
deletionStats.receivedRequestsCleaned = result.modifiedCount;
|
||||
}
|
||||
|
||||
// 7. Anonymize reports made by user
|
||||
if (counts.reportsMade > 0) {
|
||||
const result = await Report.updateMany(
|
||||
{ 'reportedBy.accountId': targetUser._id.toString() },
|
||||
{
|
||||
$set: {
|
||||
'reportedBy.username': '[Deleted User]',
|
||||
'reportedBy.accountId': null
|
||||
}
|
||||
}
|
||||
);
|
||||
deletionStats.reportsMadeAnonymized = result.modifiedCount;
|
||||
}
|
||||
|
||||
// 8. Anonymize reports against user
|
||||
if (counts.reportsAgainst > 0) {
|
||||
const result = await Report.updateMany(
|
||||
{ 'reportedUser.accountId': targetUser._id.toString() },
|
||||
{
|
||||
$set: {
|
||||
'reportedUser.username': '[Deleted User]',
|
||||
'reportedUser.accountId': null
|
||||
}
|
||||
}
|
||||
);
|
||||
deletionStats.reportsAgainstAnonymized = result.modifiedCount;
|
||||
}
|
||||
|
||||
// 9. Create moderation log BEFORE deleting user
|
||||
await ModerationLog.create({
|
||||
targetUser: {
|
||||
accountId: deletedUserInfo.accountId,
|
||||
username: deletedUserInfo.username
|
||||
},
|
||||
moderator: {
|
||||
accountId: moderator._id.toString(),
|
||||
username: moderator.username
|
||||
},
|
||||
actionType: 'user_deleted',
|
||||
reason: reason,
|
||||
notes: JSON.stringify({
|
||||
deletedUserInfo,
|
||||
deletionStats,
|
||||
deletedAt: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
// 10. Delete NameChangeRequests
|
||||
await NameChangeRequest.deleteMany({ 'user.accountId': targetUser._id.toString() });
|
||||
|
||||
// 11. Finally, delete the user account
|
||||
const userResult = await User.deleteOne({ _id: targetUser._id });
|
||||
deletionStats.userAccountDeleted = userResult.deletedCount;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: `User "${deletedUserInfo.username}" has been permanently deleted`,
|
||||
deletedUser: deletedUserInfo,
|
||||
deletionStats
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while deleting user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
138
api/mod/gameDetails.js
Normal file
138
api/mod/gameDetails.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import Game from '../../models/Game.js';
|
||||
import User from '../../models/User.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, gameId, targetUserId } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
if (!gameId || typeof gameId !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid gameId' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized - staff access required' });
|
||||
}
|
||||
|
||||
// Fetch the specific game (no player check for staff)
|
||||
const game = await Game.findOne({ gameId: gameId }).lean();
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ message: 'Game not found' });
|
||||
}
|
||||
|
||||
// Determine which user's perspective to use (for displaying the game)
|
||||
// Use targetUserId if provided AND it matches a player in the game
|
||||
// Otherwise fall back to first player with an accountId
|
||||
let perspectiveUserId = targetUserId;
|
||||
|
||||
// Validate that targetUserId is actually a player in this game
|
||||
const targetPlayerMatch = perspectiveUserId ?
|
||||
game.players.find(p => p.accountId === perspectiveUserId || p.playerId === perspectiveUserId) : null;
|
||||
|
||||
if (!targetPlayerMatch) {
|
||||
// targetUserId not in game (e.g., moderator viewing) - use first player as perspective
|
||||
const firstAccountPlayer = game.players.find(p => p.accountId);
|
||||
perspectiveUserId = firstAccountPlayer?.accountId || game.players[0]?.playerId;
|
||||
}
|
||||
|
||||
// Format the game data for HistoricalGameView
|
||||
const formattedGame = {
|
||||
gameId: game.gameId,
|
||||
gameType: game.gameType,
|
||||
startedAt: game.startedAt,
|
||||
endedAt: game.endedAt,
|
||||
totalDuration: game.totalDuration,
|
||||
|
||||
// Game settings
|
||||
settings: game.settings,
|
||||
|
||||
// All rounds with locations and guesses
|
||||
rounds: game.rounds.map((round, index) => {
|
||||
// Find the perspective user's guess for this round
|
||||
const userGuess = round.playerGuesses.find(guess =>
|
||||
guess.accountId === perspectiveUserId || guess.playerId === perspectiveUserId
|
||||
);
|
||||
|
||||
return {
|
||||
roundNumber: round.roundNumber,
|
||||
location: round.location,
|
||||
|
||||
// User's guess data (from perspective user)
|
||||
guess: userGuess ? {
|
||||
guessLat: userGuess.guessLat,
|
||||
guessLong: userGuess.guessLong,
|
||||
points: userGuess.points,
|
||||
timeTaken: userGuess.timeTaken,
|
||||
xpEarned: userGuess.xpEarned,
|
||||
usedHint: userGuess.usedHint,
|
||||
guessedAt: userGuess.guessedAt
|
||||
} : null,
|
||||
|
||||
// All player guesses (for multiplayer games)
|
||||
allGuesses: round.playerGuesses.map(guess => ({
|
||||
playerId: guess.accountId || guess.playerId,
|
||||
username: guess.username,
|
||||
guessLat: guess.guessLat,
|
||||
guessLong: guess.guessLong,
|
||||
points: guess.points,
|
||||
timeTaken: guess.timeTaken,
|
||||
xpEarned: guess.xpEarned,
|
||||
usedHint: guess.usedHint
|
||||
})),
|
||||
|
||||
startedAt: round.startedAt,
|
||||
endedAt: round.endedAt,
|
||||
roundTimeLimit: round.roundTimeLimit
|
||||
};
|
||||
}),
|
||||
|
||||
// All players
|
||||
players: game.players.map(player => ({
|
||||
playerId: player.accountId || player.playerId,
|
||||
username: player.username,
|
||||
accountId: player.accountId,
|
||||
totalPoints: player.totalPoints,
|
||||
totalXp: player.totalXp,
|
||||
averageTimePerRound: player.averageTimePerRound,
|
||||
finalRank: player.finalRank,
|
||||
elo: player.elo
|
||||
})),
|
||||
|
||||
// Game result
|
||||
result: game.result,
|
||||
|
||||
// Multiplayer info
|
||||
multiplayer: game.multiplayer,
|
||||
|
||||
// Find the perspective user's player data
|
||||
userPlayer: game.players.find(player =>
|
||||
player.accountId === perspectiveUserId || player.playerId === perspectiveUserId
|
||||
),
|
||||
|
||||
// Add perspective user's ID for frontend comparisons
|
||||
currentUserId: perspectiveUserId
|
||||
};
|
||||
|
||||
return res.status(200).json({ game: formattedGame });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mod game details error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching game details',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
295
api/mod/getReports.js
Normal file
295
api/mod/getReports.js
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import User from '../../models/User.js';
|
||||
import Report from '../../models/Report.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, status, reason, limit = 50, skip = 0, showAll = false } = req.body;
|
||||
|
||||
// Validate secret
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Build query for status filter
|
||||
const matchQuery = {};
|
||||
if (status && ['pending', 'reviewed', 'dismissed', 'action_taken'].includes(status)) {
|
||||
matchQuery.status = status;
|
||||
}
|
||||
|
||||
// Add reason filter if provided
|
||||
if (reason && ['inappropriate_username', 'cheating', 'other'].includes(reason)) {
|
||||
matchQuery.reason = reason;
|
||||
}
|
||||
|
||||
// If showAll is true, we want all reports regardless of status
|
||||
const wantAllReports = showAll || (status !== 'pending' && status !== undefined);
|
||||
|
||||
// Get counts by status for dashboard stats
|
||||
const statusCounts = await Report.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$status',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
reviewed: 0,
|
||||
dismissed: 0,
|
||||
action_taken: 0
|
||||
};
|
||||
|
||||
statusCounts.forEach(item => {
|
||||
if (item._id in stats) {
|
||||
stats[item._id] = item.count;
|
||||
}
|
||||
stats.total += item.count;
|
||||
});
|
||||
|
||||
// Get counts by reason for pending reports (for the filter dropdown)
|
||||
const reasonCounts = await Report.aggregate([
|
||||
{ $match: { status: 'pending' } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$reason',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingReasonCounts = {
|
||||
cheating: 0,
|
||||
inappropriate_username: 0,
|
||||
other: 0
|
||||
};
|
||||
|
||||
reasonCounts.forEach(item => {
|
||||
if (item._id in pendingReasonCounts) {
|
||||
pendingReasonCounts[item._id] = item.count;
|
||||
}
|
||||
});
|
||||
|
||||
stats.pendingByReason = pendingReasonCounts;
|
||||
|
||||
// For pending reports, we want to group by reported user and order by:
|
||||
// 1. Number of reports against user (descending)
|
||||
// 2. Oldest report date (ascending) - so oldest reports are seen first
|
||||
|
||||
// If filtering by pending status (and not requesting all), use the special grouping logic
|
||||
if (status === 'pending' && !wantAllReports) {
|
||||
// Build match query for pending reports (including optional reason filter)
|
||||
const pendingMatchQuery = { status: 'pending' };
|
||||
if (reason && ['inappropriate_username', 'cheating', 'other'].includes(reason)) {
|
||||
pendingMatchQuery.reason = reason;
|
||||
}
|
||||
|
||||
// Get pending reports grouped by reported user
|
||||
const groupedReports = await Report.aggregate([
|
||||
{ $match: pendingMatchQuery },
|
||||
{
|
||||
$group: {
|
||||
_id: '$reportedUser.accountId',
|
||||
reportedUser: { $first: '$reportedUser' },
|
||||
reports: { $push: '$$ROOT' },
|
||||
reportCount: { $sum: 1 },
|
||||
oldestReportDate: { $min: '$createdAt' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
reportCount: -1, // Users with more reports first
|
||||
oldestReportDate: 1 // Then by oldest report (so old reports aren't forgotten)
|
||||
}
|
||||
},
|
||||
{ $skip: skip },
|
||||
{ $limit: Math.min(limit, 100) }
|
||||
]);
|
||||
|
||||
// Get all unique user IDs (reporters and reported users) to fetch their status
|
||||
const reporterIds = new Set();
|
||||
const reportedUserIds = new Set();
|
||||
groupedReports.forEach(group => {
|
||||
reportedUserIds.add(group.reportedUser.accountId);
|
||||
group.reports.forEach(report => {
|
||||
reporterIds.add(report.reportedBy.accountId);
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch reporter stats and status
|
||||
const reporters = await User.find(
|
||||
{ _id: { $in: Array.from(reporterIds) } },
|
||||
{ _id: 1, username: 1, reporterStats: 1, banned: 1, banType: 1, banExpiresAt: 1, pendingNameChange: 1 }
|
||||
).lean();
|
||||
|
||||
// Check ban history for all reporters
|
||||
const reporterBanHistory = await ModerationLog.find(
|
||||
{
|
||||
'targetUser.accountId': { $in: Array.from(reporterIds) },
|
||||
actionType: { $in: ['ban_permanent', 'ban_temporary'] }
|
||||
},
|
||||
{ 'targetUser.accountId': 1 }
|
||||
).lean();
|
||||
|
||||
const reportersWithBanHistory = new Set(
|
||||
reporterBanHistory.map(log => log.targetUser.accountId)
|
||||
);
|
||||
|
||||
const reporterDataMap = {};
|
||||
reporters.forEach(reporter => {
|
||||
reporterDataMap[reporter._id.toString()] = {
|
||||
helpfulReports: reporter.reporterStats?.helpfulReports || 0,
|
||||
unhelpfulReports: reporter.reporterStats?.unhelpfulReports || 0,
|
||||
banned: reporter.banned,
|
||||
banType: reporter.banType,
|
||||
banExpiresAt: reporter.banExpiresAt,
|
||||
pendingNameChange: reporter.pendingNameChange,
|
||||
hasBanHistory: reportersWithBanHistory.has(reporter._id.toString())
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch reported user status
|
||||
const reportedUsers = await User.find(
|
||||
{ _id: { $in: Array.from(reportedUserIds) } },
|
||||
{ _id: 1, banned: 1, banType: 1, banExpiresAt: 1, pendingNameChange: 1 }
|
||||
).lean();
|
||||
|
||||
const reportedUserDataMap = {};
|
||||
reportedUsers.forEach(user => {
|
||||
reportedUserDataMap[user._id.toString()] = {
|
||||
banned: user.banned,
|
||||
banType: user.banType,
|
||||
banExpiresAt: user.banExpiresAt,
|
||||
pendingNameChange: user.pendingNameChange
|
||||
};
|
||||
});
|
||||
|
||||
// Enrich reports with reporter stats and status info
|
||||
const enrichedGroups = groupedReports.map(group => ({
|
||||
reportedUser: {
|
||||
...group.reportedUser,
|
||||
...reportedUserDataMap[group.reportedUser.accountId]
|
||||
},
|
||||
reportCount: group.reportCount,
|
||||
oldestReportDate: group.oldestReportDate,
|
||||
reports: group.reports.map(report => ({
|
||||
...report,
|
||||
reporterStats: reporterDataMap[report.reportedBy.accountId] || {
|
||||
helpfulReports: 0,
|
||||
unhelpfulReports: 0
|
||||
},
|
||||
reporterStatus: reporterDataMap[report.reportedBy.accountId] || {}
|
||||
}))
|
||||
}));
|
||||
|
||||
// Get total count of users with pending reports (with optional reason filter)
|
||||
const totalUsersWithPendingReports = await Report.aggregate([
|
||||
{ $match: pendingMatchQuery },
|
||||
{ $group: { _id: '$reportedUser.accountId' } },
|
||||
{ $count: 'total' }
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
groupedReports: enrichedGroups,
|
||||
stats,
|
||||
pagination: {
|
||||
total: totalUsersWithPendingReports[0]?.total || 0,
|
||||
limit,
|
||||
skip,
|
||||
hasMore: skip + groupedReports.length < (totalUsersWithPendingReports[0]?.total || 0)
|
||||
},
|
||||
isGrouped: true
|
||||
});
|
||||
}
|
||||
|
||||
// For non-pending status, return flat list (for historical review)
|
||||
const reports = await Report.find(matchQuery)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(Math.min(limit, 100))
|
||||
.skip(skip)
|
||||
.lean();
|
||||
|
||||
// Get all unique user IDs
|
||||
const reporterIds = [...new Set(reports.map(r => r.reportedBy.accountId))];
|
||||
const reportedUserIds = [...new Set(reports.map(r => r.reportedUser.accountId))];
|
||||
const allUserIds = [...new Set([...reporterIds, ...reportedUserIds])];
|
||||
|
||||
// Fetch user data (stats and status)
|
||||
const users = await User.find(
|
||||
{ _id: { $in: allUserIds } },
|
||||
{ _id: 1, reporterStats: 1, banned: 1, banType: 1, banExpiresAt: 1, pendingNameChange: 1 }
|
||||
).lean();
|
||||
|
||||
// Check ban history for all reporters
|
||||
const reporterBanHistory = await ModerationLog.find(
|
||||
{
|
||||
'targetUser.accountId': { $in: reporterIds },
|
||||
actionType: { $in: ['ban_permanent', 'ban_temporary'] }
|
||||
},
|
||||
{ 'targetUser.accountId': 1 }
|
||||
).lean();
|
||||
|
||||
const reportersWithBanHistory = new Set(
|
||||
reporterBanHistory.map(log => log.targetUser.accountId)
|
||||
);
|
||||
|
||||
const userDataMap = {};
|
||||
users.forEach(user => {
|
||||
userDataMap[user._id.toString()] = {
|
||||
helpfulReports: user.reporterStats?.helpfulReports || 0,
|
||||
unhelpfulReports: user.reporterStats?.unhelpfulReports || 0,
|
||||
banned: user.banned,
|
||||
banType: user.banType,
|
||||
banExpiresAt: user.banExpiresAt,
|
||||
pendingNameChange: user.pendingNameChange,
|
||||
hasBanHistory: reportersWithBanHistory.has(user._id.toString())
|
||||
};
|
||||
});
|
||||
|
||||
// Enrich reports with reporter stats and status info
|
||||
const enrichedReports = reports.map(report => ({
|
||||
...report,
|
||||
reporterStats: userDataMap[report.reportedBy.accountId] || {
|
||||
helpfulReports: 0,
|
||||
unhelpfulReports: 0
|
||||
},
|
||||
reporterStatus: userDataMap[report.reportedBy.accountId] || {},
|
||||
reportedUserStatus: userDataMap[report.reportedUser.accountId] || {}
|
||||
}));
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await Report.countDocuments(matchQuery);
|
||||
|
||||
return res.status(200).json({
|
||||
reports: enrichedReports,
|
||||
stats,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
skip,
|
||||
hasMore: skip + reports.length < totalCount
|
||||
},
|
||||
isGrouped: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get reports error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching reports',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
202
api/mod/modActivity.js
Normal file
202
api/mod/modActivity.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import User from '../../models/User.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
import Report from '../../models/Report.js';
|
||||
|
||||
/**
|
||||
* Mod Activity API
|
||||
*
|
||||
* Returns monthly activity breakdown per moderator
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, year, month } = req.body;
|
||||
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized - staff access required' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const targetYear = year || now.getUTCFullYear();
|
||||
const targetMonth = month || (now.getUTCMonth() + 1);
|
||||
|
||||
const startOfMonth = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
|
||||
const endOfMonth = new Date(Date.UTC(targetYear, targetMonth, 1));
|
||||
|
||||
// Aggregate actions by moderator and action type for the month
|
||||
const moderatorActivity = await ModerationLog.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startOfMonth, $lt: endOfMonth },
|
||||
actionType: { $ne: 'name_change_manual' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
moderatorId: '$moderator.accountId',
|
||||
moderatorUsername: '$moderator.username',
|
||||
actionType: '$actionType'
|
||||
},
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$_id.moderatorId',
|
||||
username: { $last: '$_id.moderatorUsername' },
|
||||
actions: {
|
||||
$push: {
|
||||
actionType: '$_id.actionType',
|
||||
count: '$count'
|
||||
}
|
||||
},
|
||||
totalActions: { $sum: '$count' }
|
||||
}
|
||||
},
|
||||
{ $sort: { totalActions: -1 } }
|
||||
]);
|
||||
|
||||
// Filter to only staff members
|
||||
const staffUserIds = await User.find(
|
||||
{ _id: { $in: moderatorActivity.map(m => m._id) }, staff: true },
|
||||
{ _id: 1 }
|
||||
).lean();
|
||||
const staffIdSet = new Set(staffUserIds.map(u => u._id.toString()));
|
||||
const filteredActivity = moderatorActivity.filter(m => staffIdSet.has(m._id));
|
||||
|
||||
// Format moderators with actions as object
|
||||
const totals = {};
|
||||
let grandTotal = 0;
|
||||
|
||||
const moderators = filteredActivity.map(mod => {
|
||||
const actions = {};
|
||||
for (const a of mod.actions) {
|
||||
actions[a.actionType] = a.count;
|
||||
totals[a.actionType] = (totals[a.actionType] || 0) + a.count;
|
||||
}
|
||||
grandTotal += mod.totalActions;
|
||||
return {
|
||||
accountId: mod._id,
|
||||
username: mod.username,
|
||||
actions,
|
||||
totalActions: mod.totalActions
|
||||
};
|
||||
});
|
||||
|
||||
// Daily report flow: incoming vs handled
|
||||
const [dailyIncoming, dailyHandled] = await Promise.all([
|
||||
Report.aggregate([
|
||||
{ $match: { createdAt: { $gte: startOfMonth, $lt: endOfMonth } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dayOfMonth: { date: '$createdAt', timezone: 'UTC' } },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: 1 } }
|
||||
]),
|
||||
Report.aggregate([
|
||||
{ $match: { reviewedAt: { $gte: startOfMonth, $lt: endOfMonth }, status: { $ne: 'pending' } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dayOfMonth: { date: '$reviewedAt', timezone: 'UTC' } },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: 1 } }
|
||||
])
|
||||
]);
|
||||
|
||||
// Per-moderator daily actions
|
||||
const dailyPerMod = await ModerationLog.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startOfMonth, $lt: endOfMonth },
|
||||
actionType: { $ne: 'name_change_manual' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
day: { $dayOfMonth: { date: '$createdAt', timezone: 'UTC' } },
|
||||
moderatorId: '$moderator.accountId'
|
||||
},
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { '_id.day': 1 } }
|
||||
]);
|
||||
|
||||
// Build per-mod daily map: { modId: { day: count } }
|
||||
const perModDailyMap = {};
|
||||
for (const entry of dailyPerMod) {
|
||||
const modId = entry._id.moderatorId;
|
||||
if (!staffIdSet.has(modId)) continue;
|
||||
if (!perModDailyMap[modId]) perModDailyMap[modId] = {};
|
||||
perModDailyMap[modId][entry._id.day] = entry.count;
|
||||
}
|
||||
|
||||
// Build daily data array for the month
|
||||
const daysInMonth = new Date(Date.UTC(targetYear, targetMonth, 0)).getUTCDate();
|
||||
const dailyReports = [];
|
||||
const incomingMap = Object.fromEntries(dailyIncoming.map(d => [d._id, d.count]));
|
||||
const handledMap = Object.fromEntries(dailyHandled.map(d => [d._id, d.count]));
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
dailyReports.push({
|
||||
day,
|
||||
incoming: incomingMap[day] || 0,
|
||||
handled: handledMap[day] || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Build per-mod daily arrays
|
||||
const dailyByModerator = {};
|
||||
for (const modId of Object.keys(perModDailyMap)) {
|
||||
dailyByModerator[modId] = [];
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
dailyByModerator[modId].push(perModDailyMap[modId][day] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Get available months
|
||||
const availableMonths = await ModerationLog.aggregate([
|
||||
{ $match: { actionType: { $ne: 'name_change_manual' } } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: { date: '$createdAt', timezone: 'UTC' } },
|
||||
month: { $month: { date: '$createdAt', timezone: 'UTC' } }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $sort: { '_id.year': -1, '_id.month': -1 } }
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
moderators,
|
||||
totals,
|
||||
grandTotal,
|
||||
dailyReports,
|
||||
dailyByModerator,
|
||||
month: targetMonth,
|
||||
year: targetYear,
|
||||
availableMonths: availableMonths.map(m => ({ year: m._id.year, month: m._id.month }))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mod activity error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching mod activity',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
72
api/mod/nameReviewQueue.js
Normal file
72
api/mod/nameReviewQueue.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import User from '../../models/User.js';
|
||||
import NameChangeRequest from '../../models/NameChangeRequest.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
|
||||
/**
|
||||
* Name Review Queue API
|
||||
*
|
||||
* GET-style (POST for auth): Get pending name change requests
|
||||
* This returns users who have submitted new names for review after being forced to change
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, limit = 50, skip = 0 } = req.body;
|
||||
|
||||
// Validate secret
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Get pending name change requests (oldest first)
|
||||
const pendingRequests = await NameChangeRequest.find({ status: 'pending' })
|
||||
.sort({ createdAt: 1 }) // Oldest first
|
||||
.limit(Math.min(limit, 100))
|
||||
.skip(skip)
|
||||
.lean();
|
||||
|
||||
// Get total count
|
||||
const totalCount = await NameChangeRequest.countDocuments({ status: 'pending' });
|
||||
|
||||
// Get stats
|
||||
const stats = {
|
||||
pending: totalCount,
|
||||
approvedToday: await NameChangeRequest.countDocuments({
|
||||
status: 'approved',
|
||||
reviewedAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }
|
||||
}),
|
||||
rejectedToday: await NameChangeRequest.countDocuments({
|
||||
status: 'rejected',
|
||||
reviewedAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }
|
||||
})
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
requests: pendingRequests,
|
||||
stats,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
skip,
|
||||
hasMore: skip + pendingRequests.length < totalCount
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Name review queue error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching name review queue',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
186
api/mod/reviewNameChange.js
Normal file
186
api/mod/reviewNameChange.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import User, { USERNAME_COLLATION } from '../../models/User.js';
|
||||
import NameChangeRequest from '../../models/NameChangeRequest.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
import Report from '../../models/Report.js';
|
||||
|
||||
/**
|
||||
* Review Name Change API
|
||||
*
|
||||
* Allows moderators to approve or reject pending name change requests
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const {
|
||||
secret,
|
||||
requestId, // NameChangeRequest ID
|
||||
action, // 'approve' or 'reject'
|
||||
rejectionReason // Required if rejecting
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).json({ message: 'Request ID is required' });
|
||||
}
|
||||
|
||||
if (!action || !['approve', 'reject'].includes(action)) {
|
||||
return res.status(400).json({ message: 'Invalid action - must be "approve" or "reject"' });
|
||||
}
|
||||
|
||||
if (action === 'reject' && (!rejectionReason || rejectionReason.trim().length < 3)) {
|
||||
return res.status(400).json({ message: 'Rejection reason is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user is staff
|
||||
const moderator = await User.findOne({ secret });
|
||||
if (!moderator || !moderator.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized - staff access required' });
|
||||
}
|
||||
|
||||
// Get the name change request
|
||||
const nameRequest = await NameChangeRequest.findById(requestId);
|
||||
if (!nameRequest) {
|
||||
return res.status(404).json({ message: 'Name change request not found' });
|
||||
}
|
||||
|
||||
if (nameRequest.status !== 'pending') {
|
||||
return res.status(400).json({ message: 'This request has already been reviewed' });
|
||||
}
|
||||
|
||||
// Get the target user
|
||||
const targetUser = await User.findById(nameRequest.user.accountId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
// Check if the new username is already taken (case-insensitive with collation index)
|
||||
const existingUser = await User.findOne({
|
||||
username: nameRequest.requestedUsername,
|
||||
_id: { $ne: targetUser._id }
|
||||
}).collation(USERNAME_COLLATION);
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
message: 'This username is already taken. Reject this request so the user can submit a different name.'
|
||||
});
|
||||
}
|
||||
|
||||
const oldUsername = targetUser.username;
|
||||
|
||||
// Update the user's username and clear pending status
|
||||
await User.findByIdAndUpdate(targetUser._id, {
|
||||
username: nameRequest.requestedUsername,
|
||||
pendingNameChange: false,
|
||||
pendingNameChangeReason: null,
|
||||
pendingNameChangePublicNote: null,
|
||||
lastNameChange: new Date()
|
||||
});
|
||||
|
||||
// Update pending reports against this user to use the new username
|
||||
await Report.updateMany(
|
||||
{
|
||||
'reportedUser.accountId': targetUser._id.toString(),
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
'reportedUser.username': nameRequest.requestedUsername
|
||||
}
|
||||
);
|
||||
|
||||
// Update the request
|
||||
await NameChangeRequest.findByIdAndUpdate(requestId, {
|
||||
status: 'approved',
|
||||
reviewedBy: {
|
||||
accountId: moderator._id.toString(),
|
||||
username: moderator.username
|
||||
},
|
||||
reviewedAt: new Date()
|
||||
});
|
||||
|
||||
// Create moderation log
|
||||
await ModerationLog.create({
|
||||
targetUser: {
|
||||
accountId: targetUser._id.toString(),
|
||||
username: oldUsername
|
||||
},
|
||||
moderator: {
|
||||
accountId: moderator._id.toString(),
|
||||
username: moderator.username
|
||||
},
|
||||
actionType: 'name_change_approved',
|
||||
reason: `Name change approved: ${oldUsername} → ${nameRequest.requestedUsername}`,
|
||||
nameChange: {
|
||||
oldName: oldUsername,
|
||||
newName: nameRequest.requestedUsername
|
||||
},
|
||||
notes: ''
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
action: 'approved',
|
||||
oldUsername: oldUsername,
|
||||
newUsername: nameRequest.requestedUsername,
|
||||
message: `Username changed from "${oldUsername}" to "${nameRequest.requestedUsername}"`
|
||||
});
|
||||
|
||||
} else {
|
||||
// Reject the name change
|
||||
await NameChangeRequest.findByIdAndUpdate(requestId, {
|
||||
status: 'rejected',
|
||||
reviewedBy: {
|
||||
accountId: moderator._id.toString(),
|
||||
username: moderator.username
|
||||
},
|
||||
reviewedAt: new Date(),
|
||||
rejectionReason: rejectionReason,
|
||||
$inc: { rejectionCount: 1 }
|
||||
});
|
||||
|
||||
// User remains in pending name change state - they must submit a new name
|
||||
// pendingNameChange stays true
|
||||
|
||||
// Create moderation log
|
||||
await ModerationLog.create({
|
||||
targetUser: {
|
||||
accountId: targetUser._id.toString(),
|
||||
username: targetUser.username
|
||||
},
|
||||
moderator: {
|
||||
accountId: moderator._id.toString(),
|
||||
username: moderator.username
|
||||
},
|
||||
actionType: 'name_change_rejected',
|
||||
reason: rejectionReason,
|
||||
nameChange: {
|
||||
oldName: targetUser.username,
|
||||
newName: nameRequest.requestedUsername
|
||||
},
|
||||
notes: `Rejected name: "${nameRequest.requestedUsername}"`
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
action: 'rejected',
|
||||
rejectedUsername: nameRequest.requestedUsername,
|
||||
message: `Name change to "${nameRequest.requestedUsername}" was rejected. User must submit a new name.`
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Review name change error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while reviewing name change',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
1014
api/mod/takeAction.js
Normal file
1014
api/mod/takeAction.js
Normal file
File diff suppressed because it is too large
Load diff
407
api/mod/userLookup.js
Normal file
407
api/mod/userLookup.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import mongoose from 'mongoose';
|
||||
import User, { USERNAME_COLLATION } from '../../models/User.js';
|
||||
import Report from '../../models/Report.js';
|
||||
import ModerationLog from '../../models/ModerationLog.js';
|
||||
import NameChangeRequest from '../../models/NameChangeRequest.js';
|
||||
import UserStats from '../../models/UserStats.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, username, accountId, searchMode = false } = req.body;
|
||||
|
||||
// Validate secret
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify requesting user exists
|
||||
const requestingUser = await User.findOne({ secret });
|
||||
if (!requestingUser || !requestingUser.staff) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
let targetUser;
|
||||
let searchType = null; // Track how we found the user
|
||||
|
||||
// If accountId is provided, use it directly (more reliable)
|
||||
if (accountId) {
|
||||
// Validate ObjectId format first
|
||||
if (mongoose.Types.ObjectId.isValid(accountId)) {
|
||||
targetUser = await User.findById(accountId);
|
||||
}
|
||||
|
||||
// If still not found, could be stored as a string reference
|
||||
if (!targetUser) {
|
||||
targetUser = await User.findOne({ _id: accountId });
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: `User not found by ID: ${accountId}` });
|
||||
}
|
||||
searchType = 'accountId';
|
||||
}
|
||||
// If username is 'self', return the requesting user's data
|
||||
else if (username === 'self') {
|
||||
targetUser = requestingUser;
|
||||
searchType = 'self';
|
||||
}
|
||||
// Search mode - find all matching users by current or past names, email, or account ID
|
||||
else if (searchMode && username) {
|
||||
const searchTerm = username.trim();
|
||||
if (searchTerm.length < 2) {
|
||||
return res.status(400).json({ message: 'Search term must be at least 2 characters' });
|
||||
}
|
||||
|
||||
// Check if search term looks like a MongoDB ObjectId
|
||||
const isObjectId = mongoose.Types.ObjectId.isValid(searchTerm) && searchTerm.length === 24;
|
||||
|
||||
// Check if search term looks like an email
|
||||
const isEmail = searchTerm.includes('@');
|
||||
|
||||
let currentNameMatches = [];
|
||||
|
||||
if (isObjectId) {
|
||||
// Search by account ID
|
||||
const userById = await User.findById(searchTerm)
|
||||
.select('_id username totalXp elo banned banType pendingNameChange staff supporter created_at')
|
||||
.lean();
|
||||
if (userById) {
|
||||
currentNameMatches = [userById];
|
||||
}
|
||||
} else if (isEmail) {
|
||||
// Search by email (case-insensitive)
|
||||
currentNameMatches = await User.find({
|
||||
email: { $regex: new RegExp(searchTerm, 'i') }
|
||||
})
|
||||
.select('_id username totalXp elo banned banType pendingNameChange staff supporter created_at')
|
||||
.limit(10)
|
||||
.lean();
|
||||
} else {
|
||||
// Find users by current username (case-insensitive partial match)
|
||||
currentNameMatches = await User.find({
|
||||
username: { $regex: new RegExp(searchTerm, 'i') }
|
||||
})
|
||||
.select('_id username totalXp elo banned banType pendingNameChange staff supporter created_at')
|
||||
.limit(10)
|
||||
.lean();
|
||||
}
|
||||
|
||||
// Find users by past names in ModerationLog (only for non-ID/email searches)
|
||||
// Only search nameChange.oldName - NOT targetUser.username (that would incorrectly match
|
||||
// users who were targets of moderation actions but never had that username)
|
||||
let pastNameLogs = [];
|
||||
if (!isObjectId && !isEmail) {
|
||||
pastNameLogs = await ModerationLog.find({
|
||||
'nameChange.oldName': { $regex: new RegExp(searchTerm, 'i') },
|
||||
actionType: { $in: ['name_change_approved', 'name_change_forced', 'force_name_change', 'name_change_manual'] }
|
||||
})
|
||||
.select('targetUser.accountId targetUser.username nameChange createdAt')
|
||||
.limit(20)
|
||||
.lean();
|
||||
}
|
||||
|
||||
// Get unique account IDs from past name matches
|
||||
const pastNameAccountIds = [...new Set(pastNameLogs.map(log => log.targetUser.accountId))];
|
||||
|
||||
// Fetch those users (exclude users already in currentNameMatches)
|
||||
const currentMatchIds = currentNameMatches.map(u => u._id.toString());
|
||||
const pastNameUsers = await User.find({
|
||||
_id: { $in: pastNameAccountIds.filter(id => !currentMatchIds.includes(id)) }
|
||||
})
|
||||
.select('_id username totalXp elo banned banType pendingNameChange staff supporter created_at')
|
||||
.limit(10)
|
||||
.lean();
|
||||
|
||||
// Add past name info to users found by past names
|
||||
const pastNameUsersWithInfo = pastNameUsers.map(user => {
|
||||
const relevantLogs = pastNameLogs.filter(log => log.targetUser.accountId === user._id.toString());
|
||||
const pastNames = relevantLogs
|
||||
.filter(log => log.nameChange?.oldName)
|
||||
.map(log => log.nameChange.oldName);
|
||||
const lastChange = relevantLogs[0]?.createdAt;
|
||||
return {
|
||||
...user,
|
||||
matchedByPastName: true,
|
||||
pastNames: [...new Set(pastNames)],
|
||||
lastNameChangeDate: lastChange
|
||||
};
|
||||
});
|
||||
|
||||
// Combine results
|
||||
const allMatches = [
|
||||
...currentNameMatches.map(u => ({ ...u, matchedByPastName: false })),
|
||||
...pastNameUsersWithInfo
|
||||
];
|
||||
|
||||
return res.status(200).json({
|
||||
searchResults: allMatches,
|
||||
totalMatches: allMatches.length
|
||||
});
|
||||
}
|
||||
// Exact username lookup (also supports account ID and email)
|
||||
// IMPORTANT: Also checks for multiple matches (ban evader detection)
|
||||
else if (username) {
|
||||
const searchTerm = username.trim();
|
||||
|
||||
// Check if search term looks like a MongoDB ObjectId (24 hex characters)
|
||||
const isObjectId = mongoose.Types.ObjectId.isValid(searchTerm) && searchTerm.length === 24;
|
||||
|
||||
// Check if search term looks like an email
|
||||
const isEmail = searchTerm.includes('@');
|
||||
|
||||
if (isObjectId) {
|
||||
// Search by account ID - exact match, no multiple results possible
|
||||
targetUser = await User.findById(searchTerm);
|
||||
if (targetUser) {
|
||||
searchType = 'accountId';
|
||||
}
|
||||
} else if (isEmail) {
|
||||
// Search by email (exact match, case-insensitive)
|
||||
targetUser = await User.findOne({ email: { $regex: new RegExp(`^${searchTerm}$`, 'i') } });
|
||||
if (targetUser) {
|
||||
searchType = 'email';
|
||||
}
|
||||
} else {
|
||||
// Username search - check for MULTIPLE matches to catch ban evaders!
|
||||
// Escape regex special characters to prevent injection
|
||||
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// 1. Find current user with this exact username (case-insensitive with collation index)
|
||||
const currentUserWithName = await User.findOne({
|
||||
username: searchTerm
|
||||
}).collation(USERNAME_COLLATION);
|
||||
|
||||
// 2. Find users who previously had this EXACT username (from name changes - including voluntary)
|
||||
const pastNameLogs = await ModerationLog.find({
|
||||
'nameChange.oldName': { $regex: new RegExp(`^${escapedSearchTerm}$`, 'i') },
|
||||
actionType: { $in: ['name_change_approved', 'name_change_forced', 'force_name_change', 'name_change_manual'] }
|
||||
}).sort({ createdAt: -1 }).limit(20).lean();
|
||||
|
||||
// Get unique account IDs from past name logs (filter out nulls)
|
||||
const pastUserIds = [...new Set(
|
||||
pastNameLogs
|
||||
.filter(log => log.targetUser?.accountId)
|
||||
.map(log => log.targetUser.accountId)
|
||||
)];
|
||||
|
||||
// Fetch those users (excluding current user if they're in the list)
|
||||
let pastUsers = [];
|
||||
if (pastUserIds.length > 0) {
|
||||
const excludeId = currentUserWithName?._id?.toString();
|
||||
// Filter out the current user's ID before querying (can't have two _id conditions)
|
||||
const idsToFetch = excludeId
|
||||
? pastUserIds.filter(id => id !== excludeId)
|
||||
: pastUserIds;
|
||||
|
||||
if (idsToFetch.length > 0) {
|
||||
pastUsers = await User.find({
|
||||
_id: { $in: idsToFetch }
|
||||
}).limit(10).lean();
|
||||
}
|
||||
}
|
||||
|
||||
// Build list of all matches
|
||||
const allMatches = [];
|
||||
|
||||
if (currentUserWithName) {
|
||||
allMatches.push({
|
||||
user: currentUserWithName,
|
||||
matchType: 'current_username',
|
||||
matchInfo: `Currently using "${searchTerm}"`
|
||||
});
|
||||
}
|
||||
|
||||
for (const pastUser of pastUsers) {
|
||||
const relevantLogs = pastNameLogs.filter(log => log.targetUser.accountId === pastUser._id.toString());
|
||||
const changeDate = relevantLogs[0]?.createdAt;
|
||||
allMatches.push({
|
||||
user: pastUser,
|
||||
matchType: 'past_username',
|
||||
matchInfo: `Previously used "${searchTerm}" (changed ${changeDate ? new Date(changeDate).toLocaleDateString() : 'unknown'})`
|
||||
});
|
||||
}
|
||||
|
||||
// If multiple matches found, return them all for review
|
||||
if (allMatches.length > 1) {
|
||||
// Build detailed info for each match
|
||||
const multipleMatches = await Promise.all(allMatches.map(async (match) => {
|
||||
return {
|
||||
_id: match.user._id,
|
||||
username: match.user.username,
|
||||
totalXp: match.user.totalXp,
|
||||
elo: match.user.elo,
|
||||
banned: match.user.banned,
|
||||
banType: match.user.banType,
|
||||
pendingNameChange: match.user.pendingNameChange,
|
||||
staff: match.user.staff,
|
||||
supporter: match.user.supporter,
|
||||
created_at: match.user.created_at,
|
||||
matchType: match.matchType,
|
||||
matchInfo: match.matchInfo
|
||||
};
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
multipleMatches: true,
|
||||
searchTerm: searchTerm,
|
||||
matchCount: multipleMatches.length,
|
||||
matches: multipleMatches,
|
||||
warning: '⚠️ Multiple accounts associated with this username - possible ban evasion!'
|
||||
});
|
||||
}
|
||||
|
||||
// Single match or no match
|
||||
if (allMatches.length === 1) {
|
||||
targetUser = allMatches[0].user;
|
||||
searchType = allMatches[0].matchType === 'current_username' ? 'username' : 'past_username';
|
||||
if (searchType === 'past_username') {
|
||||
const response = await buildUserResponse(targetUser);
|
||||
response.foundByPastName = true;
|
||||
response.searchedName = searchTerm;
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: `User not found. Searched by: ${isObjectId ? 'Account ID' : isEmail ? 'Email' : 'Username'}` });
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ message: 'Username or accountId is required' });
|
||||
}
|
||||
|
||||
const response = await buildUserResponse(targetUser);
|
||||
return res.status(200).json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mod user lookup error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while looking up user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function buildUserResponse(targetUser) {
|
||||
// Base response
|
||||
const response = {
|
||||
targetUser: {
|
||||
username: targetUser.username,
|
||||
secret: targetUser.secret,
|
||||
_id: targetUser._id,
|
||||
totalXp: targetUser.totalXp,
|
||||
totalGamesPlayed: targetUser.totalGamesPlayed,
|
||||
elo: targetUser.elo,
|
||||
created_at: targetUser.created_at,
|
||||
banned: targetUser.banned,
|
||||
banType: targetUser.banType || 'none',
|
||||
banExpiresAt: targetUser.banExpiresAt,
|
||||
pendingNameChange: targetUser.pendingNameChange,
|
||||
pendingNameChangeReason: targetUser.pendingNameChangeReason,
|
||||
staff: targetUser.staff,
|
||||
supporter: targetUser.supporter,
|
||||
reporterStats: targetUser.reporterStats || { helpfulReports: 0, unhelpfulReports: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
// Get moderation history
|
||||
const moderationHistory = await ModerationLog.find({
|
||||
'targetUser.accountId': targetUser._id.toString()
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean();
|
||||
|
||||
// Get reports made BY this user
|
||||
const reportsMade = await Report.find({
|
||||
'reportedBy.accountId': targetUser._id.toString()
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean();
|
||||
|
||||
// Get reports made AGAINST this user
|
||||
const reportsAgainst = await Report.find({
|
||||
'reportedUser.accountId': targetUser._id.toString()
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean();
|
||||
|
||||
// Get name change history
|
||||
const nameChangeHistory = await NameChangeRequest.find({
|
||||
'user.accountId': targetUser._id.toString()
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(20)
|
||||
.lean();
|
||||
|
||||
// Extract username history from moderation logs
|
||||
const usernameHistory = moderationHistory
|
||||
.filter(log => log.nameChange && log.nameChange.oldName && log.nameChange.newName)
|
||||
.map(log => ({
|
||||
oldName: log.nameChange.oldName,
|
||||
newName: log.nameChange.newName,
|
||||
changedAt: log.createdAt,
|
||||
action: log.actionType
|
||||
}));
|
||||
|
||||
// Extract ban history
|
||||
const banHistory = moderationHistory
|
||||
.filter(log => ['ban_permanent', 'ban_temporary', 'unban'].includes(log.actionType))
|
||||
.map(log => ({
|
||||
action: log.actionType,
|
||||
reason: log.reason,
|
||||
duration: log.durationString,
|
||||
expiresAt: log.expiresAt,
|
||||
moderator: log.moderator.username,
|
||||
createdAt: log.createdAt
|
||||
}));
|
||||
|
||||
// Get ELO refunds from UserStats
|
||||
const eloRefunds = await UserStats.find({
|
||||
userId: targetUser._id.toString(),
|
||||
triggerEvent: 'elo_refund'
|
||||
})
|
||||
.sort({ timestamp: -1 })
|
||||
.limit(100)
|
||||
.lean();
|
||||
|
||||
// Format ELO refunds for display
|
||||
const formattedEloRefunds = eloRefunds.map(refund => ({
|
||||
amount: refund.eloRefundDetails?.amount || 0,
|
||||
bannedUsername: refund.eloRefundDetails?.bannedUsername || 'Unknown',
|
||||
bannedUserId: refund.eloRefundDetails?.bannedUserId || null,
|
||||
timestamp: refund.timestamp,
|
||||
newElo: refund.elo,
|
||||
moderationLogId: refund.eloRefundDetails?.moderationLogId || null
|
||||
}));
|
||||
|
||||
const totalEloRefunded = formattedEloRefunds.reduce((sum, r) => sum + r.amount, 0);
|
||||
|
||||
response.history = {
|
||||
moderationLogs: moderationHistory,
|
||||
reportsMade: reportsMade,
|
||||
reportsAgainst: reportsAgainst,
|
||||
nameChangeRequests: nameChangeHistory,
|
||||
usernameHistory: usernameHistory,
|
||||
banHistory: banHistory,
|
||||
eloRefunds: formattedEloRefunds,
|
||||
summary: {
|
||||
totalModerationActions: moderationHistory.length,
|
||||
totalReportsMade: reportsMade.length,
|
||||
totalReportsAgainst: reportsAgainst.length,
|
||||
totalBans: banHistory.filter(b => b.action !== 'unban').length,
|
||||
totalUnbans: banHistory.filter(b => b.action === 'unban').length,
|
||||
totalNameChanges: usernameHistory.length,
|
||||
totalEloRefunded: totalEloRefunded,
|
||||
totalEloRefunds: formattedEloRefunds.length
|
||||
}
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
47
api/publicAccount.js
Normal file
47
api/publicAccount.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import User from '../models/User.js';
|
||||
|
||||
export const USERNAME_CHANGE_COOLDOWN = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Extract the user ID from the request body
|
||||
const { id } = req.body;
|
||||
|
||||
// Validate user ID
|
||||
if (!id || typeof id !== 'string') {
|
||||
return res.status(400).json({ message: 'Valid user ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by the provided ID only (no secrets in public endpoints)
|
||||
const user = await User.findById(id).cache(20, `publicData_${id}`);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
// convert lastNameChange to number
|
||||
const lastNameChange = user.lastNameChange ? new Date(user.lastNameChange).getTime() : 0;
|
||||
|
||||
|
||||
// Get public data
|
||||
const publicData = {
|
||||
username: user.username,
|
||||
totalXp: user.totalXp,
|
||||
createdAt: user.created_at,
|
||||
gamesLen: user.totalGamesPlayed || 0,
|
||||
lastLogin: user.lastLogin || user.created_at,
|
||||
canChangeUsername: !user.lastNameChange || Date.now() - lastNameChange > USERNAME_CHANGE_COOLDOWN,
|
||||
daysUntilNameChange: lastNameChange ? Math.max(0, Math.ceil((lastNameChange + USERNAME_CHANGE_COOLDOWN - Date.now()) / (24 * 60 * 60 * 1000))) : 0,
|
||||
recentChange: user.lastNameChange ? Date.now() - lastNameChange < 24 * 60 * 60 * 1000 : false,
|
||||
countryCode: user.countryCode || null,
|
||||
};
|
||||
|
||||
// Return the public data
|
||||
return res.status(200).json(publicData);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'An error occurred', error: error.message });
|
||||
}
|
||||
}
|
||||
248
api/publicProfile.js
Normal file
248
api/publicProfile.js
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import mongoose from 'mongoose';
|
||||
import User, { USERNAME_COLLATION } from '../models/User.js';
|
||||
import { getLeague } from '../components/utils/leagues.js';
|
||||
import { rateLimit } from '../utils/rateLimit.js';
|
||||
|
||||
|
||||
// Cache for profile data (userId -> {data, timestamp})
|
||||
const profileCache = new Map();
|
||||
const CACHE_DURATION = 60000; // 60 seconds
|
||||
|
||||
// In-memory store for IP -> profile views to prevent refresh spam
|
||||
// Format: "ip:userId" -> timestamp
|
||||
const profileViewTracking = new Map();
|
||||
const VIEW_COOLDOWN = 5 * 60 * 1000; // 5 minutes - same IP can't count as a view again for 5 minutes
|
||||
|
||||
// Cleanup old cache entries every 2 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of profileCache.entries()) {
|
||||
if (now - value.timestamp > CACHE_DURATION * 2) {
|
||||
profileCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
// Cleanup old profile view tracking entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of profileViewTracking.entries()) {
|
||||
if (now - timestamp > VIEW_COOLDOWN * 2) {
|
||||
profileViewTracking.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Public Profile API Endpoint
|
||||
* Returns public profile data for a given username
|
||||
* Includes rate limiting, caching, and security measures
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
// Only allow GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Apply rate limiting: 10 requests per minute per IP
|
||||
const limiter = rateLimit({ max: 20, windowMs: 60000 });
|
||||
if (!limiter(req, res)) {
|
||||
return; // Rate limit exceeded, response already sent
|
||||
}
|
||||
|
||||
const { username } = req.query;
|
||||
console.log(`[API] publicProfile: ${username}`);
|
||||
|
||||
// Validate username is provided
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ message: 'Username is required' });
|
||||
}
|
||||
|
||||
// Connect to MongoDB if not already connected (needed for view tracking)
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB);
|
||||
} catch (error) {
|
||||
console.error('Database connection failed:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by username (case-insensitive with collation for index usage)
|
||||
const user = await User.findOne({ username: username }).collation(USERNAME_COLLATION);
|
||||
|
||||
// Generic error message to prevent user enumeration
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Exclude banned users (security: don't expose banned users' profiles)
|
||||
// if (user.banned === true) {
|
||||
// return res.status(404).json({ message: 'User not found' });
|
||||
// }
|
||||
|
||||
// Exclude users with pending name changes (security: match eloRank.js pattern)
|
||||
// if (user.pendingNameChange === true) {
|
||||
// return res.status(404).json({ message: 'User not found' });
|
||||
// }
|
||||
|
||||
const userId = user._id.toString();
|
||||
|
||||
// Track profile view before checking cache (to ensure all unique IPs count)
|
||||
const clientIP = getClientIP(req);
|
||||
let viewCounted = false;
|
||||
try {
|
||||
viewCounted = await trackProfileView(userId, clientIP);
|
||||
// If view was counted, invalidate cache to get fresh view count
|
||||
if (viewCounted) {
|
||||
profileCache.delete(userId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error tracking profile view:', err);
|
||||
}
|
||||
|
||||
// Check cache (after potentially invalidating it)
|
||||
const cached = profileCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return res.status(200).json(cached.data);
|
||||
}
|
||||
|
||||
// Calculate ELO rank (exclude banned users and pending name changes)
|
||||
const rank = (await User.countDocuments({
|
||||
elo: { $gt: user.elo },
|
||||
banned: false
|
||||
}).cache(2000)) + 1;
|
||||
|
||||
// Calculate league info
|
||||
const league = getLeague(user.elo);
|
||||
|
||||
// Calculate win rate safely (avoid division by zero)
|
||||
const totalDuels = (user.duels_wins || 0) + (user.duels_losses || 0) + (user.duels_tied || 0);
|
||||
const winRate = totalDuels > 0 ? (user.duels_wins || 0) / totalDuels : 0;
|
||||
|
||||
// Calculate "member since" duration
|
||||
const memberSince = calculateMemberSince(user.created_at);
|
||||
|
||||
// Refresh user data if view was counted to get updated view count
|
||||
let profileViews = user.profileViews || 0;
|
||||
if (viewCounted) {
|
||||
const refreshedUser = await User.findById(userId);
|
||||
if (refreshedUser) {
|
||||
profileViews = refreshedUser.profileViews || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Build public profile response (ONLY public data)
|
||||
const publicProfile = {
|
||||
username: user.username,
|
||||
userId: userId,
|
||||
totalXp: user.totalXp || 0,
|
||||
gamesPlayed: user.totalGamesPlayed || 0,
|
||||
createdAt: user.created_at,
|
||||
memberSince: memberSince,
|
||||
lastLogin: user.lastLogin || user.created_at,
|
||||
profileViews: profileViews,
|
||||
elo: user.elo || 1000,
|
||||
rank: rank,
|
||||
league: {
|
||||
name: league.name,
|
||||
emoji: league.emoji,
|
||||
color: league.color,
|
||||
minElo: league.minElo
|
||||
},
|
||||
duelStats: {
|
||||
wins: user.duels_wins || 0,
|
||||
losses: user.duels_losses || 0,
|
||||
ties: user.duels_tied || 0,
|
||||
winRate: parseFloat(winRate.toFixed(3))
|
||||
},
|
||||
supporter: user.supporter === true,
|
||||
countryCode: user.countryCode || null
|
||||
};
|
||||
|
||||
// Cache the response using userId
|
||||
profileCache.set(userId, {
|
||||
data: publicProfile,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Return public profile data
|
||||
return res.status(200).json(publicProfile);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching public profile:', error);
|
||||
return res.status(500).json({ message: 'An error occurred while fetching profile data' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address from request
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {string} Client IP address
|
||||
*/
|
||||
function getClientIP(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Track profile view if not recently viewed by this IP
|
||||
* @param {string} userId - User ID of the profile being viewed
|
||||
* @param {string} ip - IP address of the viewer
|
||||
* @returns {boolean} True if view was counted, false if it was a duplicate
|
||||
*/
|
||||
async function trackProfileView(userId, ip) {
|
||||
const viewKey = `${ip}:${userId}`;
|
||||
const now = Date.now();
|
||||
|
||||
// Check if this IP recently viewed this profile
|
||||
const lastViewTime = profileViewTracking.get(viewKey);
|
||||
if (lastViewTime && (now - lastViewTime) < VIEW_COOLDOWN) {
|
||||
return false; // Don't count duplicate views
|
||||
}
|
||||
|
||||
// Record this view
|
||||
profileViewTracking.set(viewKey, now);
|
||||
|
||||
// Increment profile view count in database
|
||||
try {
|
||||
await User.updateOne(
|
||||
{ _id: userId },
|
||||
{ $inc: { profileViews: 1 } }
|
||||
);
|
||||
return true; // View was counted
|
||||
} catch (error) {
|
||||
console.error('Error tracking profile view:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate human-readable "member since" duration
|
||||
* @param {Date} createdAt - User creation date
|
||||
* @returns {string} Human-readable duration (e.g., "3 months", "1 year")
|
||||
*/
|
||||
function calculateMemberSince(createdAt) {
|
||||
if (!createdAt) return 'Unknown';
|
||||
|
||||
const now = new Date();
|
||||
const created = new Date(createdAt);
|
||||
const diffMs = now - created;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) return 'Today';
|
||||
if (diffDays === 1) return '1 day';
|
||||
if (diffDays < 30) return `${diffDays} days`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths === 1) return '1 month';
|
||||
if (diffMonths < 12) return `${diffMonths} months`;
|
||||
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
if (diffYears === 1) return '1 year';
|
||||
return `${diffYears} years`;
|
||||
}
|
||||
216
api/setName.js
Normal file
216
api/setName.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// pages/api/setName.js
|
||||
import User, { USERNAME_COLLATION } from "../models/User.js";
|
||||
import { Webhook } from "discord-webhook-node";
|
||||
import { USERNAME_CHANGE_COOLDOWN } from "./publicAccount.js";
|
||||
import Map from "../models/Map.js";
|
||||
import cachegoose from "recachegoose";
|
||||
import { Filter } from "bad-words";
|
||||
import UserStatsService from "../components/utils/userStatsService.js";
|
||||
import ModerationLog from "../models/ModerationLog.js";
|
||||
import Report from "../models/Report.js";
|
||||
import NameChangeRequest from "../models/NameChangeRequest.js";
|
||||
const filter = new Filter();
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
// Extract the token and username from the request body
|
||||
const { token, username } = req.body;
|
||||
if (typeof token !== "string" || typeof username !== "string") {
|
||||
return res.status(400).json({ message: "Invalid input" });
|
||||
}
|
||||
if (!token || !username) {
|
||||
return res.status(400).json({ message: "Missing token or username" });
|
||||
}
|
||||
|
||||
// Ensure username meets criteria
|
||||
if (username.length < 3 || username.length > 30) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Username must be between 3 and 30 characters" });
|
||||
}
|
||||
// Alphanumeric characters and underscores only
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
message: "Username must contain only letters, numbers, and underscores",
|
||||
});
|
||||
}
|
||||
// make sure username is not profane
|
||||
if (filter.isProfane(username)) {
|
||||
return res.status(400).json({ message: "Inappropriate content" });
|
||||
}
|
||||
// Make sure the username is unique (case-insensitive)
|
||||
// Uses collation index for fast O(log n) lookup instead of slow regex scan
|
||||
const existing = await User.findOne({ username })
|
||||
.collation(USERNAME_COLLATION);
|
||||
if (existing) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Username already taken, please select a new one" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by the provided token
|
||||
const user = await User.findOne({ secret: token });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Check if user is banned (permanent or active temp ban)
|
||||
if (user.banned) {
|
||||
const isActiveBan = user.banType === 'permanent' ||
|
||||
(user.banType === 'temporary' && user.banExpiresAt && new Date(user.banExpiresAt) > new Date());
|
||||
if (isActiveBan) {
|
||||
return res.status(403).json({ message: "Banned users cannot change their username" });
|
||||
}
|
||||
}
|
||||
|
||||
// If user has a forced name change, submit for moderator review instead of direct change
|
||||
if (user.pendingNameChange) {
|
||||
// Check if user already has a pending request
|
||||
const existingRequest = await NameChangeRequest.findOne({
|
||||
'user.accountId': user._id.toString(),
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
if (existingRequest) {
|
||||
// Update the existing request with the new name
|
||||
await NameChangeRequest.findByIdAndUpdate(existingRequest._id, {
|
||||
requestedUsername: username,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
pendingReview: true,
|
||||
message: 'Your name change request has been updated. Please wait for moderator review.',
|
||||
requestId: existingRequest._id
|
||||
});
|
||||
}
|
||||
|
||||
// Create new name change request
|
||||
const nameRequest = await NameChangeRequest.create({
|
||||
user: {
|
||||
accountId: user._id.toString(),
|
||||
currentUsername: user.username
|
||||
},
|
||||
requestedUsername: username,
|
||||
reason: user.pendingNameChangeReason || 'Forced name change',
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
pendingReview: true,
|
||||
message: 'Your name change request has been submitted. Please wait for moderator review.',
|
||||
requestId: nameRequest._id
|
||||
});
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
// this means this is a name change, not a first time name set
|
||||
// check if the user has waited long enough since the last name change
|
||||
if (
|
||||
user.lastNameChange &&
|
||||
Date.now() - user.lastNameChange < USERNAME_CHANGE_COOLDOWN
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "You must wait 30 days between name changes" });
|
||||
}
|
||||
user.lastNameChange = Date.now();
|
||||
|
||||
// update users map with new username
|
||||
const userMaps = await Map.find({ created_by: user._id });
|
||||
for (const map of userMaps) {
|
||||
map.map_creator_name = username;
|
||||
await map.save();
|
||||
}
|
||||
|
||||
// recachegoose clear key publicData_${id}
|
||||
cachegoose.clearCache(`publicData_${user._id.toString()}`, (error) => {
|
||||
if (error) {
|
||||
console.error("Error clearing cache", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update the user's username
|
||||
const isFirstTimeSettingUsername = !user.username;
|
||||
const oldUsername = user.username; // Store old name before changing
|
||||
user.username = username;
|
||||
await user.save();
|
||||
|
||||
// Log name change to ModerationLog for audit trail (only for name changes, not first time)
|
||||
if (!isFirstTimeSettingUsername && oldUsername) {
|
||||
try {
|
||||
await ModerationLog.create({
|
||||
targetUser: {
|
||||
accountId: user._id.toString(),
|
||||
username: username // New username
|
||||
},
|
||||
moderator: {
|
||||
accountId: user._id.toString(), // Self-initiated
|
||||
username: username
|
||||
},
|
||||
actionType: 'name_change_manual',
|
||||
reason: 'User-initiated name change',
|
||||
nameChange: {
|
||||
oldName: oldUsername,
|
||||
newName: username
|
||||
},
|
||||
notes: 'Voluntary name change (no approval required)'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Don't fail the name change if logging fails
|
||||
console.error('Error logging name change to ModerationLog:', logError);
|
||||
}
|
||||
|
||||
// Update pending reports against this user to use the new username
|
||||
try {
|
||||
await Report.updateMany(
|
||||
{
|
||||
'reportedUser.accountId': user._id.toString(),
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
'reportedUser.username': username
|
||||
}
|
||||
);
|
||||
} catch (reportError) {
|
||||
// Don't fail the name change if report update fails
|
||||
console.error('Error updating pending reports with new username:', reportError);
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial UserStats entry for new users
|
||||
if (isFirstTimeSettingUsername) {
|
||||
try {
|
||||
await UserStatsService.recordGameStats(user._id, null, { triggerEvent: 'account_created' });
|
||||
} catch (error) {
|
||||
console.error('Error creating initial user stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// try {
|
||||
// if(process.env.DISCORD_WEBHOOK) {
|
||||
// const hook = new Webhook(process.env.DISCORD_WEBHOOK);
|
||||
// hook.setUsername("WorldGuessr");
|
||||
// hook.send(`🎉 **${username}** has joined WorldGuessr!`);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Discord webhook failed', error);
|
||||
// }
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ message: "Server error", error: error.message, success: false });
|
||||
}
|
||||
}
|
||||
186
api/storeGame.js
Normal file
186
api/storeGame.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// import ratelimiter from '@/components/utils/ratelimitMiddleware'
|
||||
import ratelimiter from '../components/utils/ratelimitMiddleware.js';
|
||||
import Game from '../models/Game.js';
|
||||
import User from '../models/User.js';
|
||||
import UserStatsService from '../components/utils/userStatsService.js';
|
||||
import { createUUID } from '../components/createUUID.js';
|
||||
|
||||
// Handle singleplayer game completion
|
||||
async function guess(req, res) {
|
||||
const { secret, maxDist, rounds, official, location } = req.body;
|
||||
|
||||
// secret must be string
|
||||
if(typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid input' });
|
||||
}
|
||||
|
||||
// Handle batch rounds (singleplayer game completion)
|
||||
if(rounds && Array.isArray(rounds)) {
|
||||
if(secret) {
|
||||
try {
|
||||
// Get user info
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Generate unique game ID
|
||||
const gameId = `sp_${createUUID()}`;
|
||||
|
||||
// Calculate realistic game timing
|
||||
const totalRoundTime = rounds.reduce((sum, round) => sum + round.roundTime, 0);
|
||||
const gameEndTime = new Date();
|
||||
const gameStartTime = new Date(gameEndTime.getTime() - (totalRoundTime * 1000) - (rounds.length * 10000)); // Add 10s between rounds
|
||||
|
||||
// Calculate total duration and points
|
||||
const totalDuration = rounds.reduce((sum, round) => sum + round.roundTime, 0); // Keep in seconds
|
||||
const totalPoints = rounds.reduce((sum, round) => sum + round.points, 0); // Use actual points from rounds
|
||||
|
||||
// Validate and cap XP to prevent exploitation
|
||||
// Max XP per round is 100 (5000 points / 50), cap total at 500 per request
|
||||
const MAX_XP_PER_ROUND = 100;
|
||||
const MAX_TOTAL_XP = 500;
|
||||
|
||||
let totalXp = rounds.reduce((sum, round) => {
|
||||
const roundXp = Math.min(Math.max(0, round.xp || 0), MAX_XP_PER_ROUND);
|
||||
return sum + roundXp;
|
||||
}, 0);
|
||||
|
||||
// Cap total XP
|
||||
if (totalXp > MAX_TOTAL_XP) {
|
||||
console.warn(`XP cap exceeded for user ${user.username}: attempted ${totalXp}, capped to ${MAX_TOTAL_XP}`);
|
||||
totalXp = MAX_TOTAL_XP;
|
||||
}
|
||||
|
||||
// Prepare rounds data for Games collection
|
||||
let currentRoundStart = gameStartTime.getTime();
|
||||
const gameRounds = rounds.map((round, index) => {
|
||||
const { lat: guessLat, long: guessLong, actualLat, actualLong, usedHint, maxDist, roundTime, xp, points } = round;
|
||||
const actualPoints = points; // Use actual points from frontend
|
||||
|
||||
const roundStart = new Date(currentRoundStart);
|
||||
const roundEnd = new Date(currentRoundStart + (roundTime * 1000));
|
||||
const guessTime = new Date(currentRoundStart + (roundTime * 1000));
|
||||
|
||||
// Move to next round (add round time + 10 seconds between rounds)
|
||||
currentRoundStart += (roundTime * 1000) + 10000;
|
||||
|
||||
return {
|
||||
roundNumber: index + 1,
|
||||
location: {
|
||||
lat: actualLat,
|
||||
long: actualLong,
|
||||
panoId: round.panoId || null,
|
||||
country: round.country || null, // We don't have country data in the current structure
|
||||
place: round.place || null
|
||||
},
|
||||
playerGuesses: [{
|
||||
playerId: user._id,
|
||||
username: user.username || 'Player',
|
||||
accountId: user._id,
|
||||
guessLat: guessLat,
|
||||
guessLong: guessLong,
|
||||
points: actualPoints,
|
||||
timeTaken: roundTime,
|
||||
xpEarned: xp || 0,
|
||||
guessedAt: guessTime,
|
||||
usedHint: usedHint || false
|
||||
}],
|
||||
startedAt: roundStart,
|
||||
endedAt: roundEnd
|
||||
// No roundTimeLimit for singleplayer games - players can take as long as they want
|
||||
};
|
||||
});
|
||||
|
||||
// Create game document
|
||||
const gameDoc = new Game({
|
||||
gameId: gameId,
|
||||
gameType: 'singleplayer',
|
||||
|
||||
settings: {
|
||||
location: location || 'all', // Use provided location or default to 'all'
|
||||
rounds: rounds.length,
|
||||
maxDist: maxDist || 20000,
|
||||
timePerRound: null, // No time limit for singleplayer
|
||||
official: official !== undefined ? official : true, // Use provided official status or default to true
|
||||
showRoadName: false,
|
||||
noMove: false,
|
||||
noPan: false,
|
||||
noZoom: false
|
||||
},
|
||||
|
||||
startedAt: gameStartTime,
|
||||
endedAt: gameEndTime,
|
||||
totalDuration: totalDuration,
|
||||
|
||||
rounds: gameRounds,
|
||||
|
||||
players: [{
|
||||
playerId: user._id,
|
||||
username: user.username || 'Player',
|
||||
accountId: user._id,
|
||||
totalPoints: totalPoints,
|
||||
totalXp: totalXp,
|
||||
averageTimePerRound: rounds.reduce((sum, r) => sum + r.roundTime, 0) / rounds.length,
|
||||
finalRank: 1,
|
||||
elo: {
|
||||
before: null,
|
||||
after: null,
|
||||
change: null
|
||||
}
|
||||
}],
|
||||
|
||||
result: {
|
||||
winner: null,
|
||||
isDraw: false,
|
||||
maxPossiblePoints: rounds.length * 5000
|
||||
},
|
||||
|
||||
multiplayer: {
|
||||
isPublic: false,
|
||||
gameCode: null,
|
||||
hostPlayerId: null,
|
||||
maxPlayers: 1
|
||||
}
|
||||
});
|
||||
|
||||
// Save the game to Games collection
|
||||
await gameDoc.save();
|
||||
|
||||
// Update user's totalGamesPlayed (increment by 1 per game, not per round)
|
||||
await User.updateOne(
|
||||
{ secret: user.secret },
|
||||
{
|
||||
$inc: {
|
||||
totalGamesPlayed: 1,
|
||||
totalXp: totalXp
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Record user stats for analytics
|
||||
try {
|
||||
await UserStatsService.recordGameStats(user._id, gameId);
|
||||
} catch (statsError) {
|
||||
console.warn('Failed to record user stats:', statsError);
|
||||
// Don't fail the entire request if stats recording fails
|
||||
}
|
||||
|
||||
console.log(`Saved singleplayer game ${gameId} for user ${user.username} with ${totalPoints} points`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving singleplayer game:', error);
|
||||
return res.status(500).json({ error: 'An error occurred', message: error.message });
|
||||
}
|
||||
}
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
// No batch rounds provided - invalid request
|
||||
return res.status(400).json({ message: 'Invalid input: rounds array required' });
|
||||
}
|
||||
|
||||
// Limit to 4 request per 10 seconds, generous limit but better than nothing
|
||||
export default ratelimiter(guess, 4, 10000)
|
||||
// no rate limit
|
||||
// export default guess;
|
||||
119
api/submitNameChange.js
Normal file
119
api/submitNameChange.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import User, { USERNAME_COLLATION } from '../models/User.js';
|
||||
import NameChangeRequest from '../models/NameChangeRequest.js';
|
||||
|
||||
/**
|
||||
* Submit Name Change API
|
||||
*
|
||||
* For users who have been forced to change their name.
|
||||
* Submits a new username for moderator review.
|
||||
*/
|
||||
|
||||
// TODO: FUNCTIONALTIY MERGED TO setName.js, SHOULD BE DEPRECATED SOON AFTER UPDATING CLIENT.
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret, newUsername } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid secret' });
|
||||
}
|
||||
|
||||
if (!newUsername || typeof newUsername !== 'string') {
|
||||
return res.status(400).json({ message: 'New username is required' });
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
const trimmedUsername = newUsername.trim();
|
||||
|
||||
if (trimmedUsername.length < 3 || trimmedUsername.length > 30) {
|
||||
return res.status(400).json({ message: 'Username must be between 3 and 30 characters' });
|
||||
}
|
||||
|
||||
// Only allow alphanumeric, underscores
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
message: "Username must contain only letters, numbers, and underscores",
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Get the user
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if user is banned (permanent or active temp ban)
|
||||
if (user.banned) {
|
||||
const isActiveBan = user.banType === 'permanent' ||
|
||||
(user.banType === 'temporary' && user.banExpiresAt && new Date(user.banExpiresAt) > new Date());
|
||||
if (isActiveBan) {
|
||||
return res.status(403).json({ message: 'Banned users cannot change their username' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user actually needs to change their name
|
||||
if (!user.pendingNameChange) {
|
||||
return res.status(400).json({ message: 'You do not have a pending name change' });
|
||||
}
|
||||
|
||||
// Check if username is already taken (case-insensitive with collation index)
|
||||
const existingUser = await User.findOne({
|
||||
username: trimmedUsername,
|
||||
_id: { $ne: user._id }
|
||||
}).collation(USERNAME_COLLATION);
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: 'This username is already taken' });
|
||||
}
|
||||
|
||||
// Check if user already has a pending request
|
||||
const existingRequest = await NameChangeRequest.findOne({
|
||||
'user.accountId': user._id.toString(),
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
if (existingRequest) {
|
||||
// Update the existing request with the new name
|
||||
await NameChangeRequest.findByIdAndUpdate(existingRequest._id, {
|
||||
requestedUsername: trimmedUsername,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Your name change request has been updated. Please wait for moderator review.',
|
||||
requestId: existingRequest._id
|
||||
});
|
||||
}
|
||||
|
||||
// Create new name change request
|
||||
const nameRequest = await NameChangeRequest.create({
|
||||
user: {
|
||||
accountId: user._id.toString(),
|
||||
currentUsername: user.username
|
||||
},
|
||||
requestedUsername: trimmedUsername,
|
||||
reason: user.pendingNameChangeReason || 'Forced name change',
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Your name change request has been submitted. Please wait for moderator review.',
|
||||
requestId: nameRequest._id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Submit name change error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while submitting name change',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
181
api/submitReport.js
Normal file
181
api/submitReport.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import User from '../models/User.js';
|
||||
import Report from '../models/Report.js';
|
||||
import Game from '../models/Game.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const {
|
||||
secret,
|
||||
reportedUserAccountId, // Optional - can be inferred from game for duels
|
||||
reason,
|
||||
description,
|
||||
gameId,
|
||||
gameType
|
||||
} = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid session' });
|
||||
}
|
||||
|
||||
if (!reason || !['inappropriate_username', 'cheating', 'other'].includes(reason)) {
|
||||
return res.status(400).json({ message: 'Invalid reason' });
|
||||
}
|
||||
|
||||
if (!description || typeof description !== 'string' || description.trim().length < 10) {
|
||||
return res.status(400).json({ message: 'Description must be at least 10 characters' });
|
||||
}
|
||||
|
||||
if (description.length > 500) {
|
||||
return res.status(400).json({ message: 'Description must be less than 500 characters' });
|
||||
}
|
||||
|
||||
if (!gameId || typeof gameId !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid game ID' });
|
||||
}
|
||||
|
||||
if (!gameType || !['ranked_duel', 'unranked_multiplayer', 'private_multiplayer'].includes(gameType)) {
|
||||
return res.status(400).json({ message: 'Invalid game type' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify reporter exists
|
||||
const reporter = await User.findOne({ secret });
|
||||
if (!reporter) {
|
||||
return res.status(403).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check if reporter is banned
|
||||
if (reporter.banned) {
|
||||
return res.status(403).json({ message: 'Your account is banned and cannot submit reports' });
|
||||
}
|
||||
|
||||
// Find the game to verify it exists and get reported user info
|
||||
const game = await Game.findOne({ gameId });
|
||||
if (!game) {
|
||||
return res.status(404).json({ message: 'Game not found' });
|
||||
}
|
||||
|
||||
// Verify the reporter was in this game
|
||||
const reporterInGame = game.players.find(
|
||||
p => p.accountId && p.accountId === reporter._id.toString()
|
||||
);
|
||||
if (!reporterInGame) {
|
||||
return res.status(403).json({ message: 'You were not in this game' });
|
||||
}
|
||||
|
||||
// Determine the reported user from the game
|
||||
// CRITICAL SECURITY: The reported user MUST be verified to be in the game
|
||||
let reportedPlayerInGame;
|
||||
let finalReportedUserAccountId;
|
||||
|
||||
if (reportedUserAccountId) {
|
||||
// SECURITY CHECK: Validate the provided reportedUserAccountId is actually in the game
|
||||
// This prevents users from reporting random people by sending fake user IDs
|
||||
reportedPlayerInGame = game.players.find(
|
||||
p => p.accountId && p.accountId === reportedUserAccountId
|
||||
);
|
||||
|
||||
if (!reportedPlayerInGame) {
|
||||
return res.status(400).json({
|
||||
message: 'The reported player was not in this game. You can only report players who participated in the game.'
|
||||
});
|
||||
}
|
||||
|
||||
finalReportedUserAccountId = reportedUserAccountId;
|
||||
} else {
|
||||
// If not provided, try to infer from game (works for duels only)
|
||||
const playersWithAccounts = game.players.filter(p => p.accountId);
|
||||
|
||||
if (playersWithAccounts.length === 2) {
|
||||
// It's a duel - the other player is the reported one
|
||||
reportedPlayerInGame = playersWithAccounts.find(
|
||||
p => p.accountId !== reporter._id.toString()
|
||||
);
|
||||
if (!reportedPlayerInGame) {
|
||||
return res.status(400).json({ message: 'Could not determine reported player' });
|
||||
}
|
||||
finalReportedUserAccountId = reportedPlayerInGame.accountId;
|
||||
} else {
|
||||
// Multiplayer game - need to specify which player
|
||||
return res.status(400).json({
|
||||
message: 'For multiplayer games, you must specify which player to report'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Double-check: Ensure reportedPlayerInGame was found in the game
|
||||
// This should always be true at this point, but adding as an extra safety measure
|
||||
if (!reportedPlayerInGame || !reportedPlayerInGame.accountId) {
|
||||
return res.status(400).json({ message: 'Invalid reported player data' });
|
||||
}
|
||||
|
||||
// Prevent self-reporting
|
||||
if (reporter._id.toString() === finalReportedUserAccountId) {
|
||||
return res.status(400).json({ message: 'You cannot report yourself' });
|
||||
}
|
||||
|
||||
// Verify reported user exists (by MongoDB _id)
|
||||
const reportedUser = await User.findById(finalReportedUserAccountId);
|
||||
if (!reportedUser) {
|
||||
return res.status(404).json({ message: 'Reported user not found' });
|
||||
}
|
||||
|
||||
// Check for duplicate reports (same reporter, same reported user, same game)
|
||||
const existingReport = await Report.findOne({
|
||||
'reportedBy.accountId': reporter._id.toString(),
|
||||
'reportedUser.accountId': finalReportedUserAccountId,
|
||||
gameId: gameId
|
||||
});
|
||||
|
||||
if (existingReport) {
|
||||
return res.status(409).json({ message: 'You have already reported this player for this game' });
|
||||
}
|
||||
|
||||
// Check for spam (more than 5 reports in the last hour)
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const recentReportsCount = await Report.countDocuments({
|
||||
'reportedBy.accountId': reporter._id.toString(),
|
||||
createdAt: { $gte: oneHourAgo }
|
||||
});
|
||||
|
||||
if (recentReportsCount >= 5) {
|
||||
return res.status(429).json({ message: 'You are submitting reports too quickly. Please try again later.' });
|
||||
}
|
||||
|
||||
// Create the report - use username from game data for accuracy
|
||||
const report = new Report({
|
||||
reportedBy: {
|
||||
accountId: reporter._id.toString(),
|
||||
username: reporter.username || 'Anonymous'
|
||||
},
|
||||
reportedUser: {
|
||||
accountId: finalReportedUserAccountId,
|
||||
username: reportedPlayerInGame.username // Get username from game data
|
||||
},
|
||||
reason,
|
||||
description: description.trim(),
|
||||
gameId,
|
||||
gameType,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
await report.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Report submitted successfully',
|
||||
reportId: report._id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Submit report error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while submitting the report',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
81
api/updateCountryCode.js
Normal file
81
api/updateCountryCode.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import User from '../models/User.js';
|
||||
import cachegoose from 'recachegoose';
|
||||
import { VALID_COUNTRY_CODES } from '../serverUtils/timezoneToCountry.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { token, countryCode } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (typeof token !== 'string' || !token) {
|
||||
return res.status(400).json({ message: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Allow null or empty string to remove flag, or a valid country code string
|
||||
if (countryCode !== null && countryCode !== '' && typeof countryCode !== 'string') {
|
||||
return res.status(400).json({ message: 'Invalid country code format' });
|
||||
}
|
||||
|
||||
// Validate country code format if provided (not empty and not null)
|
||||
if (countryCode && countryCode !== '') {
|
||||
const upperCode = countryCode.toUpperCase();
|
||||
|
||||
if (!VALID_COUNTRY_CODES.includes(upperCode)) {
|
||||
return res.status(400).json({ message: 'Invalid country code. Must be a valid ISO 3166-1 alpha-2 code.' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by token
|
||||
const user = await User.findOne({ secret: token });
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
if (user.banned) {
|
||||
const isActiveBan = user.banType === 'permanent' ||
|
||||
(user.banType === 'temporary' && user.banExpiresAt && new Date(user.banExpiresAt) > new Date());
|
||||
if (isActiveBan) {
|
||||
return res.status(403).json({ message: 'Banned users cannot update their flag' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update country code
|
||||
// Important: Use empty string "" for "user opted out" (not null)
|
||||
// null = "not set yet" (will be auto-assigned on next login)
|
||||
// "" = "user explicitly removed flag" (won't be auto-assigned)
|
||||
const newCountryCode = (countryCode === '' || countryCode === null)
|
||||
? '' // Always use empty string for removal/opt-out
|
||||
: countryCode.toUpperCase();
|
||||
|
||||
user.countryCode = newCountryCode;
|
||||
await user.save();
|
||||
|
||||
// Clear caches for this user
|
||||
cachegoose.clearCache(`publicData_${user._id.toString()}`, (error) => {
|
||||
if (error) {
|
||||
console.error('Error clearing publicData cache', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear auth cache so next auth request gets fresh data
|
||||
cachegoose.clearCache(`userAuth_${token}`, (error) => {
|
||||
if (error) {
|
||||
console.error('Error clearing userAuth cache', error);
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, countryCode: newCountryCode || null });
|
||||
} catch (error) {
|
||||
console.error('Error updating country code:', error);
|
||||
return res.status(500).json({
|
||||
message: 'Server error',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
168
api/userModerationData.js
Normal file
168
api/userModerationData.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import User from '../models/User.js';
|
||||
import UserStats from '../models/UserStats.js';
|
||||
import ModerationLog from '../models/ModerationLog.js';
|
||||
import Report from '../models/Report.js';
|
||||
|
||||
/**
|
||||
* User Moderation Data API
|
||||
*
|
||||
* Returns moderation-related data for a user's own account:
|
||||
* - ELO refunds they received (from banned players)
|
||||
* - Moderation actions against them (public notes only, no internal reasons)
|
||||
* - Reports they submitted and their status
|
||||
*
|
||||
* Does NOT return:
|
||||
* - Reports against them (for security)
|
||||
* - Internal moderator notes
|
||||
* - Details about what action was taken on their reports
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { secret } = req.body;
|
||||
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return res.status(400).json({ message: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by secret
|
||||
const user = await User.findOne({ secret });
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Invalid authentication' });
|
||||
}
|
||||
|
||||
const accountId = user._id.toString();
|
||||
|
||||
// 1. Get ELO refunds from UserStats
|
||||
const eloRefunds = await UserStats.find({
|
||||
userId: accountId,
|
||||
triggerEvent: 'elo_refund'
|
||||
})
|
||||
.sort({ timestamp: -1 })
|
||||
.limit(100)
|
||||
.lean();
|
||||
|
||||
// Format ELO refunds for frontend
|
||||
const formattedRefunds = eloRefunds.map(refund => ({
|
||||
id: refund._id,
|
||||
amount: refund.eloRefundDetails?.amount || 0,
|
||||
bannedUsername: refund.eloRefundDetails?.bannedUsername || 'Unknown',
|
||||
date: refund.timestamp,
|
||||
newElo: refund.elo
|
||||
}));
|
||||
|
||||
// 2. Get moderation actions against this user (public info only)
|
||||
const moderationHistory = await ModerationLog.find({
|
||||
'targetUser.accountId': accountId
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean();
|
||||
|
||||
// Format moderation history - only show public information
|
||||
const formattedModerationHistory = moderationHistory.map(log => {
|
||||
// Determine a user-friendly action description
|
||||
let actionDescription;
|
||||
switch (log.actionType) {
|
||||
case 'ban_permanent':
|
||||
actionDescription = 'Account suspended';
|
||||
break;
|
||||
case 'ban_temporary':
|
||||
actionDescription = 'Account temporarily suspended';
|
||||
break;
|
||||
case 'unban':
|
||||
actionDescription = 'Account suspension lifted';
|
||||
break;
|
||||
case 'force_name_change':
|
||||
actionDescription = 'Username change required';
|
||||
break;
|
||||
case 'name_change_approved':
|
||||
actionDescription = 'Username change approved';
|
||||
break;
|
||||
case 'name_change_rejected':
|
||||
actionDescription = 'Username change rejected';
|
||||
break;
|
||||
case 'warning':
|
||||
actionDescription = 'Warning issued';
|
||||
break;
|
||||
default:
|
||||
actionDescription = 'Moderation action';
|
||||
}
|
||||
|
||||
return {
|
||||
id: log._id,
|
||||
actionType: log.actionType,
|
||||
actionDescription,
|
||||
publicNote: log.notes || null, // Only the public note, never the internal reason
|
||||
date: log.createdAt,
|
||||
expiresAt: log.expiresAt || null, // For temp bans
|
||||
durationString: log.durationString || null
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Get reports submitted by this user
|
||||
const submittedReports = await Report.find({
|
||||
'reportedBy.accountId': accountId
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.lean();
|
||||
|
||||
// Format submitted reports - only show basic status, no details
|
||||
const formattedReports = submittedReports.map(report => {
|
||||
// Simplified status for users
|
||||
let displayStatus;
|
||||
switch (report.status) {
|
||||
case 'pending':
|
||||
case 'reviewed': // Still being reviewed
|
||||
displayStatus = 'open';
|
||||
break;
|
||||
case 'dismissed':
|
||||
displayStatus = 'ignored';
|
||||
break;
|
||||
case 'action_taken':
|
||||
displayStatus = 'action_taken';
|
||||
break;
|
||||
default:
|
||||
displayStatus = 'open';
|
||||
}
|
||||
|
||||
return {
|
||||
id: report._id,
|
||||
reportedUsername: report.reportedUser.username,
|
||||
reason: report.reason,
|
||||
status: displayStatus,
|
||||
date: report.createdAt
|
||||
// Intentionally NOT including: description, actionTaken, moderatorNotes
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate summary stats
|
||||
const totalEloRefunded = formattedRefunds.reduce((sum, r) => sum + r.amount, 0);
|
||||
const reportsResultingInAction = formattedReports.filter(r => r.status === 'action_taken').length;
|
||||
|
||||
return res.status(200).json({
|
||||
eloRefunds: formattedRefunds,
|
||||
totalEloRefunded,
|
||||
moderationHistory: formattedModerationHistory,
|
||||
submittedReports: formattedReports,
|
||||
reportStats: {
|
||||
total: formattedReports.length,
|
||||
open: formattedReports.filter(r => r.status === 'open').length,
|
||||
ignored: formattedReports.filter(r => r.status === 'ignored').length,
|
||||
actionTaken: reportsResultingInAction
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('User moderation data error:', error);
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
163
api/userProgression.js
Normal file
163
api/userProgression.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import mongoose from 'mongoose';
|
||||
import User, { USERNAME_COLLATION } from '../models/User.js';
|
||||
import UserStatsService from '../components/utils/userStatsService.js';
|
||||
import { rateLimit } from '../utils/rateLimit.js';
|
||||
|
||||
|
||||
// gautam note: this doesnt make any sense at all, ai slop.
|
||||
// user id is public, username is public, so why are we pretending like user id is private?
|
||||
// temporarily fix this by setting isPublicRequest to true, every request is public.
|
||||
|
||||
// Username validation regex: alphanumeric and underscores only, 3-20 characters
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
|
||||
// MongoDB ObjectId validation regex
|
||||
const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
|
||||
|
||||
/**
|
||||
* Sanitize progression data by removing sensitive fields
|
||||
* @param {Array} progression - Raw progression data
|
||||
* @param {boolean} isPublic - Whether this is a public (username-based) request
|
||||
* @returns {Array} Sanitized progression data
|
||||
*/
|
||||
function sanitizeProgression(progression, isPublic = false) {
|
||||
return progression.map(stat => {
|
||||
const sanitized = {
|
||||
timestamp: stat.timestamp,
|
||||
totalXp: stat.totalXp,
|
||||
xpRank: stat.xpRank,
|
||||
elo: stat.elo,
|
||||
eloRank: stat.eloRank,
|
||||
// Calculated fields
|
||||
xpGain: stat.xpGain || 0,
|
||||
eloChange: stat.eloChange || 0,
|
||||
rankImprovement: stat.rankImprovement || 0
|
||||
};
|
||||
|
||||
// Never expose userId for public requests
|
||||
if (!isPublic) {
|
||||
sanitized.userId = stat.userId;
|
||||
}
|
||||
|
||||
// Never expose gameId, eloRefundDetails, or other sensitive fields
|
||||
// These are intentionally excluded for security
|
||||
|
||||
return sanitized;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User Progression API Endpoint
|
||||
* Returns user stats progression for charts
|
||||
* Includes rate limiting, input validation, and security measures
|
||||
*/
|
||||
export default async function handler(req, res) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// Determine if this is a public (username-based) or authenticated (userId-based) request
|
||||
const { userId, username } = req.body;
|
||||
console.log(`[API] userProgression: ${username || userId}`);
|
||||
const isPublicRequest = true
|
||||
|
||||
// Apply stricter rate limiting for public requests
|
||||
// Public: 5 requests per minute per IP
|
||||
// Authenticated: 20 requests per minute per IP
|
||||
const limiter = rateLimit({
|
||||
max: 10,
|
||||
windowMs: 60000,
|
||||
message: 'Too many requests. Please try again later.'
|
||||
});
|
||||
|
||||
if (!limiter(req, res)) {
|
||||
return; // Rate limit exceeded, response already sent
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate input: must provide either userId or username, but not both
|
||||
if (!userId && !username) {
|
||||
return res.status(400).json({ message: 'UserId or username is required' });
|
||||
}
|
||||
|
||||
if (userId && username) {
|
||||
return res.status(400).json({ message: 'Provide either userId or username, not both' });
|
||||
}
|
||||
|
||||
// Validate userId format (MongoDB ObjectId)
|
||||
if (userId) {
|
||||
if (typeof userId !== 'string' || !OBJECT_ID_REGEX.test(userId)) {
|
||||
return res.status(400).json({ message: 'Invalid userId format' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate username format (prevent injection attacks)
|
||||
if (username) {
|
||||
if (typeof username !== 'string') {
|
||||
return res.status(400).json({ message: 'Username must be a string' });
|
||||
}
|
||||
if (!USERNAME_REGEX.test(username)) {
|
||||
return res.status(400).json({
|
||||
message: 'Invalid username format. Username must be 3-20 characters and contain only letters, numbers, and underscores.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to MongoDB if not already connected
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB);
|
||||
} catch (error) {
|
||||
console.error('Database connection failed:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Find user by userId or username
|
||||
let user;
|
||||
if (userId) {
|
||||
user = await User.findOne({ _id: userId });
|
||||
} else if (username) {
|
||||
user = await User.findOne({ username: username }).collation(USERNAME_COLLATION);
|
||||
}
|
||||
|
||||
// Generic error message to prevent user enumeration
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
// Exclude banned users and users with pending name changes (public API security)
|
||||
// if ((user.banned === true || user.pendingNameChange === true) && isPublicRequest) {
|
||||
// // Only apply this check for username-based requests (public access)
|
||||
// // Allow userId-based requests (authenticated user viewing their own data)
|
||||
// return res.status(404).json({ message: 'User not found' });
|
||||
// }
|
||||
|
||||
// Get user's stats progression
|
||||
const progression = await UserStatsService.getUserProgression(user._id);
|
||||
|
||||
// Sanitize progression data - remove gameId and other sensitive fields
|
||||
const sanitizedProgression = sanitizeProgression(progression, isPublicRequest);
|
||||
|
||||
// Build response
|
||||
const response = {
|
||||
progression: sanitizedProgression,
|
||||
username: user.username
|
||||
};
|
||||
|
||||
// Only include userId for authenticated requests (not public)
|
||||
if (!isPublicRequest) {
|
||||
response.userId = user._id.toString();
|
||||
}
|
||||
|
||||
return res.status(200).json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user progression:', error);
|
||||
// Don't expose internal error details in production
|
||||
return res.status(500).json({
|
||||
message: 'An error occurred while fetching progression data'
|
||||
});
|
||||
}
|
||||
}
|
||||
12
clientConfig.js
Normal file
12
clientConfig.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
export default function config() {
|
||||
const isHttps = window ? (window.location.protocol === "https:") : true;
|
||||
const prefixHttp = (isHttps ? "https" : "http")+"://";
|
||||
const prefixWs = (isHttps ? "wss" : "ws")+"://";
|
||||
|
||||
|
||||
return {
|
||||
"apiUrl": prefixHttp+(process.env.NEXT_PUBLIC_API_URL ?? "localhost:3001"),
|
||||
"websocketUrl": prefixWs+(process.env.NEXT_PUBLIC_WS_HOST ?? process.env.NEXT_PUBLIC_API_URL ?? "localhost:3002")+'/wg',
|
||||
}
|
||||
}
|
||||
97
components/AnimatedCounter.js
Normal file
97
components/AnimatedCounter.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export default function AnimatedCounter({
|
||||
value,
|
||||
duration = 800,
|
||||
className = '',
|
||||
showIncrement = true,
|
||||
incrementColor = '#22c55e',
|
||||
formatNumber = true
|
||||
}) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [incrementAmount, setIncrementAmount] = useState(0);
|
||||
const [showIncrementText, setShowIncrementText] = useState(false);
|
||||
const previousValue = useRef(value);
|
||||
const animationRef = useRef();
|
||||
const incrementTimeoutRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const startValue = previousValue.current;
|
||||
const endValue = value;
|
||||
const difference = endValue - startValue;
|
||||
|
||||
// Only animate if there's a change and it's positive (increment)
|
||||
if (difference <= 0) {
|
||||
setDisplayValue(value);
|
||||
previousValue.current = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show increment animation
|
||||
if (showIncrement && difference > 0) {
|
||||
setIncrementAmount(difference);
|
||||
setShowIncrementText(true);
|
||||
|
||||
// Hide increment text after animation
|
||||
incrementTimeoutRef.current = setTimeout(() => {
|
||||
setShowIncrementText(false);
|
||||
}, duration + 200);
|
||||
}
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function - ease out cubic for smooth deceleration
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const currentValue = startValue + (difference * easeOutCubic);
|
||||
setDisplayValue(Math.round(currentValue));
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setDisplayValue(endValue);
|
||||
setIsAnimating(false);
|
||||
previousValue.current = endValue;
|
||||
}
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
if (incrementTimeoutRef.current) {
|
||||
clearTimeout(incrementTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, duration, showIncrement]);
|
||||
|
||||
const formattedValue = formatNumber && displayValue >= 1000
|
||||
? displayValue.toLocaleString()
|
||||
: displayValue;
|
||||
|
||||
return (
|
||||
<span className={`animated-counter ${className} ${isAnimating ? 'animating' : ''}`}>
|
||||
<span className="counter-value">{formattedValue}</span>
|
||||
{showIncrement && showIncrementText && incrementAmount > 0 && (
|
||||
<span
|
||||
className="increment-indicator"
|
||||
style={{
|
||||
color: incrementColor,
|
||||
animation: `pointIncrement ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) forwards`
|
||||
}}
|
||||
>
|
||||
+{formatNumber && incrementAmount >= 1000 ? incrementAmount.toLocaleString() : incrementAmount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
157
components/MaintenanceBanner.js
Normal file
157
components/MaintenanceBanner.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { FaWrench } from "react-icons/fa";
|
||||
|
||||
// Maintenance window: 13:00-15:00 UTC on Dec 23, 2025
|
||||
const MAINTENANCE_START_UTC = new Date("2025-12-23T13:00:00Z");
|
||||
const MAINTENANCE_END_UTC = new Date("2025-12-23T15:00:00Z");
|
||||
|
||||
function formatTimeRange(start, end) {
|
||||
const startHour = start.getHours();
|
||||
const endHour = end.getHours();
|
||||
const startMin = start.getMinutes();
|
||||
const endMin = end.getMinutes();
|
||||
|
||||
const startPeriod = startHour >= 12 ? "PM" : "AM";
|
||||
const endPeriod = endHour >= 12 ? "PM" : "AM";
|
||||
|
||||
const formatHour = (h) => h % 12 || 12;
|
||||
const formatMin = (m) => m > 0 ? `:${String(m).padStart(2, "0")}` : "";
|
||||
|
||||
const startStr = `${formatHour(startHour)}${formatMin(startMin)}`;
|
||||
const endStr = `${formatHour(endHour)}${formatMin(endMin)}`;
|
||||
|
||||
// Only show AM/PM once if same period
|
||||
if (startPeriod === endPeriod) {
|
||||
return `${startStr}–${endStr} ${endPeriod}`;
|
||||
}
|
||||
return `${startStr} ${startPeriod}–${endStr} ${endPeriod}`;
|
||||
}
|
||||
|
||||
function formatCountdown(ms) {
|
||||
if (ms <= 0) return "00:00:00";
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function MaintenanceBanner() {
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// const dismissedUntil = localStorage.getItem("maintenanceBannerDismissed");
|
||||
// if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
|
||||
// setDismissed(true);
|
||||
// }
|
||||
}, []);
|
||||
|
||||
const isBeforeMaintenance = now < MAINTENANCE_START_UTC;
|
||||
const isDuringMaintenance = now >= MAINTENANCE_START_UTC && now < MAINTENANCE_END_UTC;
|
||||
const isAfterMaintenance = now >= MAINTENANCE_END_UTC;
|
||||
|
||||
if (isAfterMaintenance || dismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem("maintenanceBannerDismissed", MAINTENANCE_END_UTC.toISOString());
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const countdown = isBeforeMaintenance
|
||||
? formatCountdown(MAINTENANCE_START_UTC - now)
|
||||
: formatCountdown(MAINTENANCE_END_UTC - now);
|
||||
|
||||
return (
|
||||
<div style={styles.banner}>
|
||||
<div style={styles.content}>
|
||||
<FaWrench style={isDuringMaintenance ? styles.iconActive : styles.icon} />
|
||||
|
||||
<div style={styles.text}>
|
||||
{isDuringMaintenance ? (
|
||||
<>🔧 <strong>Server maintenance in progress</strong> · Back in <strong style={styles.countdown}>{countdown}</strong></>
|
||||
) : (
|
||||
<>⚠️ <strong>Server maintenance scheduled for {formatTimeRange(MAINTENANCE_START_UTC, MAINTENANCE_END_UTC)}</strong> (starts in <span style={styles.countdown}>{countdown}</span>)</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isDuringMaintenance && (
|
||||
<button onClick={handleDismiss} style={styles.closeBtn} aria-label="Dismiss">×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
banner: {
|
||||
width: "100%",
|
||||
padding: "0 8px",
|
||||
minWidth: 300,
|
||||
|
||||
marginBottom: "12px",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
content: {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
background: "linear-gradient(90deg, #d35400 0%, #c0392b 100%)",
|
||||
border: "2px solid #e74c3c",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
boxShadow: "0 4px 12px rgba(211, 84, 0, 0.4)",
|
||||
flexWrap: "wrap",
|
||||
maxWidth: "100%",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
icon: {
|
||||
color: "#fff",
|
||||
fontSize: "14px",
|
||||
flexShrink: 0,
|
||||
marginTop: "2px",
|
||||
},
|
||||
iconActive: {
|
||||
color: "#fff",
|
||||
fontSize: "14px",
|
||||
flexShrink: 0,
|
||||
marginTop: "2px",
|
||||
animation: "spin 2s linear infinite",
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
fontSize: "0.85rem",
|
||||
color: "#fff",
|
||||
lineHeight: 1.5,
|
||||
fontWeight: 500,
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
countdown: {
|
||||
color: "#ffe066",
|
||||
fontFamily: "'JetBrains Mono', 'Consolas', monospace",
|
||||
fontWeight: 700,
|
||||
},
|
||||
closeBtn: {
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: "16px",
|
||||
cursor: "pointer",
|
||||
padding: "2px 6px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
flexShrink: 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof document !== "undefined" && !document.getElementById("maintenance-banner-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "maintenance-banner-styles";
|
||||
style.textContent = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
312
components/Map.js
Normal file
312
components/Map.js
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CircleMarker, Marker, Polyline, Tooltip, useMapEvents } from "react-leaflet";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import { asset } from '@/lib/basePath';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import customPins from '../public/customPins.json' with { type: "module" };
|
||||
import guestNameString from "@/serverUtils/guestNameFromString";
|
||||
import CountryFlag from './utils/countryFlag';
|
||||
const hintMul = 7500000 / 20000; //7500000 for all countries (20,000 km)
|
||||
|
||||
// Simple seeded random for stable hint offset per round
|
||||
function seededRandom(seed) {
|
||||
const x = Math.sin(seed * 9999) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
// HintCircle component that scales with zoom
|
||||
function HintCircle({ location, gameOptions, round }) {
|
||||
const [zoom, setZoom] = useState(2);
|
||||
|
||||
const map = useMapEvents({
|
||||
zoom: () => setZoom(map.getZoom()),
|
||||
});
|
||||
|
||||
// 100px at zoom 1, doubles each zoom level
|
||||
const ogRadius = 75
|
||||
const pixelRadius = ogRadius * Math.pow(2, zoom - 1);
|
||||
// Offset the center by 0 to pixelRadius in a random direction (sqrt for uniform area distribution)
|
||||
const seed = (round ?? 1) + Math.abs(location.lat * ogRadius + location.long * ogRadius);
|
||||
const offsetAngle = seededRandom(seed * 3) * 2 * Math.PI;
|
||||
const offsetAmount = Math.sqrt(seededRandom(seed * 7)) * pixelRadius;
|
||||
const offsetX = offsetAmount * Math.cos(offsetAngle);
|
||||
const offsetY = offsetAmount * Math.sin(offsetAngle);
|
||||
|
||||
// Convert pixel offset back to lat/lng
|
||||
const pointC = map.latLngToContainerPoint(L.latLng(location.lat, location.long));
|
||||
const offsetPoint = L.point(pointC.x + offsetX, pointC.y + offsetY);
|
||||
const offsetCenter = map.containerPointToLatLng(offsetPoint);
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
center={offsetCenter}
|
||||
radius={pixelRadius}
|
||||
className="hintCircle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamic import of react-leaflet components
|
||||
const MapContainer = dynamic(
|
||||
() => import("react-leaflet").then((module) => module.MapContainer),
|
||||
{
|
||||
ssr: false, // Disable server-side rendering for this component
|
||||
}
|
||||
);
|
||||
const TileLayer = dynamic(
|
||||
() => import("react-leaflet").then((module) => module.TileLayer),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
function MapPlugin({ pinPoint, setPinPoint, answerShown, dest, gameOptions, ws, multiplayerState, playSound }) {
|
||||
const multiplayerStateRef = React.useRef(multiplayerState);
|
||||
const wsRef = React.useRef(ws);
|
||||
|
||||
// Update the ref whenever multiplayerState changes
|
||||
useEffect(() => {
|
||||
multiplayerStateRef.current = multiplayerState;
|
||||
}, [multiplayerState]);
|
||||
useEffect(() => {
|
||||
wsRef.current = ws;
|
||||
}, [ws]);
|
||||
|
||||
const map = useMapEvents({
|
||||
click(e) {
|
||||
const currentMultiplayerState = multiplayerStateRef.current; // Use the ref here
|
||||
const currentWs = wsRef.current; // Use the ref here
|
||||
if (!answerShown && (!currentMultiplayerState?.inGame || (currentMultiplayerState?.inGame && !currentMultiplayerState?.gameData?.players.find(p => p.id === currentMultiplayerState?.gameData?.myId)?.final))) {
|
||||
setPinPoint(e.latlng);
|
||||
if (currentMultiplayerState?.inGame && currentMultiplayerState.gameData?.state === "guess" && currentWs) {
|
||||
const pinpointLatLong = [e.latlng.lat, e.latlng.lng];
|
||||
currentWs.send(JSON.stringify({ type: "place", latLong: pinpointLatLong, final: false }));
|
||||
}
|
||||
// play sound
|
||||
// playSound();
|
||||
// if point is outside bounds, pan back
|
||||
const bounds = L.latLngBounds([-90, -180], [90, 180]);
|
||||
|
||||
if(!bounds.contains(e.latlng)) {
|
||||
const center = e.target.panInsideBounds(bounds, { animate: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let extent = gameOptions?.extent;
|
||||
if (!map || answerShown) return;
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
||||
if (extent) {
|
||||
const bounds = L.latLngBounds([extent[1], extent[0]], [extent[3], extent[2]]);
|
||||
map.fitBounds(bounds);
|
||||
} else {
|
||||
// reset to default
|
||||
map.setView([30, 0], 2);
|
||||
}
|
||||
}catch(e) {}
|
||||
}, 500);
|
||||
}, [gameOptions?.extent ? JSON.stringify(gameOptions.extent) : null, map, answerShown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinPoint) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const bounds = L.latLngBounds([pinPoint, { lat: dest.lat, lng: dest.long }]).pad(0.5);
|
||||
map.flyToBounds(bounds, { duration: 0.5 });
|
||||
} catch(e) {}
|
||||
}, 300);
|
||||
}
|
||||
}, [answerShown]);
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => {
|
||||
map.invalidateSize();
|
||||
}, 5);
|
||||
return () => clearInterval(i);
|
||||
}, [map]);
|
||||
}
|
||||
|
||||
const MapComponent = ({ shown, options, ws, session, pinPoint, setPinPoint, answerShown, location, setKm, guessing, multiplayerSentGuess, multiplayerState, showHint, round, focused, gameOptions }) => {
|
||||
const mapRef = React.useRef(null);
|
||||
const plopSound = React.useRef();
|
||||
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
|
||||
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
// Detect mobile/tablet devices
|
||||
useEffect(() => {
|
||||
const checkDevice = () => {
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isSmallScreen = window.innerWidth <= 1024;
|
||||
setIsMobileOrTablet(isTouchDevice || isSmallScreen);
|
||||
};
|
||||
checkDevice();
|
||||
window.addEventListener('resize', checkDevice);
|
||||
return () => window.removeEventListener('resize', checkDevice);
|
||||
}, []);
|
||||
|
||||
// Cache icons to prevent repeated requests
|
||||
const icons = useMemo(() => ({
|
||||
dest: L.icon({
|
||||
iconUrl: asset('/dest.png'),
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
}),
|
||||
src: L.icon({
|
||||
iconUrl: asset('/src.png'),
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
}),
|
||||
src2: L.icon({
|
||||
iconUrl: asset('/src2.png'),
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
}),
|
||||
polandball: L.icon({
|
||||
iconUrl: './polandball.png',
|
||||
iconSize: [50, 82],
|
||||
iconAnchor: [25, 41],
|
||||
popupAnchor: [1, 5],
|
||||
})
|
||||
}), []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (answerShown && pinPoint && location) {
|
||||
// setKm(Math.round(
|
||||
|
||||
let distanceInKm =pinPoint.distanceTo({ lat: location.lat, lng: location.long }) / 1000;
|
||||
if (distanceInKm > 100) distanceInKm = Math.round(distanceInKm);
|
||||
else if (distanceInKm > 10) distanceInKm = parseFloat(distanceInKm.toFixed(1));
|
||||
else distanceInKm = parseFloat(distanceInKm.toFixed(2));
|
||||
setKm(distanceInKm);
|
||||
}
|
||||
}, [answerShown, pinPoint, location]);
|
||||
|
||||
const corner1 = L.latLng(-90 * 2, -180 * 2)
|
||||
const corner2 = L.latLng(90 * 2, 180 * 2)
|
||||
const bounds = L.latLngBounds(corner1, corner2)
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[0, 0]}
|
||||
zoom={2}
|
||||
minZoom={2}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
whenCreated={mapInstance => {
|
||||
mapRef.current = mapInstance;
|
||||
}}
|
||||
>
|
||||
|
||||
<div className='mapAttr'>
|
||||
<img width="60" src='https://lh3.googleusercontent.com/d_S5gxu_S1P6NR1gXeMthZeBzkrQMHdI5uvXrpn3nfJuXpCjlqhLQKH_hbOxTHxFhp5WugVOEcl4WDrv9rmKBDOMExhKU5KmmLFQVg' alt="Google" />
|
||||
</div>
|
||||
<MapPlugin playSound={
|
||||
() => {
|
||||
plopSound.current.play();
|
||||
}
|
||||
} pinPoint={pinPoint} setPinPoint={setPinPoint} answerShown={answerShown} dest={location} gameOptions={gameOptions} ws={ws} multiplayerState={multiplayerState} />
|
||||
{/* place a pin */}
|
||||
{location && answerShown && (
|
||||
<Marker position={{ lat: location.lat, lng: location.long }} icon={icons.dest} />
|
||||
)}
|
||||
{pinPoint && (
|
||||
<>
|
||||
<Marker position={pinPoint} icon={customPins[session?.token?.username] === "polandball" ? icons.polandball : icons.src} >
|
||||
<Tooltip direction="top" offset={[0, -45]} opacity={1} permanent position={{ lat: pinPoint.lat, lng: pinPoint.lng }}>
|
||||
|
||||
{text("yourGuess")}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
|
||||
|
||||
{answerShown && location && (
|
||||
< Polyline positions={[pinPoint, { lat: location.lat, lng: location.long }]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{multiplayerState?.inGame && answerShown && location && multiplayerState?.gameData?.players.map((player, index) => {
|
||||
if(player.id === multiplayerState?.gameData?.myId) return null;
|
||||
if(!player.guess) return null;
|
||||
|
||||
|
||||
const name = process.env.NEXT_PUBLIC_COOLMATH?guestNameString(player.username):player.username;
|
||||
const latLong = [player.guess[0], player.guess[1]];
|
||||
|
||||
const tIcon = customPins[name]==="polandball" ? icons.polandball : icons.src2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker key={(index*2)} position={{ lat: latLong[0], lng: latLong[1] }} icon={tIcon}>
|
||||
<Tooltip direction="top" offset={[0, -45]} opacity={1} permanent position={{ lat: latLong[0], lng: latLong[1] }}>
|
||||
<span style={{color: "black", display: 'flex', alignItems: 'center', gap: '4px'}}>
|
||||
{name}
|
||||
{player.countryCode && <CountryFlag countryCode={player.countryCode} style={{ fontSize: '0.9em', marginRight: '0' }} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
<Polyline key={(index*2)+1} positions={[{ lat: latLong[0], lng: latLong[1] }, { lat: location.lat, lng: location.long }]} color="green" />
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* /* function drawHint(initialMap, location, randomOffset) {
|
||||
// create a circle overlay 10000km radius from location
|
||||
|
||||
let lat = location.lat;
|
||||
let long = location.long
|
||||
let center = fromLonLat([long, lat]);
|
||||
center = [center[0] + randomOffset[0], center[1] + randomOffset[1]];
|
||||
// move it a bit randomly so it's not exactly on the location but location is inside the circle
|
||||
const circle = new Feature(new Circle(center, hintMul * (gameOptions?.maxDist ?? 0)));
|
||||
vectorSource.current.addFeature(circle);
|
||||
|
||||
const circleLayer = new VectorLayer({
|
||||
source: new VectorSource({
|
||||
features: [circle]
|
||||
}),
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#f00',
|
||||
width: 2
|
||||
})
|
||||
})
|
||||
});
|
||||
initialMap.addLayer(circleLayer);
|
||||
} */}
|
||||
|
||||
{showHint && location && (
|
||||
<HintCircle location={location} gameOptions={gameOptions} round={round} />
|
||||
)}
|
||||
|
||||
<TileLayer
|
||||
key={isMobileOrTablet ? 'mobile' : 'desktop'}
|
||||
noWrap={true}
|
||||
url={`https://mt{s}.google.com/vt/lyrs=${options?.mapType ?? 'm'}&x={x}&y={y}&z={z}&hl=${text("lang")}&scale=2`}
|
||||
subdomains={['0', '1', '2', '3']}
|
||||
attribution='© <a href="https://maps.google.com">Google</a>'
|
||||
maxZoom={22}
|
||||
// tileSize={isMobileOrTablet ? 512 : 256}
|
||||
// zoomOffset={isMobileOrTablet ? -1 : 0}
|
||||
// detectRetina={true}
|
||||
/>
|
||||
|
||||
<audio ref={plopSound} src={asset("/plop.mp3")} preload="auto"></audio>
|
||||
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapComponent;
|
||||
150
components/ReportActionButtons.js
Normal file
150
components/ReportActionButtons.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import styles from '../styles/modDashboard.module.css';
|
||||
|
||||
/**
|
||||
* Reusable Report Action Buttons Component
|
||||
*
|
||||
* Renders moderation action buttons for reports.
|
||||
* Used in both the Reports tab and User Lookup page.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.targetUser - { id, username } of the reported user
|
||||
* @param {Array} props.reportIds - Array of report IDs to act upon
|
||||
* @param {Array} props.reports - Array of report objects (optional, for checking report types)
|
||||
* @param {Function} props.onAction - Callback when action button is clicked: (actionType, targetUser, reportIds, options) => void
|
||||
* @param {boolean} props.showForceNameChange - Whether to show the Force Name Change button (default: auto-detect from reports)
|
||||
* @param {boolean} props.compact - Whether to use compact layout (default: false)
|
||||
* @param {boolean} props.showResolve - Whether to show Resolve button (default: true)
|
||||
* @param {boolean} props.showIgnore - Whether to show Ignore button (default: true)
|
||||
* @param {boolean} props.showBan - Whether to show Ban buttons (default: true)
|
||||
*/
|
||||
export default function ReportActionButtons({
|
||||
targetUser,
|
||||
reportIds = [],
|
||||
reports = [],
|
||||
onAction,
|
||||
showForceNameChange,
|
||||
compact = false,
|
||||
showResolve = true,
|
||||
showIgnore = true,
|
||||
showBan = true,
|
||||
}) {
|
||||
// Auto-detect if we should show Force Name Change based on report reasons
|
||||
const hasInappropriateUsername = showForceNameChange !== undefined
|
||||
? showForceNameChange
|
||||
: reports.some(r => r.reason === 'inappropriate_username');
|
||||
|
||||
// Get only inappropriate username report IDs for force name change action
|
||||
const inappropriateUsernameReportIds = reports
|
||||
.filter(r => r.reason === 'inappropriate_username')
|
||||
.map(r => r._id);
|
||||
|
||||
const handleAction = (actionType, options = {}) => {
|
||||
if (onAction) {
|
||||
onAction(actionType, targetUser, options.reportIds || reportIds, options);
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={styles.reportActionsCompact}>
|
||||
{showResolve && (
|
||||
<button
|
||||
className={styles.resolveBtnCompact}
|
||||
onClick={() => handleAction('mark_resolved')}
|
||||
title="Mark Resolved (report was valid but no action needed)"
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
)}
|
||||
{showIgnore && (
|
||||
<button
|
||||
className={styles.ignoreBtnCompact}
|
||||
onClick={() => handleAction('ignore')}
|
||||
title="Ignore (spam/invalid report)"
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
)}
|
||||
{showBan && (
|
||||
<>
|
||||
<button
|
||||
className={styles.banBtnCompact}
|
||||
onClick={() => handleAction('ban_permanent')}
|
||||
title="Permanent Ban"
|
||||
>
|
||||
⛔
|
||||
</button>
|
||||
<button
|
||||
className={styles.tempBanBtnCompact}
|
||||
onClick={() => handleAction('ban_temporary')}
|
||||
title="Temporary Ban"
|
||||
>
|
||||
⏱️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasInappropriateUsername && (
|
||||
<button
|
||||
className={styles.forceNameBtnCompact}
|
||||
onClick={() => handleAction('force_name_change', {
|
||||
reportIds: inappropriateUsernameReportIds.length > 0 ? inappropriateUsernameReportIds : reportIds,
|
||||
hasInappropriateUsername: true
|
||||
})}
|
||||
title="Force Name Change"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.groupActions}>
|
||||
{showResolve && (
|
||||
<button
|
||||
className={styles.resolveBtn}
|
||||
onClick={() => handleAction('mark_resolved')}
|
||||
>
|
||||
✅ Resolve
|
||||
</button>
|
||||
)}
|
||||
{showIgnore && (
|
||||
<button
|
||||
className={styles.ignoreBtn}
|
||||
onClick={() => handleAction('ignore')}
|
||||
>
|
||||
🚫 Ignore
|
||||
</button>
|
||||
)}
|
||||
{showBan && (
|
||||
<>
|
||||
<button
|
||||
className={styles.banBtn}
|
||||
onClick={() => handleAction('ban_permanent')}
|
||||
>
|
||||
⛔ Ban
|
||||
</button>
|
||||
<button
|
||||
className={styles.tempBanBtn}
|
||||
onClick={() => handleAction('ban_temporary')}
|
||||
>
|
||||
⏱️ Temp Ban
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasInappropriateUsername && (
|
||||
<button
|
||||
className={styles.forceNameBtn}
|
||||
onClick={() => handleAction('force_name_change', {
|
||||
reportIds: inappropriateUsernameReportIds.length > 0 ? inappropriateUsernameReportIds : reportIds,
|
||||
hasInappropriateUsername: true
|
||||
})}
|
||||
>
|
||||
✏️ Force Name
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
851
components/XPGraph.js
Normal file
851
components/XPGraph.js
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import config from '@/clientConfig';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
TimeScale,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
TimeScale
|
||||
);
|
||||
|
||||
// Cache for user progression data to avoid refetching on tab switches
|
||||
const progressionCache = new Map();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export default function XPGraph({ session, mode = 'xp', isPublic = false, username = null }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [userStats, setUserStats] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState(mode === 'xp' ? 'xp' : 'elo'); // 'xp'/'rank' or 'elo'/'eloRank'
|
||||
const [dateFilter, setDateFilter] = useState('alltime'); // '7days', '30days', 'alltime', 'custom'
|
||||
const [customStartDate, setCustomStartDate] = useState('');
|
||||
const [customEndDate, setCustomEndDate] = useState('');
|
||||
const [chartData, setChartData] = useState(null);
|
||||
|
||||
const fetchUserProgression = async () => {
|
||||
// For public profiles, use username; for private, use session accountId
|
||||
const hasRequiredData = isPublic ? (username && (window.cConfig?.apiUrl || config()?.apiUrl)) : (session?.token?.accountId && (window.cConfig?.apiUrl || config()?.apiUrl));
|
||||
if (!hasRequiredData) return;
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = isPublic ? `public_${username}` : `private_${session.token.accountId}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = progressionCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
setUserStats(cached.data.progression);
|
||||
calculateGraphData(cached.data.progression);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestBody = isPublic
|
||||
? { username: username }
|
||||
: { userId: session.token.accountId };
|
||||
|
||||
const apiUrl = window.cConfig?.apiUrl || config()?.apiUrl;
|
||||
const response = await fetch(apiUrl + '/api/userProgression', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Cache the response
|
||||
progressionCache.set(cacheKey, {
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
setUserStats(data.progression);
|
||||
calculateGraphData(data.progression);
|
||||
} else {
|
||||
console.error('Failed to fetch user progression', response.status);
|
||||
const errorData = await response.text();
|
||||
console.error('Error details:', errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user progression:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateGraphData = (stats) => {
|
||||
const dataPoints = [];
|
||||
|
||||
// Filter stats based on date filter
|
||||
const now = new Date();
|
||||
|
||||
let filteredStats = stats.filter((stat) => {
|
||||
if (dateFilter === 'alltime') return true;
|
||||
|
||||
const statDate = new Date(stat.timestamp);
|
||||
|
||||
if (dateFilter === 'custom') {
|
||||
if (!customStartDate && !customEndDate) return true;
|
||||
|
||||
const startDate = customStartDate ? new Date(customStartDate) : new Date(0);
|
||||
const endDate = customEndDate ? new Date(customEndDate) : now;
|
||||
|
||||
return statDate >= startDate && statDate <= endDate;
|
||||
}
|
||||
|
||||
const daysDiff = Math.floor((now - statDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (dateFilter === '7days') return daysDiff <= 7;
|
||||
if (dateFilter === '30days') return daysDiff <= 30;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no entries in selected timeframe, use the most recent entry
|
||||
if (filteredStats.length === 0 && stats.length > 0) {
|
||||
filteredStats = [stats[stats.length - 1]];
|
||||
}
|
||||
|
||||
|
||||
filteredStats.forEach((stat, index) => {
|
||||
const date = new Date(stat.timestamp);
|
||||
|
||||
if (mode === 'xp') {
|
||||
if (viewMode === 'xp') {
|
||||
dataPoints.push({
|
||||
x: date,
|
||||
y: stat.totalXp,
|
||||
xpGain: stat.xpGain || 0,
|
||||
rank: stat.xpRank,
|
||||
rankGain: stat.rankImprovement || 0
|
||||
});
|
||||
} else {
|
||||
// XP Rank mode
|
||||
dataPoints.push({
|
||||
x: date,
|
||||
y: stat.xpRank,
|
||||
rankGain: stat.rankImprovement || 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// ELO mode
|
||||
if (viewMode === 'elo') {
|
||||
dataPoints.push({
|
||||
x: date,
|
||||
y: stat.elo,
|
||||
eloGain: stat.eloChange || 0,
|
||||
rank: stat.eloRank,
|
||||
rankGain: stat.eloChange ? (stat.eloChange > 0 ? Math.abs(stat.rankImprovement || 0) : -(Math.abs(stat.rankImprovement || 0))) : 0
|
||||
});
|
||||
} else {
|
||||
// ELO Rank mode
|
||||
dataPoints.push({
|
||||
x: date,
|
||||
y: stat.eloRank,
|
||||
rankGain: stat.eloChange ? (stat.eloChange > 0 ? Math.abs(stat.rankImprovement || 0) : -(Math.abs(stat.rankImprovement || 0))) : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Ensure we have at least 2 data points for the chart to display properly
|
||||
if (dataPoints.length === 1) {
|
||||
// If we only have 1 data point, duplicate it with a slightly different timestamp
|
||||
const singlePoint = dataPoints[0];
|
||||
const now = new Date();
|
||||
|
||||
// Add a second point at the current time with the same Y value
|
||||
dataPoints.push({
|
||||
x: now,
|
||||
y: singlePoint.y,
|
||||
// Copy over any additional properties
|
||||
...(mode === 'xp' ? {
|
||||
xpGain: 0,
|
||||
rank: singlePoint.rank,
|
||||
rankGain: 0
|
||||
} : {
|
||||
eloGain: 0,
|
||||
rank: singlePoint.rank,
|
||||
rankGain: 0
|
||||
})
|
||||
});
|
||||
|
||||
} else if (dataPoints.length === 0) {
|
||||
// If no data points, don't render the chart
|
||||
console.log('[XPGraph] No data points available');
|
||||
setChartData(null);
|
||||
return;
|
||||
} else if (dataPoints.length > 1) {
|
||||
// Only extend the graph to today's date if current date is within the selected range
|
||||
const lastPoint = dataPoints[dataPoints.length - 1];
|
||||
const now = new Date();
|
||||
const lastPointDate = new Date(lastPoint.x);
|
||||
|
||||
// Check if current date should be included based on date filter
|
||||
let shouldIncludeToday = false;
|
||||
|
||||
if (dateFilter === 'alltime') {
|
||||
shouldIncludeToday = true;
|
||||
} else if (dateFilter === 'custom') {
|
||||
if (!customStartDate && !customEndDate) {
|
||||
shouldIncludeToday = true;
|
||||
} else {
|
||||
const startDate = customStartDate ? new Date(customStartDate) : new Date(0);
|
||||
const endDate = customEndDate ? new Date(customEndDate) : now;
|
||||
shouldIncludeToday = now >= startDate && now <= endDate;
|
||||
}
|
||||
} else {
|
||||
// For 7days and 30days, today is always included
|
||||
shouldIncludeToday = true;
|
||||
}
|
||||
|
||||
// Only add today's point if it should be included and it's not already the last point
|
||||
const timeDiff = Math.abs(now.getTime() - lastPointDate.getTime());
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (shouldIncludeToday && timeDiff > oneDayInMs) {
|
||||
dataPoints.push({
|
||||
x: now,
|
||||
y: lastPoint.y,
|
||||
// Copy over properties with no change indicators
|
||||
...(mode === 'xp' ? {
|
||||
xpGain: 0,
|
||||
rank: lastPoint.rank,
|
||||
rankGain: 0
|
||||
} : {
|
||||
eloGain: 0,
|
||||
rank: lastPoint.rank,
|
||||
rankGain: 0
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate point radius for each data point based on whether the value actually changed
|
||||
const pointRadii = dataPoints.map((point, index) => {
|
||||
let hasChange = false;
|
||||
|
||||
// Check if this is the first or last point (always show these for context)
|
||||
if (index === 0 || index === dataPoints.length - 1) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Check if the Y value changed from the previous point
|
||||
const prevPoint = dataPoints[index - 1];
|
||||
if (prevPoint && point.y !== prevPoint.y) {
|
||||
hasChange = true;
|
||||
}
|
||||
|
||||
// Also check gain values as a fallback (for cases where Y value might be the same but there was activity)
|
||||
if (!hasChange) {
|
||||
if (mode === 'xp') {
|
||||
if (viewMode === 'xp') {
|
||||
hasChange = point.xpGain !== 0;
|
||||
} else {
|
||||
hasChange = point.rankGain !== 0;
|
||||
}
|
||||
} else {
|
||||
if (viewMode === 'elo') {
|
||||
hasChange = point.eloGain !== 0;
|
||||
} else {
|
||||
hasChange = point.rankGain !== 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasChange ? 4 : 0;
|
||||
});
|
||||
|
||||
// Calculate min/max for dynamic scaling
|
||||
const yValues = dataPoints.map(point => point.y);
|
||||
const minValue = Math.min(...yValues);
|
||||
const maxValue = Math.max(...yValues);
|
||||
|
||||
// Add some padding to the range (5% on each side)
|
||||
let range = maxValue - minValue;
|
||||
let suggestedMin, suggestedMax;
|
||||
|
||||
// If all values are the same (rank hasn't changed), create a small artificial range
|
||||
if (range === 0) {
|
||||
const baseValue = minValue;
|
||||
const artificialRange = Math.max(1, Math.abs(baseValue * 0.1)); // 10% of the value, minimum 1
|
||||
|
||||
suggestedMin = baseValue - artificialRange;
|
||||
suggestedMax = baseValue + artificialRange;
|
||||
|
||||
console.log('[XPGraph] All values are the same, using artificial range:', {
|
||||
baseValue,
|
||||
artificialRange,
|
||||
suggestedMin,
|
||||
suggestedMax
|
||||
});
|
||||
} else {
|
||||
const padding = range * 0.05;
|
||||
suggestedMin = minValue - padding;
|
||||
suggestedMax = maxValue + padding;
|
||||
}
|
||||
|
||||
const data = {
|
||||
datasets: [{
|
||||
label: mode === 'xp' ?
|
||||
(viewMode === 'xp' ? text('totalXP') : text('xpRank')) :
|
||||
(viewMode === 'elo' ? text('elo') : text('eloRank')),
|
||||
data: dataPoints,
|
||||
borderColor: (mode === 'xp' && viewMode === 'xp') || (mode === 'elo' && viewMode === 'elo') ? '#4CAF50' : '#2196F3',
|
||||
backgroundColor: (mode === 'xp' && viewMode === 'xp') || (mode === 'elo' && viewMode === 'elo') ? 'rgba(76, 175, 80, 0.1)' : 'rgba(33, 150, 243, 0.1)',
|
||||
fill: true,
|
||||
tension: 0,
|
||||
pointRadius: pointRadii,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: (mode === 'xp' && viewMode === 'xp') || (mode === 'elo' && viewMode === 'elo') ? '#4CAF50' : '#2196F3',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
suggestedMin,
|
||||
suggestedMax
|
||||
}]
|
||||
};
|
||||
|
||||
|
||||
setChartData(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (userStats.length > 0) {
|
||||
calculateGraphData(userStats);
|
||||
}
|
||||
}, [viewMode, dateFilter, userStats, customStartDate, customEndDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProgression();
|
||||
}, [session?.token?.accountId, username, isPublic]);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
return new Date(context[0].parsed.x).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
label: (context) => {
|
||||
if (mode === 'xp') {
|
||||
if (viewMode === 'xp') {
|
||||
const xpGain = context.raw.xpGain || 0;
|
||||
const tooltip = [`${text('totalXP')}: ${context.parsed.y.toLocaleString()}`];
|
||||
if (xpGain !== 0) {
|
||||
tooltip.push(`${text('xpGain')}: +${xpGain}`);
|
||||
}
|
||||
return tooltip;
|
||||
} else {
|
||||
const rankGain = context.raw.rankGain || 0;
|
||||
const tooltip = [`${text('xpRank')}: #${context.parsed.y}`];
|
||||
// if (rankGain !== 0) {
|
||||
// const rankText = rankGain > 0 ? `+${rankGain}` : `${rankGain}`;
|
||||
// tooltip.push(`${text('rankGain')}: ${rankText}`);
|
||||
// }
|
||||
return tooltip;
|
||||
}
|
||||
} else {
|
||||
// ELO mode
|
||||
if (viewMode === 'elo') {
|
||||
const eloGain = context.raw.eloGain || 0;
|
||||
const tooltip = [`${text('elo')}: ${context.parsed.y}`];
|
||||
if (eloGain !== 0) {
|
||||
const eloText = eloGain > 0 ? `+${eloGain}` : `${eloGain}`;
|
||||
tooltip.push(`${text('eloGain')}: ${eloText}`);
|
||||
}
|
||||
return tooltip;
|
||||
} else {
|
||||
const rankGain = context.raw.rankGain || 0;
|
||||
const tooltip = [`${text('eloRank')}: #${context.parsed.y}`];
|
||||
// if (rankGain !== 0) {
|
||||
// const rankText = rankGain > 0 ? `+${rankGain}` : `${rankGain}`;
|
||||
// tooltip.push(`${text('rankGain')}: ${rankText}`);
|
||||
// }
|
||||
return tooltip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'day',
|
||||
displayFormats: {
|
||||
hour: 'MMM dd',
|
||||
day: 'MMM dd',
|
||||
week: 'MMM dd',
|
||||
month: 'MMM yyyy'
|
||||
},
|
||||
tooltipFormat: 'MMM dd, yyyy HH:mm'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
maxTicksLimit: window.innerWidth < 768 ? 4 : 8,
|
||||
autoSkip: true,
|
||||
maxRotation: window.innerWidth < 768 ? 45 : 0,
|
||||
minRotation: window.innerWidth < 768 ? 45 : 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
min: chartData?.datasets[0]?.suggestedMin,
|
||||
max: chartData?.datasets[0]?.suggestedMax,
|
||||
reverse: (mode === 'xp' && viewMode === 'rank') || (mode === 'elo' && viewMode === 'eloRank'), // For rank, 1 should be at the top
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
callback: function(value) {
|
||||
if ((mode === 'xp' && viewMode === 'xp') || (mode === 'elo' && viewMode === 'elo')) {
|
||||
return Math.floor(value).toLocaleString();
|
||||
} else {
|
||||
return `#${Math.floor(value)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const graphStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: '20px',
|
||||
padding: '30px',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
marginTop: '20px'
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={graphStyle}>
|
||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid rgba(255,255,255,0.3)',
|
||||
borderTop: '3px solid #4CAF50',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 20px'
|
||||
}}></div>
|
||||
<p>{text('loadingGameHistory')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userStats.length === 0) {
|
||||
return (
|
||||
<div style={graphStyle}>
|
||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||
<h3>{text('noStatsAvailable')}</h3>
|
||||
{ !isPublic &&
|
||||
<p>{text('playGamesToSeeProgression')}</p> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getCurrentValue = () => {
|
||||
if (userStats.length === 0) return 0;
|
||||
const latest = userStats[userStats.length - 1];
|
||||
if (mode === 'xp') {
|
||||
return viewMode === 'xp' ? latest.totalXp : latest.xpRank;
|
||||
} else {
|
||||
return viewMode === 'elo' ? latest.elo : latest.eloRank;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentRank = () => {
|
||||
if (userStats.length === 0) return 1;
|
||||
const latest = userStats[userStats.length - 1];
|
||||
return mode === 'xp' ? latest.xpRank : latest.eloRank;
|
||||
};
|
||||
|
||||
const getTimeframeTitle = () => {
|
||||
const baseTitle = mode === 'xp' ?
|
||||
(viewMode === 'xp' ? text('xpOverTime') : text('rankOverTime')) :
|
||||
(viewMode === 'elo' ? text('eloOverTime') : text('eloRankOverTime'));
|
||||
|
||||
switch (dateFilter) {
|
||||
case '7days':
|
||||
return `${baseTitle} (7 Days)`;
|
||||
case '30days':
|
||||
return `${baseTitle} (30 Days)`;
|
||||
case 'custom':
|
||||
if (customStartDate && customEndDate) {
|
||||
return `${baseTitle} (${new Date(customStartDate).toLocaleDateString()} - ${new Date(customEndDate).toLocaleDateString()})`;
|
||||
} else if (customStartDate) {
|
||||
return `${baseTitle} (From ${new Date(customStartDate).toLocaleDateString()})`;
|
||||
} else if (customEndDate) {
|
||||
return `${baseTitle} (Until ${new Date(customEndDate).toLocaleDateString()})`;
|
||||
} else {
|
||||
return `${baseTitle} (Custom)`;
|
||||
}
|
||||
case 'alltime':
|
||||
default:
|
||||
return `${baseTitle} (All Time)`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={graphStyle} className="xp-graph-container">
|
||||
<div className="xp-graph-header">
|
||||
<h3 className="xp-graph-title">
|
||||
{getTimeframeTitle()}
|
||||
</h3>
|
||||
|
||||
<div className="xp-graph-controls">
|
||||
<div className="date-filter-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${dateFilter === '7days' ? 'active' : ''}`}
|
||||
onClick={() => setDateFilter('7days')}
|
||||
>
|
||||
7D
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${dateFilter === '30days' ? 'active' : ''}`}
|
||||
onClick={() => setDateFilter('30days')}
|
||||
>
|
||||
30D
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${dateFilter === 'alltime' ? 'active' : ''}`}
|
||||
onClick={() => setDateFilter('alltime')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${dateFilter === 'custom' ? 'active' : ''}`}
|
||||
onClick={() => setDateFilter('custom')}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dateFilter === 'custom' && (
|
||||
<div className="custom-date-picker">
|
||||
<input
|
||||
type="date"
|
||||
value={customStartDate}
|
||||
onChange={(e) => setCustomStartDate(e.target.value)}
|
||||
className="date-input"
|
||||
placeholder="Start Date"
|
||||
/>
|
||||
<span className="date-separator">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={customEndDate}
|
||||
onChange={(e) => setCustomEndDate(e.target.value)}
|
||||
className="date-input"
|
||||
placeholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="view-mode-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${mode === 'xp' ? (viewMode === 'xp' ? 'active' : '') : (viewMode === 'elo' ? 'active' : '')}`}
|
||||
onClick={() => setViewMode(mode === 'xp' ? 'xp' : 'elo')}
|
||||
>
|
||||
{mode === 'xp' ? text('xp') : text('elo')}
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${mode === 'xp' ? (viewMode === 'rank' ? 'active' : '') : (viewMode === 'eloRank' ? 'active' : '')}`}
|
||||
onClick={() => setViewMode(mode === 'xp' ? 'rank' : 'eloRank')}
|
||||
>
|
||||
{text('rank')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-container">
|
||||
{chartData && <Line data={chartData} options={chartOptions} />}
|
||||
</div>
|
||||
|
||||
<div className="chart-stats">
|
||||
<span className="data-points">{text('dataPoints', { count: chartData?.datasets[0]?.data?.length || 0 })}</span>
|
||||
{/* <span className="current-value">
|
||||
{mode === 'xp' ?
|
||||
(viewMode === 'xp'
|
||||
? `${text('currentXP')}: ${getCurrentValue().toLocaleString()}`
|
||||
: `${text('currentRank')}: #${getCurrentValue()}`
|
||||
) :
|
||||
(viewMode === 'elo'
|
||||
? `${text('currentElo')}: ${getCurrentValue()}`
|
||||
: `${text('currentRank')}: #${getCurrentValue()}`
|
||||
)
|
||||
}
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.xp-graph-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.xp-graph-title {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.xp-graph-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-filter-toggle,
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 25px;
|
||||
padding: 2px;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 10px 8px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: #4CAF50;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.toggle-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.custom-date-picker {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 15px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-points,
|
||||
.current-value {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Desktop styles */
|
||||
@media (min-width: 768px) {
|
||||
.xp-graph-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.xp-graph-title {
|
||||
font-size: 24px;
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xp-graph-controls {
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
width: auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.date-filter-toggle,
|
||||
.view-mode-toggle {
|
||||
width: fit-content;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 10px 20px;
|
||||
flex: none;
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.custom-date-picker {
|
||||
flex-wrap: nowrap;
|
||||
width: fit-content;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
min-width: 120px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large desktop styles */
|
||||
@media (min-width: 1024px) {
|
||||
.xp-graph-controls {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.xp-graph-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 8px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.custom-date-picker {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
components/accountModal.js
Normal file
345
components/accountModal.js
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { Modal } from "react-responsive-modal";
|
||||
import { useEffect, useState } from "react";
|
||||
import AccountView from "./accountView";
|
||||
import EloView from "./eloView";
|
||||
import GameHistory from "./gameHistory";
|
||||
import HistoricalGameView from "./historicalGameView";
|
||||
import ModerationView from "./moderationView";
|
||||
import { getLeague, leagues } from "./utils/leagues";
|
||||
import { signOut } from "@/components/auth/auth";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import FriendsModal from "@/components/friendModal";
|
||||
import { FaLink, FaCheck } from "react-icons/fa";
|
||||
import CountryFlag from './utils/countryFlag';
|
||||
import { navigate } from '@/lib/basePath';
|
||||
|
||||
export default function AccountModal({ session, setSession, shown, setAccountModalOpen, eloData, inCrazyGames, friendModal, accountModalPage, setAccountModalPage, ws, sendInvite, canSendInvite, options }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [accountData, setAccountData] = useState({});
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [sentRequests, setSentRequests] = useState([]);
|
||||
const [receivedRequests, setReceivedRequests] = useState([]);
|
||||
const [selectedGame, setSelectedGame] = useState(null);
|
||||
const [showingGameAnalysis, setShowingGameAnalysis] = useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const badgeStyle = {
|
||||
marginLeft: '15px',
|
||||
color: 'black',
|
||||
fontSize: '0.7rem',
|
||||
background: 'linear-gradient(135deg, #ffd700, #ffed4e)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '15px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: 'none'
|
||||
};
|
||||
|
||||
// Detect touch devices (mobile and iPad)
|
||||
useEffect(() => {
|
||||
const checkTouchDevice = () => {
|
||||
const hasCoarsePointer = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
|
||||
const isTouchCapable = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
setIsTouchDevice(hasCoarsePointer || isTouchCapable);
|
||||
};
|
||||
|
||||
checkTouchDevice();
|
||||
const mediaQuery = window.matchMedia('(pointer: coarse)');
|
||||
mediaQuery.addListener(checkTouchDevice);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeListener(checkTouchDevice);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Use session data for instant display, then fetch fresh data
|
||||
useEffect(() => {
|
||||
if (shown && session?.token) {
|
||||
// Immediately show session data (may be stale but instant)
|
||||
setAccountData({
|
||||
username: session.token.username,
|
||||
totalXp: session.token.totalXp || 0,
|
||||
createdAt: session.token.createdAt,
|
||||
gamesLen: session.token.gamesLen || 0,
|
||||
lastLogin: session.token.lastLogin,
|
||||
canChangeUsername: session.token.canChangeUsername,
|
||||
daysUntilNameChange: session.token.daysUntilNameChange || 0,
|
||||
recentChange: session.token.recentChange || false,
|
||||
countryCode: session.token.countryCode || null,
|
||||
});
|
||||
|
||||
// Fetch fresh data to update stale values (totalXp, gamesLen, etc.)
|
||||
fetch(window.cConfig.apiUrl + '/api/publicAccount', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: session.token.accountId }),
|
||||
})
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data) setAccountData(data);
|
||||
})
|
||||
.catch(() => {}); // Keep session data on error
|
||||
} else if (!shown) {
|
||||
// Reset game analysis state when modal is closed
|
||||
setShowingGameAnalysis(false);
|
||||
setSelectedGame(null);
|
||||
}
|
||||
}, [shown, session?.token?.accountId]);
|
||||
|
||||
// Reset game analysis when switching away from history tab
|
||||
useEffect(() => {
|
||||
if (accountModalPage !== "history") {
|
||||
setShowingGameAnalysis(false);
|
||||
setSelectedGame(null);
|
||||
}
|
||||
}, [accountModalPage]);
|
||||
|
||||
if (!eloData) return null;
|
||||
|
||||
const navigationItems = [
|
||||
{ key: "profile", label: text("profile"), icon: "👤" },
|
||||
{ key: "history", label: text("history"), icon: "📜" },
|
||||
{ key: "elo", label: text("ELO"), icon: "🏆" },
|
||||
{ key: "list", label: text("friendsText"), icon: "👥" },
|
||||
{ key: "moderation", label: text("moderationTab"), icon: "⚖️" }
|
||||
];
|
||||
|
||||
|
||||
const renderContent = () => {
|
||||
switch (accountModalPage) {
|
||||
case "profile":
|
||||
return (
|
||||
<div className="profile-content">
|
||||
<AccountView
|
||||
accountData={accountData}
|
||||
setAccountData={setAccountData}
|
||||
supporter={session?.token?.supporter}
|
||||
eloData={eloData}
|
||||
session={session}
|
||||
setSession={setSession}
|
||||
ws={ws}
|
||||
/>
|
||||
|
||||
{!inCrazyGames && (
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="logout-button"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
{text("logOut")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "history":
|
||||
return (
|
||||
<GameHistory
|
||||
session={session}
|
||||
onGameClick={(game) => {
|
||||
setSelectedGame(game);
|
||||
setShowingGameAnalysis(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "elo":
|
||||
return <EloView eloData={eloData} session={session} />;
|
||||
case "moderation":
|
||||
return <ModerationView session={session} />;
|
||||
case "list":
|
||||
default:
|
||||
return (
|
||||
<FriendsModal
|
||||
ws={ws}
|
||||
canSendInvite={canSendInvite}
|
||||
sendInvite={sendInvite}
|
||||
accountModalPage="consolidated" // Always show consolidated view
|
||||
setAccountModalPage={setAccountModalPage}
|
||||
friends={friends}
|
||||
shown={true}
|
||||
setFriends={setFriends}
|
||||
sentRequests={sentRequests}
|
||||
setSentRequests={setSentRequests}
|
||||
receivedRequests={receivedRequests}
|
||||
setReceivedRequests={setReceivedRequests}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Game Analysis - Render outside modal when active */}
|
||||
{accountModalPage === "history" && showingGameAnalysis && selectedGame && (
|
||||
<HistoricalGameView
|
||||
game={selectedGame}
|
||||
session={session}
|
||||
options={options}
|
||||
onBack={() => {
|
||||
setShowingGameAnalysis(false);
|
||||
setSelectedGame(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Modal */}
|
||||
<Modal
|
||||
styles={{
|
||||
modal: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
maxWidth: 'none',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden', // Prevent modal from scrolling
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'stretch'
|
||||
},
|
||||
modalContainer: {
|
||||
height: 'auto',
|
||||
},
|
||||
overlay: {
|
||||
// Disable library's overlay scroll behavior
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
classNames={{ modal: "account-modal", modalContainer: "account-modal-p-container" }}
|
||||
open={shown}
|
||||
center
|
||||
onClose={() => setAccountModalOpen(false)}
|
||||
showCloseIcon={false}
|
||||
animationDuration={300}
|
||||
blockScroll={false} // Critical: prevent library from blocking body scroll
|
||||
closeOnOverlayClick={true}
|
||||
>
|
||||
<div className="account-modal-container">
|
||||
{/* Background with overlay */}
|
||||
<div className="account-modal-background"></div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="account-modal-content" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header with prominent close button */}
|
||||
<div className="account-modal-header" style={{
|
||||
// Make header more compact on touch devices
|
||||
padding: isTouchDevice ? '10px 20px' : undefined,
|
||||
minHeight: isTouchDevice ? '50px' : undefined
|
||||
}}>
|
||||
<h1 className="account-modal-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{accountData?.username || text("account")}
|
||||
{accountData?.countryCode && <CountryFlag countryCode={accountData.countryCode} style={{ fontSize: '0.8em' }} />}
|
||||
{accountData?.username && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const profileUrl = `${window.location.origin}${navigate('/user')}?u=${encodeURIComponent(accountData.username)}`;
|
||||
navigator.clipboard.writeText(profileUrl).then(() => {
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
});
|
||||
}}
|
||||
title={text("copyProfileLink") || "Copy profile link"}
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
cursor: 'pointer',
|
||||
color: copiedLink ? '#4ade80' : 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.8rem',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copiedLink) e.target.style.color = '#fff';
|
||||
e.target.style.background = 'rgba(255,255,255,0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copiedLink) e.target.style.color = 'rgba(255,255,255,0.7)';
|
||||
e.target.style.background = 'rgba(255,255,255,0.1)';
|
||||
}}
|
||||
>
|
||||
{copiedLink ? <FaCheck /> : <FaLink />}
|
||||
</button>
|
||||
)}
|
||||
{accountData?.supporter && <span style={badgeStyle}>{text("supporter")}</span>}
|
||||
</h1>
|
||||
|
||||
|
||||
<button
|
||||
className="account-modal-close"
|
||||
onClick={() => setAccountModalOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<span className="close-icon">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="account-modal-nav-container" style={{
|
||||
// Make navigation more compact on touch devices
|
||||
padding: isTouchDevice ? '5px 0' : undefined
|
||||
}}>
|
||||
<nav className="account-modal-nav">
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`account-nav-item ${accountModalPage === item.key ? 'active' : ''}`}
|
||||
onClick={() => setAccountModalPage(item.key)}
|
||||
style={{
|
||||
// Make nav buttons more compact on touch devices
|
||||
padding: isTouchDevice ? '8px 12px' : undefined,
|
||||
fontSize: isTouchDevice ? '0.9rem' : undefined
|
||||
}}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Single scroll container for iOS */}
|
||||
<div className="account-modal-body" style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y pinch-zoom',
|
||||
overscrollBehavior: 'contain',
|
||||
scrollbarGutter: 'stable',
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
boxSizing: 'border-box'
|
||||
// Removed: transform, willChange - these cause flickering with backdrop-filter
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
overflowY: 'visible',
|
||||
overflowX: 'hidden',
|
||||
// Only apply large minHeight for pages that can have lots of content (history, profile, elo, moderation)
|
||||
// For friends tabs (list, add, sent, received), use natural height to prevent unnecessary scroll space
|
||||
minHeight: (accountModalPage === 'history' || accountModalPage === 'profile' || accountModalPage === 'elo' || accountModalPage === 'moderation')
|
||||
? 'calc(100vh + 1px)'
|
||||
: 'calc(100% + 1px)', // Minimal height for iOS scroll to work
|
||||
paddingBottom: '40px'
|
||||
}}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
308
components/accountView.js
Normal file
308
components/accountView.js
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import msToTime from "./msToTime";
|
||||
import { useTranslation } from '@/components/useTranslations'
|
||||
import { getLeague, leagues } from "./utils/leagues";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FaClock, FaGamepad, FaStar, FaEye, FaUsers } from "react-icons/fa6";
|
||||
import XPGraph from "./XPGraph";
|
||||
import PendingNameChangeModal from "./pendingNameChangeModal";
|
||||
import CountrySelectorModal from "./countrySelectorModal";
|
||||
import CountryFlag from "./utils/countryFlag";
|
||||
|
||||
export default function AccountView({ accountData, setAccountData, supporter, eloData, session, setSession, isPublic = false, username = null, viewingPublicProfile = false, ws = null }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [showForcedNameChangeModal, setShowForcedNameChangeModal] = useState(false);
|
||||
const [showCountrySelector, setShowCountrySelector] = useState(false);
|
||||
const [currentCountry, setCurrentCountry] = useState(null);
|
||||
|
||||
// Check if user is forced to change their name
|
||||
const isForcedNameChange = !isPublic && session?.token?.pendingNameChange;
|
||||
|
||||
// Load current country from accountData (fetched fresh when modal opens)
|
||||
useEffect(() => {
|
||||
if (!isPublic && accountData?.countryCode !== undefined) {
|
||||
setCurrentCountry(accountData.countryCode || null);
|
||||
}
|
||||
}, [isPublic, accountData?.countryCode]);
|
||||
|
||||
const changeName = async () => {
|
||||
// If forced to change name, open the proper modal instead of prompt
|
||||
if (isForcedNameChange) {
|
||||
setShowForcedNameChangeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.settingName) return;
|
||||
const secret = session?.token?.secret;
|
||||
if (!secret) return alert("An error occurred (log out and log back in)");
|
||||
// make sure name change is not in progress
|
||||
|
||||
try {
|
||||
const response1 = await fetch(window.cConfig.apiUrl + '/api/checkIfNameChangeProgress', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: secret })
|
||||
});
|
||||
|
||||
// get the json
|
||||
const data1 = await response1.json();
|
||||
if (data1.name) {
|
||||
return alert(text("nameChangeInProgress", { name: data1.name }));
|
||||
}
|
||||
} catch (error) {
|
||||
return alert('An error occurred');
|
||||
}
|
||||
|
||||
const username = prompt(text("enterNewName"));
|
||||
|
||||
window.settingName = true;
|
||||
const response = await fetch(window.cConfig.apiUrl + '/api/setName', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, token: secret })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.settingName = false;
|
||||
alert(text("nameChanged"));
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
window.settingName = false;
|
||||
try {
|
||||
const data = await response.json();
|
||||
alert(data.message || 'An error occurred');
|
||||
|
||||
} catch (error) {
|
||||
alert('An error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const containerStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flexStart',
|
||||
textAlign: "left",
|
||||
color: '#fff',
|
||||
fontFamily: '"Lexend", sans-serif',
|
||||
paddingBottom: '20px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '10px',
|
||||
gap: "20px"
|
||||
};
|
||||
|
||||
const profileCardStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '20px',
|
||||
padding: '30px',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '2px 2px 4px rgba(0,0,0,0.2)',
|
||||
marginBottom: '20px'
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
fontSize: '20px',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
marginRight: '12px',
|
||||
fontSize: '20px',
|
||||
width: '24px'
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
marginTop: '20px',
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
borderRadius: '25px',
|
||||
background: 'linear-gradient(135deg, #28a745, #20c997)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 4px 15px rgba(40, 167, 69, 0.3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'block'
|
||||
};
|
||||
|
||||
const warningStyle = {
|
||||
...textStyle,
|
||||
color: '#ffc107',
|
||||
background: 'rgba(255, 193, 7, 0.1)',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(255, 193, 7, 0.3)'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={profileCardStyle}>
|
||||
<div style={textStyle}>
|
||||
<FaClock style={iconStyle} />
|
||||
{text("joined", { t: msToTime(Date.now() - new Date(accountData.createdAt).getTime()) })}
|
||||
</div>
|
||||
|
||||
{accountData.lastLogin && viewingPublicProfile && false && (
|
||||
<div style={textStyle}>
|
||||
<FaEye style={iconStyle} />
|
||||
{text("lastSeen")}: {msToTime(Date.now() - new Date(accountData.lastLogin).getTime())} {text("ago")}
|
||||
</div>
|
||||
)}
|
||||
<div style={textStyle}>
|
||||
<FaStar style={{ ...iconStyle }} />
|
||||
{accountData.totalXp} XP
|
||||
</div>
|
||||
|
||||
<div style={textStyle}>
|
||||
<FaGamepad style={iconStyle} />
|
||||
{text("gamesPlayed", { games: accountData.gamesLen || accountData.gamesPlayed || 0 })}
|
||||
</div>
|
||||
|
||||
{viewingPublicProfile && accountData.profileViews !== undefined && (
|
||||
<div style={textStyle}>
|
||||
<FaUsers style={iconStyle} />
|
||||
{text("profileViews") || "Profile Views"}: {accountData.profileViews.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* change name button - hidden in public view */}
|
||||
{!isPublic && (
|
||||
<>
|
||||
{isForcedNameChange ? (
|
||||
// Forced name change - always show button, ignore cooldowns
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: 'linear-gradient(135deg, #f0883e, #d29922)',
|
||||
boxShadow: '0 4px 15px rgba(240, 136, 62, 0.3)',
|
||||
}}
|
||||
onClick={changeName}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
e.target.style.boxShadow = '0 6px 20px rgba(240, 136, 62, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = '0 4px 15px rgba(240, 136, 62, 0.3)';
|
||||
}}
|
||||
>
|
||||
⚠️ {text("changeName")} ({text("required") || "Required"})
|
||||
</button>
|
||||
) : accountData.canChangeUsername ? (
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={changeName}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
e.target.style.boxShadow = '0 6px 20px rgba(40, 167, 69, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = '0 4px 15px rgba(40, 167, 69, 0.3)';
|
||||
}}
|
||||
>
|
||||
{text("changeName")}
|
||||
</button>
|
||||
) : accountData.recentChange ? (
|
||||
<div style={warningStyle}>
|
||||
<i className="fas fa-exclamation-triangle" style={iconStyle}></i>
|
||||
{text("recentChange")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isForcedNameChange && accountData.daysUntilNameChange > 0 && (
|
||||
<div style={warningStyle}>
|
||||
<i className="fas fa-exclamation-triangle" style={iconStyle}></i>
|
||||
{text("nameChangeCooldown", { days: accountData.daysUntilNameChange })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Change country flag button - hidden in public view */}
|
||||
{!isPublic && (
|
||||
<button
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: 'linear-gradient(135deg, #2196F3, #1976D2)',
|
||||
boxShadow: '0 4px 15px rgba(33, 150, 243, 0.3)',
|
||||
}}
|
||||
onClick={() => setShowCountrySelector(true)}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'translateY(-2px)';
|
||||
e.target.style.boxShadow = '0 6px 20px rgba(33, 150, 243, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = '0 4px 15px rgba(33, 150, 243, 0.3)';
|
||||
}}
|
||||
>
|
||||
{currentCountry
|
||||
? <><CountryFlag countryCode={currentCountry} size={1.2} style={{ marginRight: '8px' }} />{text("changeFlag") || "Change Flag"}</>
|
||||
: `🌍 ${text("setFlag") || "Set Flag"}`
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<XPGraph session={session} isPublic={isPublic} username={username} />
|
||||
|
||||
{/* Forced Name Change Modal - use portal to escape parent container's backdrop-filter */}
|
||||
{showForcedNameChangeModal && typeof document !== 'undefined' && createPortal(
|
||||
<PendingNameChangeModal
|
||||
session={session}
|
||||
isOpen={showForcedNameChangeModal}
|
||||
onClose={() => setShowForcedNameChangeModal(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Country Selector Modal */}
|
||||
{showCountrySelector && (
|
||||
<CountrySelectorModal
|
||||
shown={showCountrySelector}
|
||||
onClose={() => setShowCountrySelector(false)}
|
||||
currentCountry={currentCountry}
|
||||
onSelect={(newCountry) => {
|
||||
// Update local state
|
||||
setCurrentCountry(newCountry);
|
||||
// Update accountData for immediate UI update
|
||||
if (setAccountData) {
|
||||
setAccountData(prev => ({ ...prev, countryCode: newCountry }));
|
||||
}
|
||||
// Update session so accountBtn and other components reflect the change
|
||||
if (setSession) {
|
||||
setSession(prev => ({
|
||||
...prev,
|
||||
token: { ...prev?.token, countryCode: newCountry }
|
||||
}));
|
||||
}
|
||||
}}
|
||||
session={session}
|
||||
ws={ws}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
components/auth/auth.js
Normal file
199
components/auth/auth.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { inIframe } from "../utils/inIframe";
|
||||
import { toast } from "react-toastify";
|
||||
import retryManager from "../utils/retryFetch";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// secret: userDb.secret, username: userDb.username, email: userDb.email, staff: userDb.staff, canMakeClues: userDb.canMakeClues, supporter: userDb.supporter
|
||||
let session = false;
|
||||
// null = not logged in
|
||||
// false = session loading/fetching
|
||||
|
||||
// Listeners for session changes
|
||||
const sessionListeners = new Set();
|
||||
function notifySessionChange() {
|
||||
sessionListeners.forEach(listener => listener(session));
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
window.localStorage.removeItem("wg_secret");
|
||||
session = null;
|
||||
notifySessionChange();
|
||||
if(window.dontReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove all cookies
|
||||
console.log("Removing cookies");
|
||||
(function () {
|
||||
var cookies = document.cookie.split("; ");
|
||||
for (var c = 0; c < cookies.length; c++) {
|
||||
var d = window.location.hostname.split(".");
|
||||
while (d.length > 0) {
|
||||
var cookieBase = encodeURIComponent(cookies[c].split(";")[0].split("=")[0]) + '=; expires=Thu, 01-Jan-1970 00:00:01 GMT; domain=' + d.join('.') + ' ;path=';
|
||||
var p = location.pathname.split('/');
|
||||
document.cookie = cookieBase + '/';
|
||||
while (p.length > 0) {
|
||||
document.cookie = cookieBase + p.join('/');
|
||||
console.log(cookieBase + p.join('/'));
|
||||
p.pop();
|
||||
};
|
||||
d.shift();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function signIn() {
|
||||
console.log("Signing in");
|
||||
|
||||
|
||||
if(inIframe() && !process.env.NEXT_PUBLIC_GAMEDISTRIBUTION) {
|
||||
console.log("In iframe");
|
||||
// open site in new window
|
||||
const url = window.location.href;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
if(!process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
|
||||
toast.error("Google client ID not set");
|
||||
return;
|
||||
}
|
||||
|
||||
window.login();
|
||||
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
// sessionState is only used to trigger re-renders when session changes
|
||||
const [, setSessionState] = useState(session);
|
||||
|
||||
// Subscribe to session changes
|
||||
useEffect(() => {
|
||||
const listener = (newSession) => {
|
||||
setSessionState(newSession);
|
||||
};
|
||||
sessionListeners.add(listener);
|
||||
return () => sessionListeners.delete(listener);
|
||||
}, []);
|
||||
|
||||
if(typeof window === "undefined") {
|
||||
return {
|
||||
data: false
|
||||
}
|
||||
}
|
||||
|
||||
// check if crazygames
|
||||
if(window.location.hostname.includes("crazygames")) {
|
||||
|
||||
if(window.verifyPayload && JSON.parse(window.verifyPayload).secret === "not_logged_in") {
|
||||
// not loading
|
||||
return {
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(session === false && !window.fetchingSession && window.cConfig?.apiUrl) {
|
||||
let secret = null;
|
||||
try {
|
||||
|
||||
secret = window.localStorage.getItem("wg_secret");
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
if(secret) {
|
||||
|
||||
window.fetchingSession = true;
|
||||
|
||||
const authStartTime = performance.now();
|
||||
console.log(`[Auth] Starting authentication with retry mechanism (5s timeout, unlimited retries)`);
|
||||
|
||||
retryManager.fetchWithRetry(
|
||||
window.cConfig?.apiUrl + "/api/googleAuth",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ secret }),
|
||||
},
|
||||
'googleAuth',
|
||||
{
|
||||
timeout: 5000, // 5 second timeout
|
||||
maxRetries: Infinity, // Keep retrying until success
|
||||
baseDelay: 1000, // Start with 1 second delay
|
||||
maxDelay: 10000 // Cap delay at 10 seconds
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
window.fetchingSession = false;
|
||||
const authDuration = (performance.now() - authStartTime).toFixed(0);
|
||||
console.log(`[Auth] Authentication successful (took ${authDuration}ms)`);
|
||||
|
||||
if (data.error) {
|
||||
console.error(`[Auth] Server error:`, data.error);
|
||||
session = null;
|
||||
notifySessionChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.secret) {
|
||||
window.localStorage.setItem("wg_secret", data.secret);
|
||||
session = {token: data};
|
||||
console.log(`[Auth] Session established for user:`, data.username);
|
||||
notifySessionChange();
|
||||
} else {
|
||||
console.log(`[Auth] No session data received, user not logged in`);
|
||||
session = null;
|
||||
notifySessionChange();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
window.fetchingSession = false;
|
||||
const authDuration = (performance.now() - authStartTime).toFixed(0);
|
||||
console.error(`[Auth] Authentication failed (took ${authDuration}ms):`, e.message);
|
||||
|
||||
// Clear potentially corrupted session data
|
||||
try {
|
||||
window.localStorage.removeItem("wg_secret");
|
||||
} catch (err) {
|
||||
console.warn(`[Auth] Could not clear localStorage:`, err);
|
||||
}
|
||||
|
||||
session = null;
|
||||
notifySessionChange();
|
||||
});
|
||||
} else {
|
||||
session = null;
|
||||
notifySessionChange();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
data: session
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeaders() {
|
||||
let secret = null;
|
||||
if(session && session?.token?.secret) {
|
||||
secret = session.secret;
|
||||
} else {
|
||||
try {
|
||||
secret = window.localStorage.getItem("wg_secret");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
if(!secret) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
Authorization: "Bearer "+secret
|
||||
}
|
||||
}
|
||||
12
components/auth/serverAuth.js
Normal file
12
components/auth/serverAuth.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function getServerSecret(req) {
|
||||
|
||||
// its in headers Bearer token
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
225
components/bannerAdAdinplay.js
Normal file
225
components/bannerAdAdinplay.js
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import useWindowDimensions from "./useWindowDimensions";
|
||||
import sendEvent from "./utils/sendEvent";
|
||||
import NextImage from "next/image";
|
||||
import { asset } from '@/lib/basePath';
|
||||
|
||||
const AD_REFRESH_MS = 30000; // refresh ad every 60 seconds
|
||||
|
||||
function findAdType(screenW, screenH, types, vertThresh) {
|
||||
let type = 0;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
if (types[i][0] <= screenW * 0.9 && types[i][1] <= screenH * vertThresh) {
|
||||
type = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (types[type][0] > screenW || types[type][1] > screenH * vertThresh)
|
||||
return -1;
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export default function Ad({
|
||||
types,
|
||||
centerOnOverflow,
|
||||
inCrazyGames,
|
||||
vertThresh = 0.3,
|
||||
screenW,
|
||||
screenH,
|
||||
showAdvertisementText = true,
|
||||
}) {
|
||||
const [type, setType] = useState(
|
||||
findAdType(screenW, screenH, types, vertThresh)
|
||||
);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const adDivRef = useRef(null);
|
||||
const lastRefresh = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hostname === "localhost") setIsClient("debug");
|
||||
else setIsClient(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setType(findAdType(screenW, screenH, types, vertThresh));
|
||||
}, [screenW, screenH, JSON.stringify(types), vertThresh]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRefresh.current = 0;
|
||||
}, [type]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
const windowAny = window;
|
||||
|
||||
const displayNewAd = () => {
|
||||
if(isClient === "debug" || !isClient) return;
|
||||
console.log("Displaying new ad", type, isClient);
|
||||
|
||||
if (type === -1) return;
|
||||
setTimeout(() => {
|
||||
const isAdDivVisible =
|
||||
adDivRef.current &&
|
||||
adDivRef.current.getBoundingClientRect().top < window.innerHeight &&
|
||||
adDivRef.current.getBoundingClientRect().bottom > 0;
|
||||
if (
|
||||
(inCrazyGames || ( windowAny.aiptag && windowAny.aiptag.cmd && windowAny.aiptag.cmd.display)) &&
|
||||
isAdDivVisible &&
|
||||
Date.now() - lastRefresh.current > (AD_REFRESH_MS*(inCrazyGames?2:1))
|
||||
) {
|
||||
if(!inCrazyGames) {
|
||||
try {
|
||||
if (windowAny.aipDisplayTag && windowAny.aipDisplayTag.clear) {
|
||||
for (const type of types) {
|
||||
windowAny.aipDisplayTag.clear(
|
||||
`worldguessr-com_${type[0]}x${type[1]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert("error clearing ad");
|
||||
}
|
||||
} else {
|
||||
// clear everything inside the div
|
||||
document.getElementById(`worldguessr-com_${types[type][0]}x${types[type][1]}`).innerHTML = "";
|
||||
}
|
||||
|
||||
lastRefresh.current = Date.now();
|
||||
sendEvent(`ad_request_${types[type][0]}x${types[type][1]}`);
|
||||
setTimeout(() => {
|
||||
if(!inCrazyGames) {
|
||||
windowAny.aiptag.cmd.display.push(function () {
|
||||
windowAny.aipDisplayTag.display(
|
||||
`worldguessr-com_${types[type][0]}x${types[type][1]}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// await is not mandatory when requesting banners, but it will allow you to catch errors
|
||||
|
||||
// check if
|
||||
function requestCrazyGamesBanner() {
|
||||
try {
|
||||
|
||||
window.CrazyGames.SDK.banner.requestBanner({
|
||||
id: `worldguessr-com_${types[type][0]}x${types[type][1]}`,
|
||||
width: types[type][0],
|
||||
height: types[type][1],
|
||||
}).then((e) => {
|
||||
console.log("Banner request success", e);
|
||||
// clear everything inside the div
|
||||
// document.getElementById(`worldguessr-com_${types[type][0]}x${types[type][1]}`).innerHTML = "";
|
||||
|
||||
}).catch((e) => {
|
||||
console.log("Banner request error", e);
|
||||
document.getElementById(`worldguessr-com_${types[type][0]}x${types[type][1]}`).innerHTML = `
|
||||
<img src='${asset(`/ad_${types[type][0]}x${types[type][1]}.png`)}' width='${types[type][0]}' height='${types[type][1]}' alt='Advertisement' />`;
|
||||
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.log("Banner request error", e);
|
||||
if(e.code === "sdkNotInitialized") {
|
||||
console.log("SDK not initialized, retrying in 1s");
|
||||
setTimeout(() => {
|
||||
requestCrazyGamesBanner();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestCrazyGamesBanner();
|
||||
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
let timerId = setInterval(() => {
|
||||
displayNewAd();
|
||||
}, 1000);
|
||||
displayNewAd();
|
||||
return () => clearInterval(timerId);
|
||||
}, [type, inCrazyGames, isClient]);
|
||||
|
||||
if (type === -1) return null;
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{showAdvertisementText && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-24px",
|
||||
left: "0px",
|
||||
padding: "0 5px",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Advertisement
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
height: types[type][1],
|
||||
width: types[type][0],
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
id={`worldguessr-com_${types[type][0]}x${types[type][1]}`}
|
||||
ref={adDivRef}
|
||||
>
|
||||
{isClient === "debug" ? (
|
||||
<>
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
{/* <NextImage.default
|
||||
alt="Advertisement"
|
||||
src={`./ad_${types[type][0]}x${types[type][1]}.png`}
|
||||
width={types[type][0]}
|
||||
height={types[type][1]}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "10px",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
color: "white",
|
||||
zIndex: 2,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
>
|
||||
<h3>Banner Ad Here (Adinplay)</h3>
|
||||
<p style={{ fontSize: "0.8em" }}>
|
||||
Ad size: {types[type][0]} x {types[type][1]}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
{/* <NextImage.default
|
||||
alt="Advertisement"
|
||||
src={`./ad_${types[type][0]}x${types[type][1]}.png`}
|
||||
width={types[type][0]}
|
||||
height={types[type][1]}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
components/bannerAdNitro.js
Normal file
142
components/bannerAdNitro.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import useWindowDimensions from "./useWindowDimensions";
|
||||
import sendEvent from "./utils/sendEvent";
|
||||
|
||||
const AD_REFRESH_SEC = 30; // refresh ad every 30 seconds (NitroPay uses seconds)
|
||||
|
||||
function findAdType(screenW, screenH, types, vertThresh) {
|
||||
let type = 0;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
if (types[i][0] <= screenW * 0.9 && types[i][1] <= screenH * vertThresh) {
|
||||
type = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (types[type][0] > screenW || types[type][1] > screenH * vertThresh)
|
||||
return -1;
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export default function Ad({
|
||||
types,
|
||||
unit,
|
||||
vertThresh = 0.3,
|
||||
screenW,
|
||||
screenH,
|
||||
showAdvertisementText = true,
|
||||
}) {
|
||||
const [type, setType] = useState(
|
||||
findAdType(screenW, screenH, types, vertThresh)
|
||||
);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const adDivRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hostname === "localhost") setIsClient("debug");
|
||||
else setIsClient(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setType(findAdType(screenW, screenH, types, vertThresh));
|
||||
}, [screenW, screenH, JSON.stringify(types), vertThresh]);
|
||||
|
||||
// NitroPay ad management
|
||||
useEffect(() => {
|
||||
if (type === -1 || !isClient || isClient === "debug") return;
|
||||
|
||||
const config = {
|
||||
refreshTime: AD_REFRESH_SEC,
|
||||
renderVisibleOnly: true,
|
||||
"report": {
|
||||
"enabled": true,
|
||||
"icon": true,
|
||||
"wording": "Report Ad",
|
||||
"position": "top-right"
|
||||
},
|
||||
// demo: isClient === "debug",
|
||||
// sizes: [[types[type][0], types[type][1]]], update: instead of only choosing the best size, include the sizes that are smaller than the best (both width and height)
|
||||
sizes: types
|
||||
.filter((t) => t[0] <= types[type][0] && t[1] <= types[type][1])
|
||||
.map((t) => [t[0], t[1]]),
|
||||
report: {
|
||||
load: () => {
|
||||
sendEvent(`ad_request_${types[type][0]}x${types[type][1]}_${unit}`);
|
||||
},
|
||||
// Add other analytics hooks as needed
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
window.nitroAds.createAd(unit, config);
|
||||
} catch (error) {
|
||||
console.error("Error creating Nitro ad:", error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// window.nitroAds.destroy(unit);
|
||||
};
|
||||
}, [type, isClient, unit]);
|
||||
|
||||
if (type === -1) return null;
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{showAdvertisementText && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-24px",
|
||||
left: "0px",
|
||||
padding: "0 5px",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Advertisement
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: `rgba(0,0,0,${isClient === "debug" ? 0.5 : 0})`,
|
||||
height: types[type][1],
|
||||
width: types[type][0],
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
id={unit}
|
||||
ref={adDivRef}
|
||||
>
|
||||
{isClient === "debug" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "10px",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
color: "white",
|
||||
zIndex: 2,
|
||||
backgroundColor: `rgba(0, 0, 0, 0.5)`
|
||||
}}
|
||||
>
|
||||
<h3>Banner Ad Here (Nitro)</h3>
|
||||
<p style={{ fontSize: "0.8em" }}>
|
||||
{/* Ad size: {types[type][0]}x{types[type][1]} */}
|
||||
Ad sizes: { types
|
||||
.filter((t) => t[0] <= types[type][0] && t[1] <= types[type][1]).map((t) => `${t[0]}x${t[1]}`).join(", ") }
|
||||
</p>
|
||||
<p style={{ fontSize: "0.6em" }}>Unit: {unit}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/bannerText.js
Normal file
33
components/bannerText.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import NextImage from "next/image"
|
||||
import { asset } from '@/lib/basePath'
|
||||
|
||||
export default function BannerText({shown, text, hideCompass, subText, position}) {
|
||||
return (
|
||||
<div
|
||||
className={`banner-text ${shown ? 'shown' : 'hidden'}`}
|
||||
style={{
|
||||
position: position || 'fixed',
|
||||
zIndex: 1000,
|
||||
top: '50%',
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: 'none',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex"}}>
|
||||
<span style={{color: 'white', fontSize: '50px', marginTop: '20px', textAlign: 'center'}}>
|
||||
{text || 'Loading...'}
|
||||
</span>
|
||||
{ !hideCompass && (
|
||||
<NextImage.default alt="Loading compass" src={asset('/loader.gif')} width={100} height={100} />
|
||||
)}
|
||||
</div>
|
||||
{subText && (
|
||||
<span style={{color: 'white', fontSize: '30px', marginTop: '20px', textAlign: 'center'}}>
|
||||
{subText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
components/calcPoints.js
Normal file
28
components/calcPoints.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
Number.prototype.toRad = function() {
|
||||
return this * Math.PI / 180;
|
||||
}
|
||||
export function findDistance(lat1, lon1, lat2, lon2) {
|
||||
try {
|
||||
var R = 6371; // km
|
||||
var dLat = (lat2-lat1).toRad();
|
||||
var dLon = (lon2-lon1).toRad();
|
||||
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1.toRad()) * Math.cos(lat2.toRad()) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
var d = R * c;
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
return 0;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
export default function calcPoints({lat, lon, guessLat, guessLon, usedHint, maxDist}) {
|
||||
const dist = findDistance(lat, lon, guessLat, guessLon);
|
||||
let pts = 5000 * Math.E ** (-10*(dist / maxDist));
|
||||
if(usedHint) pts = pts / 2;
|
||||
if(pts > 4997) pts = 5000;
|
||||
// if dist under 30m, give 5000 points
|
||||
if(dist < 0.03) pts = 5000;
|
||||
return Math.round(pts);
|
||||
}
|
||||
17
components/changelog.json
Normal file
17
components/changelog.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[
|
||||
{
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"date": "8/20/2025",
|
||||
"change":"WorldGuessr just got a massive update.\n\n### 🎨 Major Improvements\n- Complete redesign of the user interface with a modern, sleek look.\n- Leaderboards fully functional including Past Day ELO.\n- Better multiplayer reconnect system\n- New round over screen for detailed postgame analysis!\n\n### 📈 Revamped Profile Page\n- Track your **ELO**, **XP**, and **ranks** over time with beautiful new graphs.\n- See your growth, streaks, and slumps in a glance.\n- Redesigned layout with better organization of your stats and achievements.\n\n### 🗺️ Better Maps!\n- Added support for **panoIds** (more precise control for creators)\n\n\nHuge thanks to the community for the feedback and ideas that made this update possible 💙\n\n",
|
||||
"postedBy": "gautam @ WorldGuessr"
|
||||
},
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"date": "11/26/2025",
|
||||
"change":"Happy Thanksgiving! WorldGuessr just received a new update.\n\n### ✨ Minor Improvements\n- Upgraded Minimap with higher resolution tiles for a sharper and more readable view.\n- Faster overall loading times for a smoother gameplay experience.\n- Small stability fixes throughout the game.\n\n### 🛡️ Moderation Enhancements\n- You can now report suspicious players or inappropriate nicknames directly from your ranked duel history. This feature is rolling out gradually.\n- Automatic elo refunds for matches impacted by confirmed cheaters or external googlers.\n- Check the newly added **Moderation** section in your profile page to track your reports and check whether you've received any ELO.\n\n",
|
||||
"postedBy": "gautam @ WorldGuessr"
|
||||
}
|
||||
]
|
||||
229
components/chatBox.js
Normal file
229
components/chatBox.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { Chatbot, createCustomMessage } from 'react-chatbot-kit';
|
||||
import 'react-chatbot-kit/build/main.css';
|
||||
import { createChatBotMessage } from 'react-chatbot-kit';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { FaXmark } from 'react-icons/fa6';
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import { Filter } from 'bad-words';
|
||||
import { toast } from 'react-toastify';
|
||||
const filter = new Filter();
|
||||
filter.removeWords('damn');
|
||||
|
||||
const config = {
|
||||
initialMessages: [],
|
||||
customComponents: {
|
||||
header: () => <div className="react-chatbot-kit-chat-header">Chat</div>
|
||||
},
|
||||
customMessages: {
|
||||
custom: (props) => {
|
||||
return <CustomMessage {...props} message={props.state.messages.find(msg => (msg.payload === props.payload))} />;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CustomMessage = ({ state, message }) => {
|
||||
return (
|
||||
<div className="react-chatbot-kit-chat-bot-message-container fgh">
|
||||
<div className="react-chatbot-kit-chat-bot-avatar">
|
||||
<div className="react-chatbot-kit-chat-bot-avatar-container">
|
||||
<p className="react-chatbot-kit-chat-bot-avatar-letter">{JSON.parse(message.message).username?.charAt(0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="react-chatbot-kit-chat-bot-message">
|
||||
<span className='authorName'>{JSON.parse(message.message).username}</span>
|
||||
<br />
|
||||
<span>{JSON.parse(message.message).message}</span>
|
||||
<div className="react-chatbot-kit-chat-bot-message-arrow"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let lastSend = 0;
|
||||
|
||||
const ActionProvider = ({ createChatBotMessage, setState, children, ws, myId, inGame }) => {
|
||||
// Use ref to always have current myId value in the event listener
|
||||
// This fixes the stale closure issue where myId might be undefined initially
|
||||
const myIdRef = useRef(myId);
|
||||
useEffect(() => {
|
||||
myIdRef.current = myId;
|
||||
}, [myId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
const ondata = (msg) => {
|
||||
const data = JSON.parse(msg.data);
|
||||
// Use ref to get current myId value, not the stale closure value
|
||||
if (data.type === 'chat' && data.id !== myIdRef.current) {
|
||||
const senderUsername = data.name;
|
||||
setState((state) => {
|
||||
return { ...state, messages: [...state.messages, createCustomMessage(JSON.stringify({
|
||||
message: data.message,
|
||||
username: senderUsername
|
||||
}), 'custom', { payload: data.message })] };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', ondata);
|
||||
return () => {
|
||||
ws.removeEventListener('message', ondata);
|
||||
};
|
||||
}, [ws]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inGame) setState((state) => {
|
||||
return { ...state, messages: [] };
|
||||
});
|
||||
}, [inGame]);
|
||||
|
||||
function sendMsg(msg) {
|
||||
if (!ws) return;
|
||||
ws.send(JSON.stringify({ type: 'chat', message: msg }));
|
||||
}
|
||||
|
||||
const handleMsg = (message) => {
|
||||
sendMsg(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.Children.map(children, (child) => {
|
||||
return React.cloneElement(child, {
|
||||
actions: {
|
||||
handleMsg,
|
||||
},
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageParser = ({ children, actions }) => {
|
||||
const parse = (message) => {
|
||||
actions.handleMsg(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.Children.map(children, (child) => {
|
||||
return React.cloneElement(child, {
|
||||
parse: parse,
|
||||
actions
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoized wrapper components for Chatbot to prevent re-renders
|
||||
const MemoizedMessageParser = React.memo(MessageParser);
|
||||
const MemoizedActionProvider = React.memo(ActionProvider);
|
||||
|
||||
function ChatBox({ ws, open, onToggle, enabled, myId, inGame, miniMapShown, isGuest, publicGame, roundOverScreenShown }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// Store ws, myId, inGame in refs so they can be accessed without causing re-renders
|
||||
const wsRef = useRef(ws);
|
||||
const myIdRef = useRef(myId);
|
||||
const inGameRef = useRef(inGame);
|
||||
|
||||
// Update refs when values change
|
||||
useEffect(() => {
|
||||
wsRef.current = ws;
|
||||
}, [ws]);
|
||||
|
||||
useEffect(() => {
|
||||
myIdRef.current = myId;
|
||||
}, [myId]);
|
||||
|
||||
useEffect(() => {
|
||||
inGameRef.current = inGame;
|
||||
}, [inGame]);
|
||||
|
||||
const notGuestChatDisabled = !(!isGuest || (isGuest && !publicGame));
|
||||
const notGuestChatDisabledRef = useRef(notGuestChatDisabled);
|
||||
|
||||
useEffect(() => {
|
||||
notGuestChatDisabledRef.current = notGuestChatDisabled;
|
||||
}, [notGuestChatDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset unread count when leaving a game (messages get cleared)
|
||||
useEffect(() => {
|
||||
if (!inGame) {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, [inGame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
const ondata = (msg) => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data.type === 'chat' && data.id !== myId && !open) {
|
||||
setUnreadCount(prevCount => prevCount + 1);
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', ondata);
|
||||
return () => {
|
||||
ws.removeEventListener('message', ondata);
|
||||
};
|
||||
}, [ws, open, myId]);
|
||||
|
||||
// Stable message parser that doesn't change between renders
|
||||
const messageParserFunc = useCallback((props) => <MemoizedMessageParser {...props} />, []);
|
||||
|
||||
// Stable action provider that uses refs for changing values
|
||||
const actionProviderFunc = useCallback((props) => (
|
||||
<MemoizedActionProvider {...props} ws={wsRef.current} myId={myIdRef.current} inGame={inGameRef.current} />
|
||||
), []);
|
||||
|
||||
// Stable validator function using ref for notGuestChatDisabled
|
||||
const validatorFunc = useCallback((input) => {
|
||||
if(notGuestChatDisabledRef.current) return false;
|
||||
if (input.length < 1) return false;
|
||||
if (input.length > 200) return false;
|
||||
if (Date.now() - lastSend < 1000) return false;
|
||||
if (filter.isProfane(input)) {
|
||||
toast.error('Be nice!');
|
||||
return false;
|
||||
}
|
||||
lastSend = Date.now();
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Hide chat button on mobile when RoundOverScreen is showing
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768;
|
||||
const shouldHideChatButton = false;
|
||||
return (
|
||||
<div className={`chatboxParent ${enabled ? 'enabled' : ''} ${notGuestChatDisabled ? 'guest' : ''} ${roundOverScreenShown ? 'roundOverScreen' : ''}`}>
|
||||
{!shouldHideChatButton && (
|
||||
<button
|
||||
className={`chatboxBtn ${open ? 'open' : ''} ${miniMapShown ? 'minimap' : ''}`} style={{ fontSize: '16px', fontWeight: 'bold', color: 'white', background: 'green', border: 'none', borderRadius: '5px', padding: '10px 20px', cursor: 'pointer' }} onClick={onToggle}>
|
||||
{open ? <FaXmark style={{ pointerEvents: 'none' }} /> : `${text("chat")}${unreadCount > 0 ? ` (${unreadCount})` : ''}`}
|
||||
</button>
|
||||
)}
|
||||
<div className={`chatbox ${open ? 'open' : ''}`}>
|
||||
<Chatbot
|
||||
config={config}
|
||||
placeholderText={notGuestChatDisabled ? "Please login to chat" : undefined}
|
||||
messageParser={messageParserFunc}
|
||||
actionProvider={actionProviderFunc}
|
||||
validator={validatorFunc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with React.memo - the key to preventing input reset is the stable
|
||||
// function references (messageParserFunc, actionProviderFunc, validatorFunc)
|
||||
// created with useCallback, not blocking re-renders
|
||||
export default React.memo(ChatBox);
|
||||
119
components/clueBanner.js
Normal file
119
components/clueBanner.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useTranslation } from '@/components/useTranslations'
|
||||
import msToTime from './msToTime';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Rating } from '@smastrom/react-rating';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ThinStar } from '@smastrom/react-rating';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ClueBanner({ explanations, close, session }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [index, setIndex] = useState(0);
|
||||
const [ratedIndexes, setRatedIndexes] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setRatedIndexes({});
|
||||
}, [explanations]);
|
||||
// explanations: [
|
||||
// {id, cluetext, rating, ratingcount, created_by_name, created_at} ]
|
||||
|
||||
|
||||
const explanation = explanations[index];
|
||||
if(!explanation) return null;
|
||||
const humanTime = msToTime(explanation.created_at);
|
||||
const readOnly = (!session?.token?.secret || ratedIndexes[`rate${index}`])?true:false
|
||||
const value = ratedIndexes[`rate${index}`] ? ratedIndexes[`rate${index}`] : explanation.rating;
|
||||
return (
|
||||
<div id='endBanner' className='clueBanner'>
|
||||
<div className="explanationContainer"
|
||||
style={{overflowY: 'scroll', maxHeight: '40vh'}}
|
||||
>
|
||||
<div className="bannerContent">
|
||||
<span className='smallmainBannerTxt'>
|
||||
{/* Your guess was {km} km away! */}
|
||||
Explanations (beta)
|
||||
</span>
|
||||
|
||||
|
||||
{explanation.cluetext.split("\n").map((line, i) => (
|
||||
<p className='motivation' style={{fontSize: '0.6em', marginBottom: '15px'}} key={i}>
|
||||
{line.split(" ").map((word, j) => (
|
||||
word.startsWith("http") || word.startsWith("www.") || word.startsWith("plonkit.net") ? (
|
||||
<button key={j} className="linkButton" onClick={() => window.open(word.startsWith("http") ? word : `https://${word}`, '_blank')}>{word}</button>
|
||||
) : (
|
||||
word + " "
|
||||
)
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="explanationFooter">
|
||||
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
|
||||
<span className="createdBy" style={{fontWeight: 10}}>{text("explanationFooter", {
|
||||
name: explanation.created_by_name,
|
||||
time: humanTime
|
||||
})}</span>
|
||||
|
||||
|
|
||||
<Rating
|
||||
style={{ maxWidth: 100 }}
|
||||
halfFillMode='svg'
|
||||
|
||||
value={readOnly?value:Math.round(value)}
|
||||
// value={4.5}
|
||||
readOnly={readOnly}
|
||||
onChange={(value) => {
|
||||
// send rating to server
|
||||
if(ratedIndexes[`rate${index}`]) return;
|
||||
setRatedIndexes({
|
||||
...ratedIndexes,
|
||||
[`rate${index}`]: value
|
||||
});
|
||||
fetch(window.cConfig.apiUrl+'/api/clues/rateClue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clueId: explanation.id,
|
||||
rating: value,
|
||||
secret: session?.token?.secret,
|
||||
}),
|
||||
}).then((res) => {
|
||||
if (res.ok) {
|
||||
toast.success('Rating submitted successfully!');
|
||||
} else {
|
||||
setRatedIndexes({
|
||||
...ratedIndexes,
|
||||
[`rate${index}`]: undefined
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
setRatedIndexes({
|
||||
...ratedIndexes,
|
||||
[`rate${index}`]: undefined
|
||||
});
|
||||
});
|
||||
}}
|
||||
|
||||
/>
|
||||
({explanation.ratingcount + (ratedIndexes[`rate${index}`] ? 1 : 0)})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endButtonContainer">
|
||||
|
||||
<button className="openInMaps" onClick={close}>
|
||||
{text("close")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
components/countryButtons.js
Normal file
24
components/countryButtons.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import nameFromCode from "./utils/nameFromCode";
|
||||
|
||||
function countryDiv({country, onPress}) {
|
||||
return (
|
||||
<div key={country} className="countryCard countryGuessrCard" style={{backgroundImage: `url(https://flagcdn.com/w160/${country?.toLowerCase()}.png)`}}
|
||||
onClick={() => {
|
||||
onPress(country)
|
||||
}}
|
||||
>
|
||||
<h3 className="countryName">{nameFromCode(country)}</h3>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
export default function CountryBtns({ countries, onCountryPress, shown }) {
|
||||
return (
|
||||
<div className={`countryGuessrOptions ${shown?"shown":""}`}>
|
||||
{countries.map((country) => {
|
||||
return countryDiv({country, onPress: onCountryPress})
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
201
components/countrySelectorModal.js
Normal file
201
components/countrySelectorModal.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Modal } from "react-responsive-modal";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import nameFromCode from './utils/nameFromCode';
|
||||
import { VALID_COUNTRY_CODES } from '@/serverUtils/timezoneToCountry';
|
||||
|
||||
export default function CountrySelectorModal({ shown, onClose, currentCountry, onSelect, session, ws }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
if (!searchQuery) return VALID_COUNTRY_CODES;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return VALID_COUNTRY_CODES.filter(code => {
|
||||
const name = nameFromCode(code);
|
||||
return name.toLowerCase().includes(query) || code.toLowerCase().includes(query);
|
||||
});
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSelect = async (countryCode) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(window.cConfig.apiUrl + '/api/updateCountryCode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: session?.token?.secret,
|
||||
countryCode
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
onSelect(data.countryCode);
|
||||
|
||||
// Send WebSocket message to update countryCode in real-time
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'updateCountryCode',
|
||||
countryCode: data.countryCode || ''
|
||||
}));
|
||||
}
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.message || 'Failed to update country');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('An error occurred');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFlag = async () => {
|
||||
await handleSelect('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={shown}
|
||||
onClose={onClose}
|
||||
center
|
||||
styles={{
|
||||
modal: {
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
boxShadow: 'none',
|
||||
maxWidth: '100%',
|
||||
width: 'auto',
|
||||
overflow: 'visible'
|
||||
},
|
||||
overlay: {
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="join-party-card" style={{
|
||||
maxWidth: '600px',
|
||||
width: '90vw',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'slideInUp 0.6s ease-out'
|
||||
}}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: '15px', flexShrink: 0 }}>
|
||||
{text('selectCountryFlag') || 'Select Your Country Flag'}
|
||||
</h2>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="join-party-input"
|
||||
placeholder={text('searchCountry') || 'Search country...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
marginBottom: '15px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(160px, 100%), 1fr))',
|
||||
gap: '8px',
|
||||
marginBottom: '15px',
|
||||
paddingRight: '5px'
|
||||
}}>
|
||||
{filteredCountries.map(code => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleSelect(code)}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: currentCountry === code
|
||||
? 'rgba(76, 175, 80, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
border: currentCountry === code
|
||||
? '2px solid #4CAF50'
|
||||
: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!saving) {
|
||||
e.target.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!saving) {
|
||||
e.target.style.background = currentCountry === code
|
||||
? 'rgba(76, 175, 80, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.1)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://flagcdn.com/w80/${code.toLowerCase()}.png`}
|
||||
srcSet={`https://flagcdn.com/w160/${code.toLowerCase()}.png 2x`}
|
||||
alt={code}
|
||||
style={{
|
||||
width: '1.8em',
|
||||
height: '1.2em',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '2px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{nameFromCode(code)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{currentCountry && (
|
||||
<button
|
||||
className="join-party-button"
|
||||
onClick={handleRemoveFlag}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: 'rgba(244, 67, 54, 0.8)',
|
||||
borderColor: '#c62828'
|
||||
}}
|
||||
>
|
||||
{text('removeFlag') || 'Remove Flag'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="join-party-button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)'
|
||||
}}
|
||||
>
|
||||
{text('cancel') || 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
10
components/createUUID.js
Normal file
10
components/createUUID.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function createCode() {
|
||||
return Math.floor(100000 + Math.random() * 900000);
|
||||
}
|
||||
|
||||
export function createUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
54
components/discordModal.js
Normal file
54
components/discordModal.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Modal } from "react-responsive-modal";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import gameStorage from "./utils/localStorage";
|
||||
|
||||
export default function DiscordModal({ shown, setOpen }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Modal id="signUpModal" styles={{
|
||||
modal: {
|
||||
zIndex: 100,
|
||||
background: '#333', // dark mode: #333
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
fontFamily: "'Arial', sans-serif",
|
||||
maxWidth: '500px',
|
||||
textAlign: 'center',
|
||||
}
|
||||
}} open={shown} center onClose={() => {
|
||||
gameStorage.setItem("shownDiscordModal", Date.now().toString())
|
||||
setOpen(false)
|
||||
}}>
|
||||
|
||||
<h2>{text("joinDiscord")}</h2>
|
||||
<p>{text("joinDiscordDesc")}</p>
|
||||
|
||||
<iframe src="https://discord.com/widget?id=1229957469116301412&theme=dark" width="350"
|
||||
height="350"
|
||||
allowtransparency="true" frameborder="0" sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"></iframe>
|
||||
|
||||
<br/>
|
||||
|
||||
<button onClick={() => {
|
||||
gameStorage.setItem("shownDiscordModal", Date.now().toString())
|
||||
|
||||
setOpen(false)
|
||||
}} style={{
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginTop: '20px',
|
||||
marginLeft: '20px'
|
||||
}}>
|
||||
{text("notNow")}
|
||||
</button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
154
components/duelHealthbar.js
Normal file
154
components/duelHealthbar.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { getLeague } from './utils/leagues';
|
||||
import Link from 'next/link';
|
||||
import CountryFlag from './utils/countryFlag';
|
||||
|
||||
const easeOutElastic = (t) => {
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
return t === 0
|
||||
? 0
|
||||
: t === 1
|
||||
? 1
|
||||
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
};
|
||||
|
||||
const easeOutBack = (t) => {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
};
|
||||
|
||||
const HealthBar = ({ health, maxHealth, name, elo, start, isStartingDuel, isOpponent = false, countryCode = null }) => {
|
||||
const [displayHealth, setDisplayHealth] = useState(health);
|
||||
const [prevHealth, setPrevHealth] = useState(health);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [damageIndicator, setDamageIndicator] = useState(null);
|
||||
const prevHealthRef = useRef(health);
|
||||
|
||||
const getHealthColor = (percentage) => {
|
||||
if (percentage > 60) return { bg: '#4ade80', glow: '#22c55e' }; // Green
|
||||
if (percentage > 30) return { bg: '#fbbf24', glow: '#f59e0b' }; // Yellow
|
||||
return { bg: '#ef4444', glow: '#dc2626' }; // Red
|
||||
};
|
||||
|
||||
const healthPercentage = Math.max(0, (displayHealth / maxHealth) * 100);
|
||||
const colors = getHealthColor(healthPercentage);
|
||||
|
||||
useEffect(() => {
|
||||
if (health !== prevHealthRef.current) {
|
||||
const damage = prevHealthRef.current - health;
|
||||
if (damage > 0) {
|
||||
setDamageIndicator(damage);
|
||||
setTimeout(() => setDamageIndicator(null), 2000);
|
||||
}
|
||||
setPrevHealth(prevHealthRef.current);
|
||||
prevHealthRef.current = health;
|
||||
}
|
||||
|
||||
let startTime;
|
||||
const duration = 1200;
|
||||
setIsAnimating(true);
|
||||
|
||||
const animateHealth = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easedProgress = progress; // Simple linear interpolation
|
||||
const newDisplayHealth = Math.max(0, displayHealth + easedProgress * (health - displayHealth));
|
||||
|
||||
setDisplayHealth(newDisplayHealth);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateHealth);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animateHealth);
|
||||
}, [health]);
|
||||
|
||||
return (
|
||||
<div className={`health-bar-container modern ${start ? 'start' : ''} ${isAnimating ? 'animating' : ''}`}>
|
||||
{damageIndicator && (
|
||||
<div className="damage-indicator">
|
||||
-{damageIndicator}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ !isStartingDuel && (
|
||||
<div className="health-bar-wrapper">
|
||||
<div className="health-bar-bg">
|
||||
<div className="health-bar-track">
|
||||
<div
|
||||
className="health-bar-fill"
|
||||
style={{
|
||||
width: `${healthPercentage}%`,
|
||||
backgroundColor: colors.bg,
|
||||
boxShadow: `0 0 20px ${colors.glow}40, inset 0 2px 4px rgba(255,255,255,0.3)`,
|
||||
}}
|
||||
>
|
||||
<div className="health-bar-shine"></div>
|
||||
<div className="health-bar-pulse" style={{ backgroundColor: colors.glow }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="health-text">
|
||||
<span className="health-number">{Math.max(0, Math.round(displayHealth))}</span>
|
||||
<span className="health-max">/{maxHealth}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`player-info-modern ${isStartingDuel ? 'starting' : ''}`}>
|
||||
<div className="player-name-wrapper">
|
||||
{isOpponent && name ? (
|
||||
<Link
|
||||
href={`/user?u=${encodeURIComponent(name)}`}
|
||||
target="_blank"
|
||||
className="player-name"
|
||||
style={{
|
||||
color: 'white',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '0.8';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
{countryCode && <CountryFlag countryCode={countryCode} />}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="player-name" style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
{name}
|
||||
{countryCode && <CountryFlag countryCode={countryCode} />}
|
||||
</span>
|
||||
)}
|
||||
{elo && (
|
||||
<span
|
||||
className="player-elo"
|
||||
style={{
|
||||
color: getLeague(elo)?.light ?? getLeague(elo)?.color ?? "#60a5fa",
|
||||
textShadow: `0 0 10px ${getLeague(elo)?.light ?? getLeague(elo)?.color ?? "#60a5fa"}60`
|
||||
}}
|
||||
>
|
||||
({elo})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthBar;
|
||||
315
components/eloView.js
Normal file
315
components/eloView.js
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { useTranslation } from '@/components/useTranslations'
|
||||
import { getLeague, leagues } from "./utils/leagues";
|
||||
import { useState } from "react";
|
||||
import XPGraph from "./XPGraph";
|
||||
|
||||
export default function EloView({ eloData, session, isPublic = false, username = null, viewingPublicProfile = false }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const userLeague = getLeague(eloData.elo);
|
||||
const [hoveredLeague, setHoveredLeague] = useState(null);
|
||||
|
||||
const containerStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'clamp(15px, 4vw, 30px)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'clamp(10px, 3vw, 20px)',
|
||||
padding: 'clamp(15px, 4vw, 30px)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: 'clamp(24px, 6vw, 48px)',
|
||||
fontWeight: 600,
|
||||
marginBottom: 'clamp(10px, 3vw, 20px)',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
textShadow: '2px 2px 4px rgba(0,0,0,0.3)'
|
||||
};
|
||||
|
||||
const descriptionStyle = {
|
||||
fontSize: 'clamp(14px, 3vw, 18px)',
|
||||
color: '#b0b0b0',
|
||||
marginBottom: '10px',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.5'
|
||||
};
|
||||
|
||||
const statsGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: 'clamp(10px, 3vw, 20px)',
|
||||
marginTop: 'clamp(10px, 3vw, 20px)'
|
||||
};
|
||||
|
||||
const statItemStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 'clamp(8px, 2vw, 15px)',
|
||||
padding: 'clamp(12px, 3vw, 20px)',
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
transition: 'all 0.3s ease'
|
||||
};
|
||||
|
||||
const statLabelStyle = {
|
||||
fontSize: 'clamp(12px, 2.5vw, 16px)',
|
||||
color: '#b0b0b0',
|
||||
marginBottom: 'clamp(4px, 1.5vw, 8px)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const statValueStyle = {
|
||||
fontSize: 'clamp(18px, 4vw, 28px)',
|
||||
color: '#ffd700',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 10px rgba(255, 215, 0, 0.3)'
|
||||
};
|
||||
|
||||
const leagueContainerStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 'clamp(8px, 2vw, 15px)',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 'clamp(15px, 4vw, 30px)',
|
||||
padding: 'clamp(10px, 3vw, 20px)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: 'clamp(10px, 3vw, 20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* ELO Header */}
|
||||
{/* <div style={cardStyle}>
|
||||
<h1 style={titleStyle}>{text("ELO")}</h1>
|
||||
|
||||
<p style={descriptionStyle}>
|
||||
{text("leagueModalDesc")}
|
||||
</p>
|
||||
|
||||
<p style={descriptionStyle}>
|
||||
{text("leagueModalDesc2")}
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* League System */}
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(20px, 4vw, 32px)',
|
||||
fontWeight: 600,
|
||||
marginBottom: 'clamp(10px, 3vw, 20px)',
|
||||
color: 'white',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{text("leagues")}
|
||||
</h2>
|
||||
|
||||
<div style={leagueContainerStyle}>
|
||||
{Object.values(leagues).map((league) => {
|
||||
const isCurrentLeague = userLeague.name === league.name;
|
||||
const eloNeeded = league.min;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={league.name}
|
||||
style={{
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.3s ease',
|
||||
transform: isCurrentLeague ? 'scale(1.15)' : 'scale(1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.2)';
|
||||
setHoveredLeague(league.name)
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = isCurrentLeague ? 'scale(1.15)' : 'scale(1)';
|
||||
setHoveredLeague(null)
|
||||
}}
|
||||
>
|
||||
{/* League Square with Shine Effect */}
|
||||
<div style={{
|
||||
width: 'clamp(50px, 10vw, 80px)',
|
||||
height: 'clamp(45px, 9vw, 70px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: league.color,
|
||||
color: 'black',
|
||||
borderRadius: 'clamp(8px, 2vw, 15px)',
|
||||
fontSize: 'clamp(25px, 6vw, 50px)',
|
||||
fontWeight: 'bold',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: isCurrentLeague ? '0 0 20px rgba(255, 215, 0, 0.5)' : '0 4px 15px rgba(0, 0, 0, 0.3)',
|
||||
border: isCurrentLeague ? '3px solid #ffd700' : '2px solid rgba(255, 255, 255, 0.2)'
|
||||
}}>
|
||||
{league.emoji}
|
||||
{/* Shiny Effect */}
|
||||
{isCurrentLeague && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-100%',
|
||||
left: '-100%',
|
||||
width: '200%',
|
||||
height: '200%',
|
||||
background: 'linear-gradient(45deg, rgba(255,255,255,0.6), rgba(255,255,255,0))',
|
||||
transform: 'rotate(30deg)',
|
||||
animation: 'shine 2s infinite linear'
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* League Name */}
|
||||
<p style={{
|
||||
fontSize: 'clamp(12px, 3vw, 16px)',
|
||||
marginTop: 'clamp(6px, 1.5vw, 8px)',
|
||||
color: isCurrentLeague ? '#ffd700' : '#e0e0e0',
|
||||
fontWeight: isCurrentLeague ? 'bold' : '600',
|
||||
textShadow: isCurrentLeague ? '0px 0px 8px #ffd700' : 'none'
|
||||
}}>
|
||||
{league.name}
|
||||
</p>
|
||||
|
||||
{/* ELO Badge */}
|
||||
{eloNeeded > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 'clamp(-20px, -4vw, -16px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: league.color,
|
||||
color: 'black',
|
||||
border: '2px solid black',
|
||||
padding: 'clamp(3px, 1vw, 4px) clamp(6px, 2vw, 8px)',
|
||||
borderRadius: 'clamp(8px, 2vw, 10px)',
|
||||
fontSize: 'clamp(10px, 2.5vw, 12px)',
|
||||
fontWeight: 'bold',
|
||||
opacity: hoveredLeague === league.name ? 1 : 0,
|
||||
transition: 'opacity 0.3s',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
className="elo-badge">
|
||||
{eloNeeded} ELO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(20px, 4vw, 32px)',
|
||||
fontWeight: 600,
|
||||
marginBottom: 'clamp(10px, 3vw, 20px)',
|
||||
color: 'white',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{text("statistics")}
|
||||
</h2>
|
||||
|
||||
<div style={statsGridStyle}>
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{viewingPublicProfile ? text("elo") : text("yourElo")}</div>
|
||||
<div style={statValueStyle}>{eloData.elo}</div>
|
||||
</div>
|
||||
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{viewingPublicProfile ? text("globalRank") : text("yourGlobalRank")}</div>
|
||||
<div style={statValueStyle}>#{eloData.rank}</div>
|
||||
</div>
|
||||
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{text("duels_won")}</div>
|
||||
<div style={statValueStyle}>{eloData.duels_wins}</div>
|
||||
</div>
|
||||
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{text("duels_lost")}</div>
|
||||
<div style={statValueStyle}>{eloData.duels_losses}</div>
|
||||
</div>
|
||||
|
||||
{eloData.duels_tied > 0 && (
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{text("duels_tied")}</div>
|
||||
<div style={statValueStyle}>{eloData.duels_tied}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eloData.win_rate ? (
|
||||
<div style={statItemStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-5px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}>
|
||||
<div style={statLabelStyle}>{text("win_rate")}</div>
|
||||
<div style={statValueStyle}>{(eloData.win_rate * 100).toFixed(2)}%</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ELO Graph */}
|
||||
<XPGraph session={session} mode="elo" isPublic={isPublic} username={username} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
components/endBanner.js
Normal file
122
components/endBanner.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import calcPoints from "./calcPoints";
|
||||
import { useTranslation } from '@/components/useTranslations'
|
||||
import triggerConfetti from "./utils/triggerConfetti";
|
||||
|
||||
export default function EndBanner({ countryStreaksEnabled, singlePlayerRound, onboarding, countryGuesser, countryGuesserCorrect, options, lostCountryStreak, session, guessed, latLong, pinPoint, countryStreak, fullReset, km, multiplayerState, usedHint, toggleMap, panoShown, setExplanationModalShown }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const confettiTriggered = useRef(false);
|
||||
|
||||
// Calculate points for confetti check
|
||||
// For singleplayer (not in multiplayer), use lastPoint (already calculated with correct maxDist for custom maps)
|
||||
// For multiplayer, calculate using multiplayer gameData
|
||||
const points = (!multiplayerState?.inGame && singlePlayerRound?.lastPoint != null)
|
||||
? singlePlayerRound.lastPoint
|
||||
: (latLong && pinPoint ? calcPoints({
|
||||
lat: latLong.lat,
|
||||
lon: latLong.long,
|
||||
guessLat: pinPoint.lat,
|
||||
guessLon: pinPoint.lng,
|
||||
usedHint: false,
|
||||
maxDist: multiplayerState?.gameData?.maxDist ?? 20000
|
||||
}) : 0);
|
||||
|
||||
// Trigger confetti for scores >= 4850
|
||||
useEffect(() => {
|
||||
if (guessed && points >= 4850 && !confettiTriggered.current) {
|
||||
confettiTriggered.current = true;
|
||||
triggerConfetti();
|
||||
}
|
||||
// Reset when banner hides
|
||||
if (!guessed) {
|
||||
confettiTriggered.current = false;
|
||||
}
|
||||
}, [guessed, points]);
|
||||
|
||||
return (
|
||||
<div id='endBanner' style={{ display: guessed ? '' : 'none' }}>
|
||||
|
||||
<button className="openInMaps topGameInfoButton" onClick={() => {
|
||||
toggleMap();
|
||||
}}>
|
||||
{panoShown ? text("showMap") : text("showPano")}
|
||||
</button>
|
||||
<div className="bannerContent">
|
||||
|
||||
|
||||
|
||||
{pinPoint && (km >= 0) ? (
|
||||
<span className='mainBannerTxt'>
|
||||
{/* Your guess was {km} km away! */}
|
||||
|
||||
|
||||
|
||||
{text(`guessDistance${options.units === "imperial" ? "Mi" : "Km"}`, { d: options.units === "imperial" ? (km * 0.621371).toFixed(1) : km })}
|
||||
</span>
|
||||
) : (
|
||||
<span className='mainBannerTxt'>{
|
||||
countryGuesser ? (
|
||||
countryGuesserCorrect ? text("correctCountry") : text("incorrectCountry")
|
||||
) : text("didntGuess")
|
||||
}!</span>
|
||||
)}
|
||||
<p className="motivation">
|
||||
{latLong && pinPoint && (onboarding || multiplayerState?.inGame) &&
|
||||
`${text('gotPoints', { p: calcPoints({ lat: latLong.lat, lon: latLong.long, guessLat: pinPoint.lat, guessLon: pinPoint.lng, usedHint: false, maxDist: multiplayerState?.gameData?.maxDist ?? 20000 }) })}! `
|
||||
}
|
||||
{
|
||||
countryGuesser && onboarding && latLong &&
|
||||
`${text('gotPoints', { p: 2500 })}!`
|
||||
}
|
||||
|
||||
|
||||
</p>
|
||||
{/* <p className="motivation">
|
||||
{xpEarned > 0 && session?.token?.secret ? text("earnedXP", { xp: xpEarned }) : ''}
|
||||
|
||||
</p> */}
|
||||
{countryStreaksEnabled && (
|
||||
<p className="motivation">
|
||||
{countryStreak > 0 ? text("onCountryStreak", { streak: countryStreak }) : ''}
|
||||
{lostCountryStreak > 0 ? `${text("lostCountryStreak", { streak: lostCountryStreak })}!` : ''}
|
||||
</p>
|
||||
)}
|
||||
<p className="motivation">
|
||||
{singlePlayerRound &&
|
||||
|
||||
text("gotPoints", { p: singlePlayerRound.lastPoint })}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
{!multiplayerState && (
|
||||
|
||||
<div className="endButtonContainer">
|
||||
<button className="playAgain" onClick={fullReset}>
|
||||
{(onboarding && onboarding.round === 5)
|
||||
|| (singlePlayerRound && singlePlayerRound.round === singlePlayerRound.totalRounds)
|
||||
? text("viewResults") : text("nextRound")}
|
||||
</button>
|
||||
{/* { !onboarding && (
|
||||
<button className="openInMaps" onClick={() => {
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${latLong.lat},${latLong.long}`);
|
||||
}}>
|
||||
{text("openInMaps")}
|
||||
</button>
|
||||
)} */}
|
||||
|
||||
|
||||
|
||||
{session?.token?.canMakeClues && (
|
||||
<button className="openInMaps" onClick={() => {
|
||||
if (!panoShown) toggleMap();
|
||||
setExplanationModalShown(true);
|
||||
}}>
|
||||
{text("writeExplanation")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
components/explanationModal.js
Normal file
127
components/explanationModal.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Modal } from "react-responsive-modal";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export default function ExplanationModal({ lat, long, session, shown, onClose }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
const [explanation, setExplanation] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!explanation) {
|
||||
setError("Explanation is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// send as form data
|
||||
// form data object
|
||||
const formData = new FormData();
|
||||
formData.append('lat', lat);
|
||||
formData.append('lng', long);
|
||||
formData.append('secret', session?.token?.secret);
|
||||
formData.append('clueText', explanation);
|
||||
setSending(true);
|
||||
|
||||
|
||||
const response = await fetch(window.cConfig.apiUrl+'/api/clues/makeClue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
},
|
||||
// body: JSON.stringify({
|
||||
// lat,
|
||||
// lng,
|
||||
// secret: session?.token?.secret,
|
||||
// clueText: explanation,
|
||||
// }),
|
||||
body: formData,
|
||||
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSending(false);
|
||||
|
||||
toast.success('Explanation submitted successfully!');
|
||||
setExplanation('');
|
||||
onClose();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setSending(false);
|
||||
|
||||
setError(data.message || 'An error occurred');
|
||||
}
|
||||
} catch (err) {
|
||||
setSending(false);
|
||||
|
||||
setError('An error occurred while submitting the explanation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id="explanationModal"
|
||||
styles={{
|
||||
modal: {
|
||||
zIndex: 100,
|
||||
background: '#333',
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
fontFamily: "'Arial', sans-serif",
|
||||
maxWidth: '500px',
|
||||
textAlign: 'center',
|
||||
}
|
||||
}}
|
||||
open={shown}
|
||||
center
|
||||
onClose={onClose}
|
||||
>
|
||||
<h2>Write an explanation</h2>
|
||||
<p>Explain the reasoning behind your guess (in English)</p>
|
||||
<p>Be specific and explain specific details in the streetview that helped you pinpoint the country and region</p>
|
||||
<p style={{color: explanation.length < 100 ? "red" : "green"}}>({explanation.length} / 1000)</p>
|
||||
|
||||
<textarea
|
||||
value={explanation}
|
||||
onChange={(e) => setExplanation(e.target.value)}
|
||||
placeholder={"Enter Explanation"}
|
||||
maxLength={1000}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '150px',
|
||||
padding: '10px',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #ccc',
|
||||
marginBottom: '20px',
|
||||
fontSize: '16px',
|
||||
fontFamily: "'Arial', sans-serif",
|
||||
resize: 'none',
|
||||
background: '#444', // dark mode: #444
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={sending}
|
||||
style={{
|
||||
background: sending ? 'gray' : 'green',
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{sending ? text("loading") : "Submit Explanation"}
|
||||
</button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
12
components/findCountry.js
Normal file
12
components/findCountry.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export default async function findCountry({lat, lon}) {
|
||||
let data = null;
|
||||
try {
|
||||
const resp = await fetch(window.cConfig.apiUrl+`/api/country?lat=${lat}&lon=${lon}`); // fetch data from OSM
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
data = { address: { country: "Unknown" }}; // default to unknown
|
||||
}
|
||||
return data.address?.country ?? "Unknown";
|
||||
}
|
||||
//https://nominatim.openstreetmap.org/reverse?lat=<value>&lon=<value>
|
||||
// https://geocode.maps.co/reverse?lat=${lat}&lon=${lon}&api_key=${process.env.NEXT_PUBLIC_MAPSCO}
|
||||
88
components/findLatLong.js
Normal file
88
components/findLatLong.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
|
||||
import { Loader } from '@googlemaps/js-api-loader';
|
||||
import findCountry from './findCountry';
|
||||
import { getRandomPointInCountry } from '@/components/randomLoc';
|
||||
const loader = new Loader({
|
||||
apiKey: "",
|
||||
version: "weekly",
|
||||
libraries: ["places"]
|
||||
});
|
||||
function generateLatLong(location) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = performance.now();
|
||||
console.log("[PERF] Starting generateLatLong");
|
||||
loader.importLibrary("streetView").then(() => {
|
||||
console.log(`[PERF] Street View library loaded in ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
const data = getRandomPointInCountry((location&&location!=="all")?location.toUpperCase():true);
|
||||
const panorama = new google.maps.StreetViewService();
|
||||
console.log("Trying to get panorama for ", data);
|
||||
const lat = data[0];
|
||||
const long = data[1];
|
||||
const panoramaStartTime = performance.now();
|
||||
|
||||
panorama.getPanorama({ location: { lat, lng: long },
|
||||
preference: google.maps.StreetViewPreference.BEST,
|
||||
radius: 1000,
|
||||
sources: [google.maps.StreetViewSource.OUTDOOR]
|
||||
}, (data, status) => {
|
||||
console.log(`[PERF] getPanorama completed in ${(performance.now() - panoramaStartTime).toFixed(2)}ms - Status: ${status}`);
|
||||
if(status === "OK" && data) {
|
||||
const latLng = data.location?.latLng;
|
||||
if(!latLng) {
|
||||
alert("Failed to get location, couldn't find latLng object")
|
||||
}
|
||||
const latO = latLng.lat();
|
||||
const longO = latLng.lng();
|
||||
const countryStartTime = performance.now();
|
||||
findCountry({ lat, lon: long }).then((country) => {
|
||||
console.log(`[PERF] findCountry completed in ${(performance.now() - countryStartTime).toFixed(2)}ms`);
|
||||
|
||||
// prevent trekkers v1
|
||||
// usually trekkers dont have location.description
|
||||
// however mongolia or south korea official coverage also doesn't have description
|
||||
// check if mongolia (MN) or south korea (KR), if not we can reject based on no description
|
||||
|
||||
if(!["MN", "KR"].includes(country) && !data.location.description) {
|
||||
console.log("No description, rejecting");
|
||||
console.log(`[PERF] Total generateLatLong time (rejected): ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
console.log(`[PERF] Total generateLatLong time (success): ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
resolve({ lat: latO, long: longO, country });
|
||||
}).catch((e) => {
|
||||
console.log("Failed to get country", e);
|
||||
console.log(`[PERF] Total generateLatLong time (error): ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
resolve({ lat: latO, long: longO, country: "Unknown" });
|
||||
});
|
||||
} else {
|
||||
console.log("Failed to get panorama", status, data);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
// });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async function findLatLongRandom(gameOptions) {
|
||||
const totalStartTime = performance.now();
|
||||
console.log("[PERF] findLatLongRandom started");
|
||||
let found = false;
|
||||
let output = null;
|
||||
let attempts = 0;
|
||||
|
||||
while (!found) {
|
||||
attempts++;
|
||||
const data = await generateLatLong(gameOptions.location);
|
||||
if(data) {
|
||||
output = data;
|
||||
found = true;
|
||||
} else {
|
||||
console.log(`[PERF] Attempt ${attempts} failed, retrying...`);
|
||||
}
|
||||
}
|
||||
console.log(`[PERF] findLatLongRandom completed in ${(performance.now() - totalStartTime).toFixed(2)}ms (${attempts} attempts)`);
|
||||
return output;
|
||||
}
|
||||
|
||||
96
components/findLatLongServer.js
Normal file
96
components/findLatLongServer.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
|
||||
|
||||
async function hasStreetViewImage(lat, long, radius) {
|
||||
if(!lat || !long) {
|
||||
console.log("Invalid lat/long", lat, long);
|
||||
return false;
|
||||
}
|
||||
const url = `https://maps.googleapis.com/maps/api/js/GeoPhotoService.SingleImageSearch?pb=!1m5!1sapiv3!5sUS!11m2!1m1!1b0!2m4!1m2!3d${lat}!4d${long}!2d${radius}!3m18!2m2!1sen!2sUS!9m1!1e2!11m12!1m3!1e2!2b1!3e2!1m3!1e3!2b1!3e2!1m3!1e10!2b1!3e2!4m6!1e1!1e2!1e3!1e4!1e8!1e6&callback=_xdc_._2kz7bz`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/59.0.3071.109 Chrome/59.0.3071.109 Safari/537.36'
|
||||
}
|
||||
});
|
||||
}catch(e){
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = await response.text();
|
||||
|
||||
// trim everything before [[0],[[1],[2,
|
||||
text = text.substring(text.indexOf("( [["));
|
||||
// trim first 2 characters and last 2 characters
|
||||
text = text.substring(2, text.length-2);
|
||||
if(text.includes("Search returned no images")) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const description = parsed[1][3][2][1][0];
|
||||
if(!description) {
|
||||
return false;
|
||||
}
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
// extract everything comma separated and keep only numbers (decimal points and negative signs allowed)
|
||||
let parts = text.split(",").map((x) => x.match(/-?\d+(\.\d+)?/g)).filter((x) => x).flat().map((x) => parseFloat(x));
|
||||
// only keep those within 1 difference to either lat or long
|
||||
parts = parts.filter((x) => Math.abs(x - lat) < 1 || Math.abs(x - long) < 1);
|
||||
let answer = [];
|
||||
for(let i = 0; i < parts.length-1; i++) {
|
||||
// find the first pair where first number within 0.1 of lat and second within 0.1 of long
|
||||
if(Math.abs(parts[i] - lat) < 0.1 && Math.abs(parts[i+1] - long) < 0.1) {
|
||||
answer = [parts[i], parts[i+1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(answer.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return { lat: answer[0], long: answer[1] };
|
||||
}
|
||||
|
||||
async function generateLatLong(location, getRandomPointInCountry, findCountry) {
|
||||
const point = getRandomPointInCountry(location&&location!=="all"?location:true);
|
||||
// console.log('point in ', location,' is ', point);
|
||||
const lat = point[0];
|
||||
const long = point[1];
|
||||
let outLat = null;
|
||||
let outLong = null;
|
||||
let country = null;
|
||||
|
||||
const hasImage = await hasStreetViewImage(lat, long, 1000);
|
||||
if (!hasImage) {
|
||||
return null;
|
||||
} else {
|
||||
outLat = hasImage.lat;
|
||||
outLong = hasImage.long;
|
||||
country = await findCountry(outLat, outLong, true);
|
||||
if(!country || !country[0]) {
|
||||
return null;
|
||||
} else {
|
||||
// todo: prevent border issues when specifying a country
|
||||
country = country[0];
|
||||
}
|
||||
}
|
||||
|
||||
return { lat: outLat, long: outLong, country: country };
|
||||
}
|
||||
|
||||
export default async function findLatLongRandom(gameOptions, getRandomPointInCountry, findCountry) {
|
||||
let found = false;
|
||||
let output = null;
|
||||
|
||||
while (!found) {
|
||||
const data = await generateLatLong(gameOptions.location, getRandomPointInCountry, findCountry);
|
||||
if(data) {
|
||||
output = data;
|
||||
found = true;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
components/formatNum.js
Normal file
15
components/formatNum.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// import text from "@/languages/lang";
|
||||
|
||||
export default function formatTime(seconds) {
|
||||
// seconds -> 1 minutes 30 seconds
|
||||
// if seconds is 90, return 1 minute 30 seconds
|
||||
// if seconds is 60, return 1 minute
|
||||
// if seconds is 61, return 1 minute 1 second
|
||||
// if seconds is 45, return 45 seconds
|
||||
// if seconds is 0, return 0 seconds
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
// return `${minutes > 0 ? `${minutes} ${minutes > 1 ? text("minutePlural") : text("minuteSingular")}` : ''} ${remainingSeconds > 0 ? `${remainingSeconds} ${remainingSeconds > 1 ? text("secondPlural") : text("secondSingular")}` : ''}`;
|
||||
return `${minutes > 0 ? `${minutes} minute${minutes > 1 ? 's' : ''}` : ''} ${remainingSeconds > 0 ? `${remainingSeconds} second${remainingSeconds > 1 ? 's' : ''}` : ''}`;
|
||||
}
|
||||
253
components/friendModal.js
Normal file
253
components/friendModal.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { Modal } from "react-responsive-modal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
|
||||
export default function FriendsModal({ shown, onClose, session, ws, canSendInvite, sendInvite, accountModalPage, setAccountModalPage, friends, setFriends, sentRequests, setSentRequests, receivedRequests, setReceivedRequests }) {
|
||||
|
||||
const [friendReqSendingState, setFriendReqSendingState] = useState(0);
|
||||
|
||||
const [friendReqProgress, setFriendReqProgress] = useState(false);
|
||||
const [allowFriendReq, setAllowFriendReq] = useState(false);
|
||||
|
||||
const [newFriend, setNewFriend] = useState('');
|
||||
//const [accountModalPage, setAccountModalPage] = useState('list');
|
||||
const { t: text } = useTranslation("common");
|
||||
const messageTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
function onMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'friends') {
|
||||
setFriends(data.friends);
|
||||
setSentRequests(data.sentRequests);
|
||||
setReceivedRequests(data.receivedRequests);
|
||||
setAllowFriendReq(data.allowFriendReq);
|
||||
}
|
||||
if (data.type === 'friendReqState') {
|
||||
setFriendReqSendingState(data.state);
|
||||
setFriendReqProgress(false);
|
||||
setNewFriend('');
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
}
|
||||
|
||||
}, [ws]);
|
||||
|
||||
useEffect(() => {
|
||||
if (friendReqSendingState > 0) {
|
||||
// Clear any existing timeout
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
setFriendReqSendingState(0);
|
||||
messageTimeoutRef.current = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Cleanup function to clear timeout on unmount
|
||||
return () => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
messageTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [friendReqSendingState]);
|
||||
|
||||
useEffect(() => {
|
||||
let int;
|
||||
if (!ws) return;
|
||||
if (shown) {
|
||||
ws.send(JSON.stringify({ type: 'getFriends' }));
|
||||
int = setInterval(() => {
|
||||
ws.send(JSON.stringify({ type: 'getFriends' }));
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(int);
|
||||
}
|
||||
}, [shown, ws])
|
||||
|
||||
const handleSendRequest = () => {
|
||||
if (!ws) return;
|
||||
setFriendReqProgress(true);
|
||||
ws.send(JSON.stringify({ type: 'sendFriendRequest', name: newFriend }));
|
||||
};
|
||||
|
||||
const handleAccept = (id) => {
|
||||
if (!ws) return;
|
||||
ws.send(JSON.stringify({ type: 'acceptFriend', id }));
|
||||
};
|
||||
|
||||
const handleDecline = (id) => {
|
||||
if (!ws) return;
|
||||
ws.send(JSON.stringify({ type: 'declineFriend', id }));
|
||||
};
|
||||
|
||||
const handleCancel = (id) => {
|
||||
if (!ws) return;
|
||||
ws.send(JSON.stringify({ type: 'cancelRequest', id }));
|
||||
};
|
||||
|
||||
const handleRemove = (id) => {
|
||||
if (!ws) return;
|
||||
ws.send(JSON.stringify({ type: 'removeFriend', id }));
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div id="friendsModal" style={{
|
||||
|
||||
zIndex: 100,
|
||||
//background: '#333',
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
fontFamily: "'Arial', sans-serif",
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
|
||||
}} className={
|
||||
|
||||
'friendsModal'
|
||||
|
||||
} open={shown} center onClose={onClose}>
|
||||
|
||||
{ws && ws.readyState !== 1 && (
|
||||
<div>{text("disconnected")}</div>
|
||||
)}
|
||||
|
||||
<div className="friendsContent">
|
||||
|
||||
|
||||
|
||||
<div className="friendsSection">
|
||||
{/* Consolidated Friends View */}
|
||||
<div style={{ width: '100%' }}>
|
||||
|
||||
{/* Add Friend Section */}
|
||||
<div style={{ marginBottom: '30px', padding: '20px', background: 'rgba(255,255,255,0.1)', borderRadius: '10px' }}>
|
||||
<h3>{text("addFriend")}</h3>
|
||||
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '15px' }}>
|
||||
{text("addFriendDescription")}
|
||||
</p>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={newFriend}
|
||||
onChange={(e) => setNewFriend(e.target.value)}
|
||||
placeholder={text("addFriendPlaceholder")}
|
||||
className="g2_input"
|
||||
/>
|
||||
<button onClick={handleSendRequest} className="g2_green_button g2_button_style" disabled={friendReqProgress}>
|
||||
{friendReqProgress ? text("loading") : text("sendRequest")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="friend-request-sent">
|
||||
{friendReqSendingState === 1 && text("friendReqSent")}
|
||||
{friendReqSendingState === 2 && text("friendReqNotAccepting")}
|
||||
{friendReqSendingState === 3 && text("friendReqNotFound")}
|
||||
{friendReqSendingState === 4 && text("friendReqAlreadySent")}
|
||||
{friendReqSendingState === 5 && text("friendReqAlreadyReceived")}
|
||||
{friendReqSendingState === 6 && text("alreadyFriends")}
|
||||
{friendReqSendingState > 6 && text("friendReqError")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Friend Request Settings */}
|
||||
<div style={{ marginBottom: '30px', padding: '15px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px' }}>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<span>
|
||||
{text("allowFriendRequests")}
|
||||
<input type="checkbox" checked={allowFriendReq} onChange={(e) => ws?.send(JSON.stringify({ type: 'setAllowFriendReq', allow: e.target.checked }))} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Received Requests Section */}
|
||||
{receivedRequests.length > 0 && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3>{text("viewReceivedRequests", { cnt: receivedRequests.length })}</h3>
|
||||
<div className="friends-list">
|
||||
{receivedRequests.map(friend => (
|
||||
<div key={friend.id} className="friend-card">
|
||||
<div className="friend-details">
|
||||
<span className="friend-name">
|
||||
{friend?.name}
|
||||
{friend?.supporter && <span className="badge">{text("supporter")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<button onClick={() => handleAccept(friend.id)} className={"accept-button"}>✔</button>
|
||||
<button onClick={() => handleDecline(friend.id)} className={"decline-button"}>✖</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sent Requests Section */}
|
||||
{sentRequests.length > 0 && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3>{text("viewSentRequests", { cnt: sentRequests.length })}</h3>
|
||||
<div className="friends-list">
|
||||
{sentRequests.map(friend => (
|
||||
<div key={friend.id} className="friend-card">
|
||||
<div className="friend-details">
|
||||
<span className="friend-name">
|
||||
{friend?.name}
|
||||
{friend?.supporter && <span className="badge">{text("supporter")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => handleCancel(friend.id)} className={"cancel-button"}>✖</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends List Section */}
|
||||
<div>
|
||||
<h3>{text("friends", { cnt: friends.length })}</h3>
|
||||
{friends.length === 0 && (
|
||||
<div>{text("noFriends")}</div>
|
||||
)}
|
||||
<div className="friends-list">
|
||||
{friends.sort((a, b) => b.online - a.online).map(friend => (
|
||||
<div key={friend.id} className="friend-card">
|
||||
<div className="friend-details">
|
||||
<span className="friend-name">
|
||||
{friend?.name}
|
||||
{friend?.supporter && <span className="badge">{text("supporter")}</span>}
|
||||
</span>
|
||||
<span className="friend-state">{friend?.online ? text("online") : text("offline")}</span>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
{canSendInvite && friend.online && friend.socketId && (
|
||||
<button onClick={() => sendInvite(friend.socketId)} className={"invite-button"}>{text("invite")}</button>
|
||||
)}
|
||||
<button onClick={() => handleRemove(friend.id)} className={"cancel-button"}>✖</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
329
components/gameHistory.js
Normal file
329
components/gameHistory.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import formatTime from '../utils/formatTime';
|
||||
import styles from '../styles/gameHistory.module.css';
|
||||
import Link from 'next/link';
|
||||
import CountryFlag from './utils/countryFlag';
|
||||
|
||||
export default function GameHistory({ session, onGameClick, targetUserSecret = null, targetUserData = null, page = null, setPage = null }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [games, setGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Use external page state if provided, otherwise use internal state
|
||||
const [internalPage, setInternalPage] = useState(1);
|
||||
const currentPage = page !== null ? page : internalPage;
|
||||
const setCurrentPage = setPage !== null ? setPage : setInternalPage;
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalGames: 0,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false
|
||||
});
|
||||
|
||||
const fetchGames = async (page = 1) => {
|
||||
if(typeof window === 'undefined' || !session?.token?.secret || !window.cConfig?.apiUrl) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(window.cConfig.apiUrl + '/api/gameHistory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: targetUserSecret || session?.token?.secret,
|
||||
page,
|
||||
limit: 10
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGames(data.games);
|
||||
setPagination(data.pagination);
|
||||
} else {
|
||||
console.error('Failed to fetch game history');
|
||||
setGames([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game history:', error);
|
||||
setGames([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && session?.token?.secret && window.cConfig?.apiUrl) {
|
||||
fetchGames(currentPage);
|
||||
}
|
||||
}, [session?.token?.secret, targetUserSecret, currentPage]);
|
||||
|
||||
const getGameTypeDisplay = (gameType) => {
|
||||
const types = {
|
||||
'singleplayer': { label: text('singleplayer'), icon: '👤', color: '#4CAF50' },
|
||||
'ranked_duel': { label: text('rankedDuel'), icon: '⚔️', color: '#FF5722' },
|
||||
'unranked_multiplayer': { label: text('multiplayer'), icon: '👥', color: '#2196F3' },
|
||||
'private_multiplayer': { label: text('privateGame'), icon: '🔒', color: '#9C27B0' }
|
||||
};
|
||||
return types[gameType] || { label: gameType, icon: '🎮', color: '#757575' };
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMinutes < 1) return text('justNow');
|
||||
if (diffMinutes < 60) return text('minutesAgo', { minutes: diffMinutes });
|
||||
if (diffHours < 24) return text('hoursAgo', { hours: diffHours });
|
||||
if (diffDays < 7) return text('daysAgo', { days: diffDays });
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getLocationDisplay = (location) => {
|
||||
if (location === 'all') return text('worldwide');
|
||||
// Handle country codes (2-letter uppercase codes)
|
||||
if (location && location.length === 2 && location === location.toUpperCase()) {
|
||||
// This could be enhanced to return actual country names
|
||||
return location;
|
||||
}
|
||||
// For community maps and other custom locations, return the name as-is
|
||||
return location || text('unknown');
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.gameHistoryLoading}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{text('loadingGameHistory')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (games.length === 0) {
|
||||
return (
|
||||
<div className={styles.gameHistoryEmpty}>
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.emptyIcon}>🎮</span>
|
||||
<h3>{text('noGamesPlayed')}</h3>
|
||||
<p>{text('startPlayingToSeeHistory')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.gameHistory}>
|
||||
<div className={styles.gameHistoryHeader}>
|
||||
<h3>
|
||||
{targetUserData ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
Game History for {targetUserData.username}
|
||||
{targetUserData.countryCode && <CountryFlag countryCode={targetUserData.countryCode} style={{ fontSize: '0.9em' }} />}
|
||||
</span>
|
||||
) : text('gameHistory')}
|
||||
</h3>
|
||||
{targetUserData && (
|
||||
<div className="mod-user-info" style={{
|
||||
fontSize: '0.9rem',
|
||||
color: '#666',
|
||||
marginTop: '5px',
|
||||
display: 'flex',
|
||||
gap: '15px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>Total XP: {targetUserData.totalXp?.toLocaleString()}</span>
|
||||
<span>Elo: {targetUserData.elo}</span>
|
||||
<span>Games: {targetUserData.totalGamesPlayed}</span>
|
||||
<span>Joined: {new Date(targetUserData.created_at).toLocaleDateString()}</span>
|
||||
{targetUserData.banned && <span style={{color: '#f44336', fontWeight: 'bold'}}>BANNED</span>}
|
||||
{targetUserData.staff && <span style={{color: '#2196f3', fontWeight: 'bold'}}>STAFF</span>}
|
||||
{targetUserData.supporter && <span style={{color: '#ff9800', fontWeight: 'bold'}}>SUPPORTER</span>}
|
||||
</div>
|
||||
)}
|
||||
<span className={styles.totalGames}>
|
||||
{text('totalGames', { count: pagination.totalGames })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.gamesList}>
|
||||
{games.map((game) => {
|
||||
const gameTypeInfo = getGameTypeDisplay(game.gameType);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={game.gameId}
|
||||
className={styles.gameItem}
|
||||
onClick={() => onGameClick(game)}
|
||||
>
|
||||
<div className={styles.gameHeader}>
|
||||
<div className={styles.gameType}>
|
||||
<span
|
||||
className={styles.gameTypeIcon}
|
||||
style={{ color: gameTypeInfo.color }}
|
||||
>
|
||||
{gameTypeInfo.icon}
|
||||
</span>
|
||||
<span className={styles.gameTypeLabel}>{gameTypeInfo.label}</span>
|
||||
</div>
|
||||
<div className={styles.gameDate}>
|
||||
{formatDate(game.endedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gameStats}>
|
||||
{game.gameType === 'ranked_duel' ? (
|
||||
<>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('result')}</span>
|
||||
<span className={styles.statValue} style={{
|
||||
color: (game.userStats?.finalRank === 1 || game.userPlayer?.finalRank === 1) ? '#4CAF50' : '#F44336'
|
||||
}}>
|
||||
{(game.userStats?.finalRank === 1 || game.userPlayer?.finalRank === 1) ? text('victory') : text('defeat')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('elo')}</span>
|
||||
<span className={styles.statValue} style={{
|
||||
color: (game.userStats?.elo?.change >= 0 || game.userPlayer?.elo?.change >= 0) ? '#4CAF50' : '#F44336'
|
||||
}}>
|
||||
{((game.userStats?.elo?.change || game.userPlayer?.elo?.change) > 0) ? '+' : ''}{(game.userStats?.elo?.change || game.userPlayer?.elo?.change) ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('opponent')}</span>
|
||||
<span className={styles.statValue} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{game.opponent?.username ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/user?u=${encodeURIComponent(game.opponent.username)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
style={{ color: 'cyan', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
{game.opponent.username}
|
||||
</Link>
|
||||
{game.opponent.countryCode && <CountryFlag countryCode={game.opponent.countryCode} style={{ fontSize: '0.9em' }} />}
|
||||
</>
|
||||
) : (
|
||||
text('unknown')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('duration')}</span>
|
||||
<span className={styles.statValue}>
|
||||
{formatTime(game.totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('points')}</span>
|
||||
<span className={styles.statValue}>
|
||||
{game.userStats.totalPoints.toLocaleString()}
|
||||
<span className={styles.statPercentage}>
|
||||
/ {game.result.maxPossiblePoints.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.userStats.totalXp > 0 && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>XP</span>
|
||||
<span className={styles.statValue}>{game.userStats.totalXp}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.gameType !== 'ranked_duel' && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>{text('duration')}</span>
|
||||
<span className={styles.statValue}>
|
||||
{formatTime(game.totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.gameDetails}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{text('map')}</span>
|
||||
<span className={styles.detailValue}>
|
||||
{getLocationDisplay(game.settings.location)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{text('rounds')}</span>
|
||||
<span className={styles.detailValue}>{game.roundsPlayed}</span>
|
||||
</div>
|
||||
|
||||
{game.multiplayer && (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{text('players')}</span>
|
||||
<span className={styles.detailValue}>{game.multiplayer.playerCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.gameArrow}>→</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
className={`${styles.paginationBtn} desktop`}
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(1)}
|
||||
>
|
||||
⏮ First
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.paginationBtn}
|
||||
disabled={!pagination.hasPrevPage}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
← {text('previous')}
|
||||
</button>
|
||||
|
||||
<span className={styles.paginationInfo}>
|
||||
{text('pageOf', {
|
||||
current: pagination.currentPage,
|
||||
total: pagination.totalPages
|
||||
})}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className={styles.paginationBtn}
|
||||
disabled={!pagination.hasNextPage}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
{text('next')} →
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`${styles.paginationBtn} desktop`}
|
||||
disabled={currentPage === pagination.totalPages}
|
||||
onClick={() => setCurrentPage(pagination.totalPages)}
|
||||
>
|
||||
Last ⏭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1032
components/gameUI.js
Normal file
1032
components/gameUI.js
Normal file
File diff suppressed because it is too large
Load diff
176
components/headContent.js
Normal file
176
components/headContent.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import Head from "next/head";
|
||||
import { useEffect } from "react";
|
||||
import { asset } from '@/lib/basePath';
|
||||
|
||||
export default function HeadContent({ text, inCoolMathGames, inCrazyGames = false, inGameDistribution = false }) {
|
||||
useEffect(() => {
|
||||
if (!window.location.search.includes("crazygames") && !process.env.NEXT_PUBLIC_POKI &&
|
||||
!process.env.NEXT_PUBLIC_COOLMATH && !process.env.NEXT_PUBLIC_GAMEDISTRIBUTION) {
|
||||
|
||||
|
||||
// start adinplay script
|
||||
// const scriptAp = document.createElement('script');
|
||||
// scriptAp.src = "https://api.adinplay.com/libs/aiptag/pub/SWT/worldguessr.com/tag.min.js";
|
||||
// scriptAp.async = true;
|
||||
// document.body.appendChild(scriptAp);
|
||||
// end adinplay script
|
||||
|
||||
// start nitroPay script
|
||||
window.nitroAds=window.nitroAds||{createAd:function(){return new Promise(e=>{window.nitroAds.queue.push(["createAd",arguments,e])})},addUserToken:function(){window.nitroAds.queue.push(["addUserToken",arguments])},queue:[]};
|
||||
|
||||
const loadNitroAds = () => {
|
||||
if (document.querySelector('script[src*="nitropay.com"]')) return;
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://s.nitropay.com/ads-2071.js";
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
// Wait for page load event (ensures fonts/LCP complete), then load ads
|
||||
const scheduleAdLoad = () => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(loadNitroAds, { timeout: 3000 });
|
||||
} else {
|
||||
setTimeout(loadNitroAds, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
scheduleAdLoad();
|
||||
} else {
|
||||
window.addEventListener('load', scheduleAdLoad, { once: true });
|
||||
}
|
||||
|
||||
// end nitroPay script
|
||||
return () => {
|
||||
// Cleanup handled by browser on unmount
|
||||
};
|
||||
} else if(window.location.search.includes("crazygames")) {
|
||||
console.log("CrazyGames detected");
|
||||
//<script src="https://sdk.crazygames.com/crazygames-sdk-v3.js"></script>
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://sdk.crazygames.com/crazygames-sdk-v3.js";
|
||||
script.async = false;
|
||||
console.log(window.CrazyGames)
|
||||
// on script load
|
||||
script.onload=() => {
|
||||
console.log("sdk loaded", window.CrazyGames)
|
||||
if(window.onCrazyload) {
|
||||
window.onCrazyload();
|
||||
}
|
||||
}
|
||||
document.body.appendChild(script);
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
} else if(process.env.NEXT_PUBLIC_COOLMATH === "true") {
|
||||
/*<script
|
||||
src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.3/jquery.min.js"></script>
|
||||
<script type="text/ja
|
||||
vascript"
|
||||
src="https://www.coolmathgames.com/sites/default/files/cmg
|
||||
-
|
||||
ads.js"></script>*/
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://ajax.googleapis.com/ajax/libs/jquery/3.6.3/jquery.min.js";
|
||||
script.async = false;
|
||||
document.body.appendChild(script);
|
||||
|
||||
const script2 = document.createElement('script');
|
||||
script2.src = "https://www.coolmathgames.com/sites/default/files/cmg-ads.js";
|
||||
script2.async = false;
|
||||
document.body.appendChild(script2);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
document.body.removeChild(script2);
|
||||
}
|
||||
|
||||
}else if(process.env.NEXT_PUBLIC_POKI === "true") {
|
||||
//
|
||||
const script = document.createElement('script');
|
||||
script.src = "https://game-cdn.poki.com/scripts/v2/poki-sdk.js";
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
}
|
||||
|
||||
} else if(process.env.NEXT_PUBLIC_GAMEDISTRIBUTION === "true") {
|
||||
window["GD_OPTIONS"] = {
|
||||
"gameId": "327b25f595b6478789b18768fd909055",
|
||||
"onEvent": function(event) {
|
||||
switch (event.name) {
|
||||
case "SDK_GAME_START":
|
||||
case "SDK_ERROR":
|
||||
case "AD_ERROR":
|
||||
case "AD_SDK_CANCELED":
|
||||
// advertisement done or failed, resume game
|
||||
if(window.onGDResumeGame) window.onGDResumeGame();
|
||||
break;
|
||||
case "SDK_GAME_PAUSE":
|
||||
// pause game logic / mute audio
|
||||
if(window.onGDPauseGame) window.onGDPauseGame();
|
||||
break;
|
||||
case "SDK_REWARDED_WATCH_COMPLETE":
|
||||
if(window.onGDRewardedComplete) window.onGDRewardedComplete();
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
(function(d, s, id) {
|
||||
var js, fjs = d.getElementsByTagName(s)[0];
|
||||
if (d.getElementById(id)) return;
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = 'https://html5.api.gamedistribution.com/main.min.js';
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}(document, 'script', 'gamedistribution-jssdk'));
|
||||
|
||||
return () => {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>
|
||||
{ inCoolMathGames ? "WorldGuessr - Play it now at CoolmathGames.com" :
|
||||
text("tabTitle") }
|
||||
</title>
|
||||
<meta property="og:title" content={text("fullTitle")}/>
|
||||
|
||||
<meta name="description"
|
||||
content={text("shortDescMeta")}
|
||||
/>
|
||||
<meta property="og:description"
|
||||
content={text("fullDescMeta")}
|
||||
/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, user-scalable=no"/>
|
||||
<link rel="icon" type="image/x-icon" href={asset("/icon.ico")} />
|
||||
<meta name="google-site-verification" content="7s9wNJJCXTQqp6yr1GiQxREhloXKjtlbOIPTHZhtY04" />
|
||||
<meta name="yandex-verification" content="2eb7e8ef6fb55e24" />
|
||||
|
||||
|
||||
{/* Preload CrazyGames SDK when on CrazyGames platform */}
|
||||
{inCrazyGames && (
|
||||
<link rel="preload" href="https://sdk.crazygames.com/crazygames-sdk-v3.js" as="script" />
|
||||
)}
|
||||
|
||||
{/* <script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'></script> */}
|
||||
|
||||
|
||||
{/* data-adbreak-test="on" */}
|
||||
{/* */}
|
||||
|
||||
|
||||
<meta property="og:image" content={asset("/icon_144x144.png")} />
|
||||
<meta property="og:url" content="https://worldguessr.com" />
|
||||
<meta property="og:type" content="website" />
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
314
components/historicalGameView.js
Normal file
314
components/historicalGameView.js
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import styles from '../styles/gameHistory.module.css';
|
||||
|
||||
const GameSummary = dynamic(() => import('./roundOverScreen'), { ssr: false });
|
||||
|
||||
export default function HistoricalGameView({ game, session, onBack, options, onUsernameLookup }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [fullGameData, setFullGameData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [isEntering, setIsEntering] = useState(true);
|
||||
|
||||
// Handle entering animation
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsEntering(false);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Handle exit animation
|
||||
const handleBack = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
}, 300); // Wait for exit animation to complete
|
||||
};
|
||||
|
||||
// Auto-close when game starts
|
||||
useEffect(() => {
|
||||
const handleGameStarting = () => {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
window.addEventListener('gameStarting', handleGameStarting);
|
||||
return () => window.removeEventListener('gameStarting', handleGameStarting);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFullGameData = async () => {
|
||||
if (typeof window === 'undefined' || !window.cConfig?.apiUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(window.cConfig.apiUrl + '/api/gameDetails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: session?.token?.secret,
|
||||
gameId: game.gameId
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFullGameData(data.game);
|
||||
} else {
|
||||
setError('Failed to load game details');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error loading game details');
|
||||
console.error('Error fetching game details:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if game already has full data (from mod dashboard pre-fetch)
|
||||
if (game && game.rounds && game.players && options?.isModView) {
|
||||
setFullGameData(game);
|
||||
setLoading(false);
|
||||
} else if (typeof window !== 'undefined' && game && session?.token?.secret && window.cConfig?.apiUrl) {
|
||||
fetchFullGameData();
|
||||
}
|
||||
}, [game, session?.token?.secret]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.historicalGameLoading}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<h3>{text('loadingGameDetails')}</h3>
|
||||
<p>{text('pleaseWait')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.historicalGameError}>
|
||||
<div className={styles.errorContainer}>
|
||||
<h3>{text('errorLoadingGame')}</h3>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className={styles.backButton}
|
||||
onClick={onBack}
|
||||
>
|
||||
← {text('backToHistory')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!fullGameData) {
|
||||
return (
|
||||
<div className={styles.historicalGameError}>
|
||||
<div className={styles.errorContainer}>
|
||||
<h3>{text('gameNotFound')}</h3>
|
||||
<p>{text('gameNoLongerAvailable')}</p>
|
||||
<button
|
||||
className={styles.backButton}
|
||||
onClick={onBack}
|
||||
>
|
||||
← {text('backToHistory')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Transform the historical game data to match the format expected by GameSummary
|
||||
const isModView = options?.isModView;
|
||||
const reportedUserId = options?.reportedUserId;
|
||||
const targetUserId = options?.targetUserId;
|
||||
|
||||
const transformedHistory = fullGameData.rounds.map((round, index) => {
|
||||
// For mod view, if round.guess is null, try to use data from allGuesses
|
||||
let guessData = round.guess;
|
||||
|
||||
if (!guessData && isModView && round.allGuesses && round.allGuesses.length > 0) {
|
||||
// For mod view, use target user's guess if available (from user lookup),
|
||||
// otherwise reported user's guess (from reports), otherwise first player's guess
|
||||
const targetUserGuess = targetUserId ?
|
||||
round.allGuesses.find(g => g.playerId === targetUserId) : null;
|
||||
const reportedUserGuess = reportedUserId ?
|
||||
round.allGuesses.find(g => g.playerId === reportedUserId) : null;
|
||||
const fallbackGuess = targetUserGuess || reportedUserGuess || round.allGuesses[0];
|
||||
|
||||
guessData = {
|
||||
guessLat: fallbackGuess.guessLat,
|
||||
guessLong: fallbackGuess.guessLong,
|
||||
points: fallbackGuess.points,
|
||||
timeTaken: fallbackGuess.timeTaken,
|
||||
xpEarned: fallbackGuess.xpEarned || 0,
|
||||
usedHint: fallbackGuess.usedHint || false
|
||||
};
|
||||
}
|
||||
|
||||
if (!guessData) {
|
||||
// User didn't participate in this round
|
||||
return null;
|
||||
}
|
||||
|
||||
// For duels and multiplayer, include players data
|
||||
let players = {};
|
||||
if (round.allGuesses && round.allGuesses.length > 0) {
|
||||
round.allGuesses.forEach(guess => {
|
||||
players[guess.playerId] = {
|
||||
username: guess.username,
|
||||
points: guess.points,
|
||||
lat: guess.guessLat,
|
||||
long: guess.guessLong,
|
||||
timeTaken: guess.timeTaken
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
lat: round.location.lat,
|
||||
long: round.location.long,
|
||||
panoId: round.location.panoId, // Include panoId for Google Maps Street View
|
||||
guessLat: guessData.guessLat,
|
||||
guessLong: guessData.guessLong,
|
||||
points: guessData.points,
|
||||
timeTaken: guessData.timeTaken,
|
||||
xpEarned: guessData.xpEarned,
|
||||
usedHint: guessData.usedHint,
|
||||
players: players // Include players data for duels/multiplayer
|
||||
};
|
||||
}).filter(round => round !== null); // Remove null entries
|
||||
|
||||
const totalPoints = transformedHistory.reduce((sum, round) => sum + round.points, 0);
|
||||
const totalTime = transformedHistory.reduce((sum, round) => sum + round.timeTaken, 0);
|
||||
const maxPoints = fullGameData.result.maxPossiblePoints;
|
||||
|
||||
// Determine if this is a duel
|
||||
const isDuel = fullGameData.gameType === 'ranked_duel';
|
||||
|
||||
// Find the perspective player (the user whose view we're showing)
|
||||
// For mod view, use target user if available, otherwise reported user, otherwise first player
|
||||
const findPerspectivePlayer = () => {
|
||||
// For mod view, first try to use target user (from user lookup)
|
||||
if (isModView && targetUserId) {
|
||||
const player = fullGameData.players.find(p => p.accountId === targetUserId || p.playerId === targetUserId);
|
||||
if (player) return player;
|
||||
}
|
||||
|
||||
// For mod view, try to use reported user (from reports)
|
||||
if (isModView && reportedUserId) {
|
||||
const player = fullGameData.players.find(p => p.accountId === reportedUserId || p.playerId === reportedUserId);
|
||||
if (player) return player;
|
||||
}
|
||||
|
||||
// Try to match by currentUserId (for regular users viewing their own games)
|
||||
let player = fullGameData.players.find(p => p.accountId === fullGameData.currentUserId);
|
||||
if (player) return player;
|
||||
|
||||
// Fallback to first player
|
||||
return fullGameData.players[0];
|
||||
};
|
||||
|
||||
const perspectivePlayer = findPerspectivePlayer();
|
||||
|
||||
// For duels, prepare the data structure
|
||||
let duelData = null;
|
||||
if (isDuel) {
|
||||
// Support both data structures: userPlayer (gameDetails API) and userStats (gameHistory API)
|
||||
// For mod view, use the perspective player if userPlayer is not available
|
||||
const playerData = fullGameData.userPlayer || fullGameData.userStats || perspectivePlayer;
|
||||
|
||||
if (playerData) {
|
||||
const eloData = playerData.elo || {};
|
||||
duelData = {
|
||||
oldElo: eloData.before || eloData.oldElo || 0,
|
||||
newElo: eloData.after || eloData.newElo || 0,
|
||||
eloDiff: eloData.change || 0,
|
||||
winner: playerData.finalRank === 1,
|
||||
draw: fullGameData.result?.isDraw || false,
|
||||
// Add opponent info if available (find player that isn't the perspective player)
|
||||
opponent: fullGameData.players?.find(p => p.accountId !== perspectivePlayer?.accountId && p.playerId !== perspectivePlayer?.playerId)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For multiplayer games, prepare the state
|
||||
let multiplayerState = null;
|
||||
if (fullGameData.gameType !== 'singleplayer') {
|
||||
// Use the perspective player's ID
|
||||
const myPlayerId = perspectivePlayer?.playerId || perspectivePlayer?.accountId || fullGameData.currentUserId;
|
||||
|
||||
multiplayerState = {
|
||||
gameData: {
|
||||
myId: myPlayerId, // Use the correct playerId that matches the game data
|
||||
players: fullGameData.players.map(player => ({
|
||||
id: player.playerId,
|
||||
username: player.username,
|
||||
points: player.totalPoints,
|
||||
rank: player.finalRank
|
||||
})),
|
||||
duel: isDuel,
|
||||
history: fullGameData.rounds.map(round => ({
|
||||
players: round.allGuesses.map(guess => ({
|
||||
id: guess.playerId,
|
||||
username: guess.username,
|
||||
points: guess.points,
|
||||
lat: guess.guessLat,
|
||||
lng: guess.guessLong,
|
||||
timeTaken: guess.timeTaken
|
||||
})),
|
||||
location: {
|
||||
lat: round.location.lat,
|
||||
lng: round.location.long,
|
||||
panoId: round.location.panoId // Include panoId for multiplayer games too
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getClassName = () => {
|
||||
let className = styles.historicalGameView;
|
||||
if (isEntering) className += ` ${styles.entering}`;
|
||||
if (isExiting) className += ` ${styles.exiting}`;
|
||||
return className;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={getClassName()}>
|
||||
{/* Use the existing GameSummary component */}
|
||||
<GameSummary
|
||||
history={transformedHistory}
|
||||
points={totalPoints}
|
||||
time={totalTime}
|
||||
maxPoints={maxPoints}
|
||||
duel={isDuel}
|
||||
data={duelData}
|
||||
multiplayerState={multiplayerState}
|
||||
button1Press={handleBack}
|
||||
button1Text={text('backToHistory')}
|
||||
button2Press={null}
|
||||
button2Text=""
|
||||
hidden={false}
|
||||
session={session}
|
||||
gameId={game?.gameId || game?._id}
|
||||
options={{
|
||||
...options,
|
||||
onUsernameLookup: onUsernameLookup,
|
||||
isModView: options?.isModView,
|
||||
reportedUserId: options?.reportedUserId
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3187
components/home.js
Normal file
3187
components/home.js
Normal file
File diff suppressed because it is too large
Load diff
25
components/homeNotice.js
Normal file
25
components/homeNotice.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export default function HomeNotice({ shown, text }) {
|
||||
// similar to bootstrap alert
|
||||
// used in home page to notify about maintenance
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
textAlign: "center",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: "clamp(1em, 2.8vw, 2em)",
|
||||
marginTop: "20px",
|
||||
textAlign: "center",
|
||||
whiteSpace: "pre-line", // allow \n to render as new line
|
||||
}}
|
||||
>
|
||||
{text || "Loading..."}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/hooks/useMapSearch.js
Normal file
47
components/hooks/useMapSearch.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import config from "@/clientConfig";
|
||||
|
||||
export const useMapSearch = (session, setSearchResults, setSearchLoading) => {
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term) => {
|
||||
// Clear any pending debounced search
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (term.length > 0 && !process.env.NEXT_PUBLIC_COOLMATH) {
|
||||
// Show loading immediately when user types enough characters
|
||||
if (setSearchLoading) setSearchLoading(true);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const apiUrl = window.cConfig?.apiUrl || config().apiUrl;
|
||||
fetch(apiUrl + "/api/map/searchMap", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query: term, secret: session?.token?.secret }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSearchResults(data);
|
||||
if (setSearchLoading) setSearchLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to search maps");
|
||||
if (setSearchLoading) setSearchLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
if (setSearchLoading) setSearchLoading(false);
|
||||
}
|
||||
},
|
||||
[session?.token?.secret, setSearchResults, setSearchLoading]
|
||||
);
|
||||
|
||||
return { handleSearch };
|
||||
};
|
||||
186
components/infoModal.js
Normal file
186
components/infoModal.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useState } from "react";
|
||||
import { Modal } from "react-responsive-modal";
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
import { asset } from '@/lib/basePath';
|
||||
|
||||
export default function InfoModal({ shown, onClose }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
// State to handle screen navigation
|
||||
const [currentScreen, setCurrentScreen] = useState(1);
|
||||
|
||||
const handleNextClick = () => {
|
||||
setCurrentScreen(2);
|
||||
};
|
||||
|
||||
const handleLetsGoClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={shown}
|
||||
onClose={onClose}
|
||||
center
|
||||
styles={{
|
||||
modal: {
|
||||
backgroundColor: '#2d2d2d',
|
||||
padding: '20px',
|
||||
borderRadius: '10px',
|
||||
color: 'white',
|
||||
maxWidth: '600px',
|
||||
},
|
||||
closeButton: {
|
||||
scale: 0.5,
|
||||
backgroundColor: 'red',
|
||||
borderRadius: '50%',
|
||||
padding: '5px 10px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<center>
|
||||
{currentScreen === 1 ? (
|
||||
<>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'lime',
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
{text("welcomeToWorldGuessr")}!
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '5px',
|
||||
}}
|
||||
>
|
||||
🧐 {text("info1")}
|
||||
</p>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '5px',
|
||||
}}
|
||||
>
|
||||
🗺️ {text("info2")}
|
||||
</p>
|
||||
|
||||
<img
|
||||
src={asset("/tutorial1.png")}
|
||||
alt="Tutorial 1"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px', // Adds spacing between the content and button
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="nextButton"
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
background: '#4CAF50',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'block', // Forces the button to be on a new line
|
||||
width: '100%', // Ensures the button takes the full width
|
||||
}}
|
||||
onClick={handleNextClick}
|
||||
>
|
||||
{text("next")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'lime',
|
||||
marginBottom: '10px'
|
||||
|
||||
}}
|
||||
>
|
||||
{text("welcomeToWorldGuessr")}!
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '5px',
|
||||
}}
|
||||
>
|
||||
🎓 {text("info3")}
|
||||
</p>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '5px',
|
||||
}}
|
||||
>
|
||||
🌍 {text("info4")}
|
||||
</p>
|
||||
|
||||
<img
|
||||
src={asset("/tutorial2.png")}
|
||||
alt="Tutorial 2"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px', // Adds spacing between the content and button
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="letsGoButton"
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
background: '#4CAF50',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'block', // Forces the button to be on a new line
|
||||
width: '100%', // Ensures the button takes the full width
|
||||
}}
|
||||
onClick={handleLetsGoClick}
|
||||
>
|
||||
{text("letsGo")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</center>
|
||||
|
||||
<style jsx>{`
|
||||
.nextButton:hover, .letsGoButton:hover {
|
||||
background-color: #45a049;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
59
components/localizedHome.js
Normal file
59
components/localizedHome.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Home from "@/components/home";
|
||||
import { useEffect } from "react";
|
||||
import { navigate } from '@/lib/basePath';
|
||||
|
||||
export default function LocalizedHome({ path }) {
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
let language = "en";
|
||||
const langs = ["en", "es", "fr", "de", "ru"];
|
||||
if(typeof window !== "undefined") {
|
||||
|
||||
try {
|
||||
var userLang = navigator.language || navigator.userLanguage;
|
||||
// convert to 2 letter code
|
||||
userLang = userLang.split("-")[0];
|
||||
if(langs.includes(userLang)){
|
||||
language = userLang;
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try{
|
||||
let lang = window.localStorage.getItem("lang");
|
||||
console.log("in localstorage", lang);
|
||||
if(lang && langs.includes(lang)) {
|
||||
language = lang;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
const currentQueryParams = new URLSearchParams(window.location.search);
|
||||
const qPsuffix = currentQueryParams.toString() ? `?${currentQueryParams.toString()}` : "";
|
||||
|
||||
if(path === "auto") {
|
||||
if(language !== "en") {
|
||||
console.log("Redirecting to", language);
|
||||
window.location.href = `${navigate('/' + language)}${qPsuffix}`;
|
||||
}
|
||||
} else {
|
||||
if(path !== language) {
|
||||
console.log("Redirecting to", language);
|
||||
window.location.href = `${navigate('/' + language)}${qPsuffix}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}, []);
|
||||
return (
|
||||
<Home />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
24
components/maintenanceTime.js
Normal file
24
components/maintenanceTime.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// OVHcloud maintenance: Tuesday 2025-12-23, 13:00-15:00 UTC
|
||||
|
||||
export const MAINTENANCE_START_UTC = new Date("2025-12-23T13:00:00Z");
|
||||
export const MAINTENANCE_END_UTC = new Date("2025-12-23T15:00:00Z");
|
||||
|
||||
export default function getTimeString() {
|
||||
const startUTC = MAINTENANCE_START_UTC;
|
||||
const endUTC = MAINTENANCE_END_UTC;
|
||||
|
||||
// Get components individually in user's local time
|
||||
const start = startUTC.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const end = endUTC.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const tz = endUTC.toLocaleTimeString(undefined, { timeZoneName: 'short' }).split(' ').pop();
|
||||
|
||||
return `${start} - ${end} ${tz}`;
|
||||
}
|
||||
|
||||
export function getMaintenanceDate() {
|
||||
return MAINTENANCE_START_UTC.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
221
components/mapGuessrModal.js
Normal file
221
components/mapGuessrModal.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import React from 'react';
|
||||
import { Modal } from "react-responsive-modal";
|
||||
|
||||
export default function MapGuessrModal({ isOpen, onClose }) {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
center
|
||||
showCloseIcon={false}
|
||||
classNames={{
|
||||
modal: "mapguessr-modal",
|
||||
modalContainer: "mapguessr-modal-container"
|
||||
}}
|
||||
styles={{
|
||||
modal: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
modalContainer: {
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
animationDuration={300}
|
||||
closeOnEsc={true}
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<div className="mapguessr-container">
|
||||
{/* Header with close button */}
|
||||
<div className="mapguessr-header">
|
||||
<div className="mapguessr-title-section">
|
||||
<button
|
||||
className="mapguessr-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Close MapGuessr"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<h1 className="mapguessr-title">MapGuessr</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded iframe */}
|
||||
<div className="mapguessr-iframe-container">
|
||||
<iframe
|
||||
src="https://mapguessr.worldguessr.com"
|
||||
className="mapguessr-iframe"
|
||||
title="MapGuessr"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allow="geolocation; microphone; camera; fullscreen"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-modals allow-presentation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.mapguessr-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mapguessr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
min-height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapguessr-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mapguessr-title {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mapguessr-close-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mapguessr-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mapguessr-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mapguessr-iframe-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for flex child */
|
||||
}
|
||||
|
||||
.mapguessr-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.mapguessr-header {
|
||||
padding: 12px 16px;
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.mapguessr-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mapguessr-close-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile screens */
|
||||
@media (max-width: 480px) {
|
||||
.mapguessr-header {
|
||||
padding: 10px 12px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mapguessr-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mapguessr-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure no scrollbars */
|
||||
.mapguessr-container,
|
||||
.mapguessr-iframe-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Global modal styles to prevent scrolling */
|
||||
:global(.mapguessr-modal) {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
:global(.mapguessr-modal-container) {
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Prevent body scroll when modal is open */
|
||||
:global(body:has(.mapguessr-modal)) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Handle iOS viewport issues */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.mapguessr-container {
|
||||
height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent zoom on iOS */
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
.mapguessr-iframe {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
239
components/maps/makeMap.js
Normal file
239
components/maps/makeMap.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useState } from "react";
|
||||
import mapConst from "./mapConst";
|
||||
import { toast } from "react-toastify";
|
||||
import { FaCopy } from "react-icons/fa6";
|
||||
import parseMapData from "../utils/parseMapData";
|
||||
|
||||
export default function MakeMapForm({ map, setMap, createMap }) {
|
||||
// map => { slug, name, created_at, created_by, plays, hearts, data, _id, created_by_name, description_short, description_long }
|
||||
const [formData, setFormData] = useState({
|
||||
name: map.name,
|
||||
description_short: map.description_short,
|
||||
description_long: map.description_long,
|
||||
data: map.data || []
|
||||
});
|
||||
const [uploaded, setUploaded] = useState(false);
|
||||
|
||||
const handleFormChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleDataChange = (index, value) => {
|
||||
const updatedData = [...formData.data];
|
||||
updatedData[index] = value;
|
||||
setFormData({ ...formData, data: updatedData });
|
||||
};
|
||||
|
||||
const handleAddUrl = () => {
|
||||
setFormData({ ...formData, data: [...formData.data, ""] });
|
||||
};
|
||||
|
||||
const handleDeleteUrl = (index) => {
|
||||
const updatedData = formData.data.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, data: updatedData });
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if(formData.data.length < mapConst.MIN_LOCATIONS) {
|
||||
toast.error(`Need at least ${mapConst.MIN_LOCATIONS} locations`);
|
||||
return;
|
||||
}
|
||||
if(formData.data.length > mapConst.MAX_LOCATIONS) {
|
||||
toast.error(`Too many locations`);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!formData.name || !formData.description_short) {
|
||||
toast.error("Missing required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
setMap({ ...map, ...formData, progress: true });
|
||||
createMap(formData);
|
||||
};
|
||||
|
||||
|
||||
function handleFileUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target.result;
|
||||
try {
|
||||
let parsed = parseMapData(text);
|
||||
if (!parsed) {
|
||||
toast.error("Failed to parse file");
|
||||
return
|
||||
}
|
||||
parsed = parsed.map((loc) => JSON.stringify(loc));
|
||||
if(parsed.length > mapConst.MAX_LOCATIONS) {
|
||||
toast.error(`More than ${mapConst.MAX_LOCATIONS} locations in file`);
|
||||
return;
|
||||
}
|
||||
toast.success("Parsed " + parsed.length + " locations");
|
||||
setUploaded(true);
|
||||
setFormData({ ...formData, data: parsed });
|
||||
} catch (e) {
|
||||
toast.error("Invalid file format");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="make-map-form" style={{ gap: 0 }}>
|
||||
<h2>Rules</h2>
|
||||
<li>Use a descriptive name</li>
|
||||
<li>Have helpful and informative descriptions</li>
|
||||
<li>Add at least {mapConst.MIN_LOCATIONS} locations</li>
|
||||
<li>You can make 1 map an hour</li>
|
||||
<li>Keep all details in English (no NSFW)</li>
|
||||
{/* no bullet */}
|
||||
<li style={{marginTop: '10px',fontSize: '1.4em', listStyleType: 'none'}}
|
||||
>Need help? <a style={{color: 'cyan'}} href="https://discord.gg/ADw47GAyS5" target="_blank" rel="noreferrer">Join our Discord</a></li>
|
||||
</div>
|
||||
|
||||
<form className="make-map-form" onSubmit={handleSubmit}>
|
||||
<h2>Basic Info</h2>
|
||||
|
||||
<label>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
Name <span style={{ color: formData.name.length < mapConst.MIN_NAME_LENGTH ? 'red' : 'green', marginLeft: '8px' }}>({formData.name.length} / {mapConst.MAX_NAME_LENGTH})</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleFormChange}
|
||||
maxLength={mapConst.MAX_NAME_LENGTH}
|
||||
minLength={mapConst.MIN_NAME_LENGTH}
|
||||
style={{ display: 'block', marginTop: '8px' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
Short Description <span style={{ color: formData.description_short.length < mapConst.MIN_SHORT_DESCRIPTION_LENGTH ? 'red' : 'green', marginLeft: '8px' }}>({formData.description_short.length} / {mapConst.MAX_SHORT_DESCRIPTION_LENGTH})</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="description_short"
|
||||
value={formData.description_short}
|
||||
onChange={handleFormChange}
|
||||
maxLength={mapConst.MAX_SHORT_DESCRIPTION_LENGTH}
|
||||
minLength={mapConst.MIN_SHORT_DESCRIPTION_LENGTH}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
Long Description (Optional) <span style={{ color: formData.description_long.length > mapConst.MAX_LONG_DESCRIPTION_LENGTH ? 'red' : 'gray', marginLeft: '8px' }}>({formData.description_long.length} / {mapConst.MAX_LONG_DESCRIPTION_LENGTH})</span>
|
||||
</div>
|
||||
<textarea
|
||||
name="description_long"
|
||||
value={formData.description_long}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div className="make-map-form" style={{ gap: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
<h2 style={{marginBottom: '10px'}}>Locations</h2>
|
||||
|
||||
<button type="button" style={{padding:'3px',marginLeft: '10px'}} onClick={() => {
|
||||
// Copy the entered locations to the clipboard
|
||||
if(formData.data.length === 0) {
|
||||
toast.error("No locations to copy");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(JSON.stringify(formData.data));
|
||||
toast.success("Copied entered locations to clipboard");
|
||||
}}>
|
||||
<FaCopy />
|
||||
</button>
|
||||
|
||||
|
||||
{ formData.data.length < mapConst.MIN_LOCATIONS && <span style={{ color: 'red', marginLeft: '8px' }}>({mapConst.MIN_LOCATIONS - formData.data.length} more needed)</span> }
|
||||
{ formData.data.length >= mapConst.MIN_LOCATIONS && <span style={{ color: 'green', marginLeft: '8px' }}>({formData.data.length} / {mapConst.MAX_LOCATIONS})</span> }
|
||||
</div>
|
||||
{ !uploaded && (
|
||||
<>
|
||||
<h3>Either enter them Manually...</h3>
|
||||
|
||||
|
||||
<span style={{marginBottom: '5px'}}>
|
||||
|
||||
<li>Visit Google Maps on a desktop computer</li>
|
||||
<li>Drag the orange figure onto the map to open a streetview</li>
|
||||
<li>Copy the URL from the address bar of your browser into the textbox</li>
|
||||
<li>You can add also add JSON strings with {`{lat, lng, heading, pitch, zoom, panoId}`}</li>
|
||||
|
||||
</span>
|
||||
|
||||
{formData.data.map((url, index) => (
|
||||
<div key={index} className="url-input-container">
|
||||
<input
|
||||
type="text"
|
||||
name={`url-${index}`}
|
||||
value={url}
|
||||
onChange={(e) => handleDataChange(index, e.target.value)}
|
||||
className="url-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="delete-button"
|
||||
onClick={() => handleDeleteUrl(index)}
|
||||
>
|
||||
✖ {/* X symbol */}
|
||||
</button>
|
||||
{/* Placeholder for validation icon */}
|
||||
<span className="validation-icon">
|
||||
{/* You can insert your validation logic here */}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="add-button" onClick={handleAddUrl}>
|
||||
+ Add URL
|
||||
</button>
|
||||
<br/>
|
||||
</>
|
||||
)}
|
||||
<h3>
|
||||
{ uploaded ? "Bulk Uploaded":"...or Bulk Upload a file" }
|
||||
</h3>
|
||||
{ !uploaded && (
|
||||
<span>Supports JSON format from <a style={{color: "cyan"}} href="https://map-degen.vercel.app/" target="_blank" rel="noreferrer">map-degen.vercel.app</a></span>
|
||||
)}
|
||||
{ !uploaded && (
|
||||
<div>
|
||||
<label htmlFor="file-upload" className="add-button button" style={{width: 'fit-content', display: 'inline-block'}}>
|
||||
<input type="file" accept=".json" onChange={handleFileUpload} style={{overflow: 'hidden', width: 0, height: 0, opacity: 0}} id="file-upload" />
|
||||
Upload File
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
uploaded && (
|
||||
<button type="button" className="add-button" onClick={() => {
|
||||
setFormData({ ...formData, data: [] });
|
||||
setUploaded(false)
|
||||
}}>
|
||||
Clear Upload
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="make-map-form" style={{ gap: 0 }}>
|
||||
<button type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={map.progress}
|
||||
>
|
||||
{map.progress ? "Loading..." : "Publish"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
components/maps/mapConst.js
Normal file
12
components/maps/mapConst.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
const MAP_CONST = {
|
||||
MIN_LOCATIONS: 5,
|
||||
MAX_LOCATIONS: 100000,
|
||||
MAX_NAME_LENGTH: 30,
|
||||
MIN_NAME_LENGTH: 3,
|
||||
MAX_SHORT_DESCRIPTION_LENGTH: 100,
|
||||
MIN_SHORT_DESCRIPTION_LENGTH: 20,
|
||||
MAX_LONG_DESCRIPTION_LENGTH: 1000,
|
||||
MIN_LONG_DESCRIPTION_LENGTH: 100,
|
||||
MIN_MAP_INTERVAL: 3600000 // 1 hour
|
||||
};
|
||||
export default MAP_CONST;
|
||||
269
components/maps/mapTile.js
Normal file
269
components/maps/mapTile.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { FaHeart, FaTrash, FaUser, FaMapMarkerAlt } from "react-icons/fa";
|
||||
import formatNumber from "../utils/fmtNumber";
|
||||
import { FaPencil } from "react-icons/fa6";
|
||||
|
||||
export default function MapTile({
|
||||
onPencilClick,
|
||||
showEditControls,
|
||||
map,
|
||||
onHeart,
|
||||
onClick,
|
||||
country,
|
||||
searchTerm,
|
||||
canHeart,
|
||||
showReviewOptions,
|
||||
secret,
|
||||
refreshHome,
|
||||
bgImage,
|
||||
forcedWidth
|
||||
}) {
|
||||
const backgroundImage = bgImage ? bgImage : (country ? `url("https://flagcdn.com/h240/${country?.toLowerCase()}.png")` : "");
|
||||
const [mapResubmittable, setMapResubmittable] = useState(map.resubmittable);
|
||||
|
||||
// Define escapeRegExp outside of highlightMatch so it exists before being called
|
||||
const escapeRegExp = (string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
const highlightMatch = (text, searchTerm) => {
|
||||
if (!searchTerm || !text || typeof searchTerm !== 'string') return text;
|
||||
if (searchTerm.length < 3) return text;
|
||||
|
||||
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
||||
|
||||
return text.split(regex).map((part, index) =>
|
||||
part?.toLowerCase() === searchTerm?.toLowerCase() ? (
|
||||
<span key={index} className="highlight-match">{part}</span>
|
||||
) : part
|
||||
);
|
||||
};
|
||||
|
||||
const handleHeartClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!canHeart) return;
|
||||
onHeart();
|
||||
};
|
||||
|
||||
// Rest of the component remains unchanged
|
||||
const onReview = (e, mapId, accepted) => {
|
||||
e.stopPropagation();
|
||||
let reject_reason = null;
|
||||
if (!accepted) {
|
||||
reject_reason = prompt("Please enter a reason for rejecting this map:");
|
||||
if (reject_reason === null) return;
|
||||
}
|
||||
|
||||
fetch(window.cConfig.apiUrl + `/api/map/approveRejectMap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
mapId,
|
||||
action: accepted ? 'approve' : 'reject',
|
||||
rejectReason: reject_reason,
|
||||
resubmittable: mapResubmittable
|
||||
})
|
||||
}).then(res => {
|
||||
res.json().then(data => {
|
||||
if (res.ok) {
|
||||
toast.success(data.message);
|
||||
refreshHome({ removeMap: mapId });
|
||||
} else {
|
||||
toast.error(data.message);
|
||||
refreshHome();
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to review the map. Please try again later.");
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to review the map. Please try again later.");
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (e, mapId) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (confirm("Are you sure you want to delete this map?")) {
|
||||
fetch(window.cConfig.apiUrl + `/api/map/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
mapId
|
||||
})
|
||||
}).then(res => {
|
||||
res.json().then(data => {
|
||||
if (res.ok) {
|
||||
toast.success(data.message);
|
||||
refreshHome();
|
||||
} else {
|
||||
toast.error(data.message);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to delete the map. Please try again later.");
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to delete the map. Please try again later.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`map-tile ${country ? 'country' : ''}`}
|
||||
onClick={onClick}
|
||||
style={backgroundImage ? { backgroundImage,
|
||||
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
width: forcedWidth ? forcedWidth : undefined
|
||||
} : {}}
|
||||
>
|
||||
<div className={`map-tile__header ${country ? 'country' : ''}`}>
|
||||
<div className="map-tile__mapdetails">
|
||||
<div className="map-tile__content">
|
||||
{/* Top section with title and actions */}
|
||||
<div className="map-tile__top-section">
|
||||
<div className="map-tile__name" title={map.name}>
|
||||
<h3>{highlightMatch(map.name, searchTerm)}</h3>
|
||||
|
||||
{/* Status indicators */}
|
||||
{!country && (map.in_review || map.reject_reason) && map.yours && !map.accepted && (
|
||||
<div className={`map-tile__status ${map.reject_reason ? 'rejected' : 'in-review'}`}>
|
||||
{!map.accepted && map.resubmittable && map.reject_reason && (
|
||||
<span>Rejected</span>
|
||||
)}
|
||||
{!map.accepted && !map.reject_reason && <span>In Review</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - only show if not country and has creator name and not in review */}
|
||||
{!country && map.created_by_name && !map.in_review && !map.reject_reason && (
|
||||
<div className="map-tile__actions">
|
||||
<button
|
||||
className={`map-tile__heart ${!canHeart ? 'disabled' : ''} ${map.hearted ? 'hearted' : ''}`}
|
||||
onClick={handleHeartClick}
|
||||
disabled={!canHeart}
|
||||
>
|
||||
{map.hearts} <FaHeart />
|
||||
</button>
|
||||
|
||||
{showEditControls && map.yours && (
|
||||
<div className="map-tile__controls">
|
||||
<button
|
||||
className="map-tile__edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
fetch(window.cConfig.apiUrl + `/api/map/action`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
action: 'get',
|
||||
mapId: map.id
|
||||
})
|
||||
}).then(res => {
|
||||
res.json().then(data => {
|
||||
if (res.ok) {
|
||||
const fullMap = data.map;
|
||||
onPencilClick({
|
||||
...map,
|
||||
data: fullMap.data,
|
||||
description_long: fullMap.description_long
|
||||
});
|
||||
} else {
|
||||
toast.error(data.message);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to retrieve the map data. Please try again later.");
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
toast.error("An error occurred while trying to retrieve the map data. Please try again later.");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaPencil />
|
||||
</button>
|
||||
<button
|
||||
className="map-tile__delete"
|
||||
onClick={(e) => onDelete(e, map.id)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section with author info - always at bottom */}
|
||||
<div className="map-tile__bottom-section">
|
||||
{!country && map.created_by_name && (
|
||||
<div className="map-tile__author">
|
||||
<FaUser size={12} />
|
||||
{!process.env.NEXT_PUBLIC_COOLMATH && (
|
||||
<>
|
||||
{highlightMatch(map.created_by_name, searchTerm)}
|
||||
•
|
||||
</>
|
||||
)}
|
||||
{map.accepted && (
|
||||
<span>
|
||||
<FaMapMarkerAlt size={12} />
|
||||
{formatNumber(map.locations, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review options for staff */}
|
||||
{showReviewOptions && (
|
||||
<div className="map-tile__review-options" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="accept" onClick={(e) => onReview(e, map.id, true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button className="reject" onClick={(e) => onReview(e, map.id, false)}>
|
||||
Reject
|
||||
</button>
|
||||
<label>
|
||||
Resubmittable?
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mapResubmittable}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setMapResubmittable(!mapResubmittable);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reject reason */}
|
||||
{map.yours && map.reject_reason && (
|
||||
<div className="map-tile__reject-reason">
|
||||
<strong>Reject Reason:</strong> {map.reject_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
components/maps/mapView.js
Normal file
586
components/maps/mapView.js
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { FaSearch, FaPlus, FaArrowLeft, FaMapMarkedAlt, FaChevronDown, FaChevronUp } from "react-icons/fa";
|
||||
import MakeMapForm from "./makeMap";
|
||||
import MapTile from "./mapTile";
|
||||
import { backupMapHome } from "../utils/backupMapHome.js";
|
||||
import config from "@/clientConfig";
|
||||
import { useMapSearch } from "../hooks/useMapSearch";
|
||||
import { asset } from '@/lib/basePath';
|
||||
|
||||
export default function MapView({
|
||||
gameOptions,
|
||||
mapModalClosing,
|
||||
setGameOptions,
|
||||
showOptions,
|
||||
showTimerOption,
|
||||
close,
|
||||
session,
|
||||
text,
|
||||
onMapClick,
|
||||
chosenMap,
|
||||
showAllCountriesOption,
|
||||
makeMap,
|
||||
setMakeMap,
|
||||
initMakeMap,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
searchResults,
|
||||
setSearchResults
|
||||
}) {
|
||||
const [mapHome, setMapHome] = useState({
|
||||
message: text("loading") + "...",
|
||||
});
|
||||
const [heartingMap, setHeartingMap] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState({});
|
||||
|
||||
const { handleSearch } = useMapSearch(session, setSearchResults, setSearchLoading);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch(searchTerm);
|
||||
}, [searchTerm, handleSearch]);
|
||||
|
||||
function refreshHome(removeMapId) {
|
||||
if (removeMapId) {
|
||||
setMapHome((prev) => {
|
||||
const newMapHome = { ...prev };
|
||||
Object.keys(newMapHome).forEach((section) => {
|
||||
newMapHome[section] = newMapHome[section].filter((m) => m.id !== removeMapId.removeMap);
|
||||
});
|
||||
return newMapHome;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
window.cConfig = config();
|
||||
|
||||
let backupMapHomeTimeout = setTimeout(() => {
|
||||
setMapHome(backupMapHome);
|
||||
setIsLoading(false);
|
||||
}, 5000);
|
||||
|
||||
const isAnon = !session?.token?.secret;
|
||||
const mapHomeUrl = window.cConfig.apiUrl + "/api/map/mapHome" + (isAnon ? "?anon=true" : "");
|
||||
fetch(mapHomeUrl, isAnon ? {
|
||||
method: "GET",
|
||||
} : {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: session?.token?.secret,
|
||||
inCG: window.inCrazyGames
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setMapHome(data);
|
||||
setIsLoading(false);
|
||||
clearTimeout(backupMapHomeTimeout);
|
||||
})
|
||||
.catch(() => {
|
||||
setMapHome(backupMapHome);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshHome();
|
||||
|
||||
// Add debounced resize listener to recalculate grid when window size changes
|
||||
let resizeTimeout;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
// Force re-render by updating state
|
||||
setExpandedSections(prev => ({ ...prev }));
|
||||
}, 150); // 150ms debounce
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
}, [session?.token?.secret]); function createMap(map) {
|
||||
if (!session?.token?.secret) {
|
||||
toast.error("Not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(window.cConfig?.apiUrl + "/api/map/action", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: makeMap.edit ? "edit" : "create",
|
||||
mapId: makeMap.mapId,
|
||||
secret: session?.token?.secret,
|
||||
name: map.name,
|
||||
description_short: map.description_short,
|
||||
description_long: map.description_long,
|
||||
data: map.data,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
let json;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (e) {
|
||||
toast.error("Max file limit 30mb");
|
||||
setMakeMap({ ...makeMap, progress: false });
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
toast.success("Map " + (makeMap.edit ? "edited" : "created"));
|
||||
setMakeMap(initMakeMap);
|
||||
refreshHome();
|
||||
} else {
|
||||
setMakeMap({ ...makeMap, progress: false });
|
||||
toast.error(json.message);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMakeMap({ ...makeMap, progress: false });
|
||||
toast.error("Unexpected Error creating map - 2");
|
||||
});
|
||||
}
|
||||
|
||||
function heartMap(map) {
|
||||
if (!session?.token?.secret) {
|
||||
toast.error("Not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
setHeartingMap(map.id);
|
||||
|
||||
fetch(window.cConfig?.apiUrl + "/api/map/heartMap", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: session?.token?.secret,
|
||||
mapId: map.id,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
setHeartingMap("");
|
||||
let json;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (e) {
|
||||
toast.error("Unexpected Error hearting map - 1");
|
||||
return;
|
||||
}
|
||||
if (res.ok && json.success) {
|
||||
toast(json.hearted ? text("heartedMap") : text("unheartedMap"), {
|
||||
type: json.hearted ? 'success' : 'info'
|
||||
});
|
||||
|
||||
const newHeartsCnt = json.hearts;
|
||||
setMapHome((prev) => {
|
||||
const newMapHome = { ...prev };
|
||||
Object.keys(newMapHome).forEach((section) => {
|
||||
newMapHome[section] = newMapHome[section].map((m) => {
|
||||
if (m.id === map.id) {
|
||||
m.hearts = newHeartsCnt;
|
||||
m.hearted = json.hearted;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
if (section === "likedMaps") {
|
||||
if (json.hearted) {
|
||||
newMapHome[section].push(map);
|
||||
} else {
|
||||
newMapHome[section] = newMapHome[section].filter((m) => m.id !== map.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
return newMapHome;
|
||||
});
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
setSearchResults((prev) => {
|
||||
return prev.map((m) => {
|
||||
if (m.id === map.id) {
|
||||
m.hearts = newHeartsCnt;
|
||||
m.hearted = json.hearted;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error(text(json.message || json.error || "unexpectedError"));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setHeartingMap("");
|
||||
console.log(e);
|
||||
toast.error("Unexpected Error hearting map - 2");
|
||||
});
|
||||
}
|
||||
|
||||
const hasResults = searchResults.length > 0 || Object.keys(mapHome)
|
||||
.filter((k) => k !== "message")
|
||||
.some((section) => {
|
||||
const mapsArray = Array.isArray(mapHome[section]) ? mapHome[section].filter(
|
||||
(map) =>
|
||||
map.name?.toLowerCase().includes(searchTerm?.toLowerCase()) ||
|
||||
map.description_short?.toLowerCase().includes(searchTerm?.toLowerCase()) ||
|
||||
map.created_by_name?.toLowerCase().includes(searchTerm?.toLowerCase())
|
||||
) : [];
|
||||
return mapsArray.length > 0;
|
||||
});
|
||||
|
||||
const toggleSection = (sectionKey) => {
|
||||
setExpandedSections(prev => {
|
||||
|
||||
// scroll to section top if being collapsed and not in view
|
||||
setTimeout(() => {
|
||||
const sectionElement = document.getElementById(sectionKey + "_map_view_section");
|
||||
if (sectionElement) {
|
||||
const sectionTop = sectionElement.getBoundingClientRect().top;
|
||||
const sectionHeight = 100;
|
||||
const windowHeight = window.innerHeight;
|
||||
// If the section is being collapsed and is not in view, scroll to it
|
||||
console.log(sectionTop, sectionHeight, windowHeight);
|
||||
if (prev[sectionKey] && (sectionTop < 0 || sectionTop + sectionHeight > windowHeight)) {
|
||||
sectionElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// toggle the section
|
||||
return{
|
||||
...prev,
|
||||
[sectionKey]: !prev[sectionKey]
|
||||
}});
|
||||
};
|
||||
|
||||
const getRowsForSection = (section) => {
|
||||
if (section === "popular") return 2;
|
||||
if (section === "spotlight") return 1; // Spotlight should show 1 row by default
|
||||
return 2;
|
||||
};
|
||||
|
||||
const getMapsPerRow = (section = "default") => {
|
||||
// Get the actual container width and calculate how many tiles can fit
|
||||
const container = document.querySelector('.mapView');
|
||||
if (!container) {
|
||||
// Fallback to screen width based calculation
|
||||
if (window.innerWidth >= 1400) return 6;
|
||||
if (window.innerWidth >= 1200) return 5;
|
||||
if (window.innerWidth >= 1000) return 4;
|
||||
if (window.innerWidth >= 768) return 3;
|
||||
return 2;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth - 40; // Account for padding (20px each side)
|
||||
|
||||
// Get grid gap - starts at 16px but changes based on screen size
|
||||
let gridGap = 16;
|
||||
if (window.innerWidth <= 480) gridGap = 8;
|
||||
else if (window.innerWidth <= 768) gridGap = 12;
|
||||
|
||||
// Get minimum widths that match the CSS exactly - updated for better desktop experience
|
||||
let minTileWidth;
|
||||
if (section === "countryMaps") {
|
||||
// Country maps have smaller tiles
|
||||
if (window.innerWidth <= 360) minTileWidth = 100;
|
||||
else if (window.innerWidth <= 480) minTileWidth = 120;
|
||||
else if (window.innerWidth <= 768) minTileWidth = 150;
|
||||
else if (window.innerWidth <= 1000) minTileWidth = 170;
|
||||
else if (window.innerWidth <= 1200) minTileWidth = 180;
|
||||
else minTileWidth = 180;
|
||||
} else {
|
||||
// Regular maps - increased minimum widths for better desktop experience
|
||||
if (window.innerWidth <= 360) minTileWidth = 120;
|
||||
else if (window.innerWidth <= 480) minTileWidth = 140;
|
||||
else if (window.innerWidth <= 768) minTileWidth = 170;
|
||||
else if (window.innerWidth <= 1000) minTileWidth = 190;
|
||||
else if (window.innerWidth <= 1200) minTileWidth = 200;
|
||||
else if (window.innerWidth <= 1400) minTileWidth = 220;
|
||||
else minTileWidth = 200;
|
||||
}
|
||||
|
||||
// Calculate how many tiles can fit using the same formula as CSS auto-fit
|
||||
const tilesPerRow = Math.floor((containerWidth + gridGap) / (minTileWidth + gridGap));
|
||||
return Math.max(1, tilesPerRow); // Ensure at least 1 tile per row
|
||||
}; if (makeMap.open) {
|
||||
return (
|
||||
<div className={`mapView ${mapModalClosing ? "slideout_right" : ""}`}>
|
||||
<div className="map-header">
|
||||
<div className="map-header-left">
|
||||
<button
|
||||
onClick={() => setMakeMap({ ...makeMap, open: false })}
|
||||
className="map-back-btn"
|
||||
>
|
||||
<FaArrowLeft /> {text("back")}
|
||||
</button>
|
||||
<h1 className="map-title">
|
||||
{makeMap?.edit ? "Edit Map" : "Make Map"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<MakeMapForm map={makeMap} setMap={setMakeMap} createMap={createMap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mapView ${mapModalClosing ? "slideout_right" : ""}`}>
|
||||
{/* Header */}
|
||||
<div className="map-header">
|
||||
<div className="map-header-left">
|
||||
<button onClick={close} className="map-back-btn">
|
||||
<FaArrowLeft /> {text("close")}
|
||||
</button>
|
||||
<h1 className="map-title">{text("maps")}</h1>
|
||||
</div>
|
||||
{session?.token?.secret && (
|
||||
<button
|
||||
onClick={() => setMakeMap({ ...makeMap, open: true })}
|
||||
className="map-create-btn"
|
||||
>
|
||||
<FaPlus /> Make Map
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="map-search-section">
|
||||
<div className="map-search-container">
|
||||
<FaSearch className="map-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={text("searchForMaps")}
|
||||
className="map-search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Options */}
|
||||
{showOptions && (
|
||||
<div className="map-options">
|
||||
{/* <div className="map-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="nm"
|
||||
checked={gameOptions.nm}
|
||||
onChange={(e) => setGameOptions({ ...gameOptions, nm: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="nm">{text('nm')}</label>
|
||||
</div>
|
||||
<div className="map-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="npz"
|
||||
checked={gameOptions.npz}
|
||||
onChange={(e) => setGameOptions({ ...gameOptions, npz: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="npz">{text('npz')}</label>
|
||||
</div>
|
||||
<div className="map-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showRoadName"
|
||||
checked={gameOptions.showRoadName}
|
||||
onChange={(e) => setGameOptions({ ...gameOptions, showRoadName: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="showRoadName">{text('showRoadName')}</label>
|
||||
|
||||
</div> */}
|
||||
|
||||
{/* <label>{text('degradedMaps')}</label>
|
||||
*/}
|
||||
|
||||
{/* re enable only NMPZ (basically setGameOptions both nm and npz to e.target.checked) */}
|
||||
<div>
|
||||
<label htmlFor="nmpz">{text('nmpz')} </label>
|
||||
<input id="nmpz"
|
||||
name="nmpz"
|
||||
type="checkbox" checked={gameOptions.nm && gameOptions.npz} onChange={(e) => {
|
||||
setGameOptions({ ...gameOptions, nm: e.target.checked, npz: e.target.checked })
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{showTimerOption && (
|
||||
<div className="map-option-timer">
|
||||
<label htmlFor="enableTimer">{text('enableTimer')} </label>
|
||||
<input id="enableTimer"
|
||||
name="enableTimer"
|
||||
type="checkbox" checked={gameOptions.timePerRound > 0} onChange={(e) => {
|
||||
setGameOptions({ ...gameOptions, timePerRound: e.target.checked ? 30 : 0 })
|
||||
}} />
|
||||
{gameOptions.timePerRound > 0 && (
|
||||
<div className="timer-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="300"
|
||||
step="10"
|
||||
value={gameOptions.timePerRound}
|
||||
onChange={(e) => setGameOptions({ ...gameOptions, timePerRound: parseInt(e.target.value) })}
|
||||
/>
|
||||
<span className="timer-slider-value">{gameOptions.timePerRound}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="maps-loading">
|
||||
<div className="maps-loading-spinner"></div>
|
||||
<div className="maps-loading-text">{text("loading")}...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{/* All Countries Option */}
|
||||
{showAllCountriesOption &&
|
||||
((searchTerm.length === 0) ||
|
||||
(text("allCountries")?.toLowerCase().includes(searchTerm?.toLowerCase()))) && (
|
||||
<div className="all-countries-tile">
|
||||
<MapTile
|
||||
bgImage={`url("${asset('/world.jpg')}")`}
|
||||
forcedWidth="300px"
|
||||
map={{ name: text("allCountries"), slug: "all" }}
|
||||
onClick={() => onMapClick({ name: text("allCountries"), slug: "all" })}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Sections */}
|
||||
{hasResults ? (
|
||||
// Ensure we have sections to iterate, and include "recent" if we have search results
|
||||
(() => {
|
||||
const sections = Object.keys(mapHome).filter((k) => k !== "message");
|
||||
// Add "recent" if not present but we have search results
|
||||
if (searchResults.length > 0 && !sections.includes("recent")) {
|
||||
sections.push("recent");
|
||||
}
|
||||
return sections;
|
||||
})()
|
||||
.filter((k) => (!process.env.NEXT_PUBLIC_COOLMATH) || k !== "recent")
|
||||
.map((section, si) => {
|
||||
const mapsArray = section === "recent" && searchResults.length > 0
|
||||
? searchResults
|
||||
: (Array.isArray(mapHome[section]) ? mapHome[section] : []).filter((map) =>
|
||||
map.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
map.description_short?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
map.created_by_name?.toLowerCase().includes(searchTerm?.toLowerCase())
|
||||
);
|
||||
|
||||
if (mapsArray.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedSections[section] || section === "recent";
|
||||
const rows = getRowsForSection(section);
|
||||
const mapsPerRow = getMapsPerRow(section);
|
||||
const defaultMaxMaps = rows * mapsPerRow;
|
||||
const shouldShowExpandButton = mapsArray.length > defaultMaxMaps && section !== "recent";
|
||||
const displayedMaps = isExpanded ? mapsArray : mapsArray.slice(0, defaultMaxMaps);
|
||||
|
||||
return (
|
||||
<div key={si} className="map-section">
|
||||
<h2
|
||||
id={section + "_map_view_section"}
|
||||
className="map-section-title"
|
||||
>
|
||||
{text(section)}
|
||||
{["myMaps", "likedMaps", "reviewQueue"].includes(section) &&
|
||||
` (${mapsArray.length})`}
|
||||
</h2>
|
||||
<div className="map-section-container">
|
||||
<div className={`map-grid ${section === "countryMaps" ? "country-maps" : ""} ${section === "popular" ? "popular-maps" : ""} ${section === "spotlight" ? "spotlight-maps" : ""} ${!isExpanded && section !== "recent" ? "collapsed" : "expanded"}`}>
|
||||
{displayedMaps.map((map, i) => (
|
||||
<MapTile
|
||||
key={map.id || i}
|
||||
map={map}
|
||||
canHeart={session?.token?.secret && heartingMap !== map.id}
|
||||
onClick={() => onMapClick(map)}
|
||||
country={map.countryMap}
|
||||
searchTerm={searchTerm}
|
||||
secret={session?.token?.secret}
|
||||
refreshHome={refreshHome}
|
||||
showEditControls={
|
||||
(map.yours && section === "myMaps") ||
|
||||
session?.token?.staff
|
||||
}
|
||||
showReviewOptions={
|
||||
session?.token?.staff && section === "reviewQueue"
|
||||
}
|
||||
onPencilClick={(map) => {
|
||||
setMakeMap({
|
||||
...initMakeMap,
|
||||
open: true,
|
||||
edit: true,
|
||||
mapId: map.id,
|
||||
name: map.name,
|
||||
description_short: map.description_short,
|
||||
description_long: map.description_long,
|
||||
data: map.data.map((loc) => JSON.stringify(loc)),
|
||||
});
|
||||
}}
|
||||
onHeart={() => heartMap(map)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shouldShowExpandButton && (
|
||||
<button
|
||||
className="show-more-btn"
|
||||
onClick={() => toggleSection(section)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<FaChevronUp />
|
||||
Show Less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaChevronDown />
|
||||
Show All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : searchLoading ? (
|
||||
<div className="maps-loading">
|
||||
<div className="maps-loading-spinner"></div>
|
||||
<div className="maps-loading-text">{text("loading")}...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-results">
|
||||
<FaMapMarkedAlt className="no-results-icon" />
|
||||
<h3 className="no-results-title">{text("noResultsFound")}</h3>
|
||||
<p className="no-results-text">
|
||||
Try adjusting your search terms or browse our featured maps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
components/maps/mapsModal.js
Normal file
168
components/maps/mapsModal.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import MapView from "./mapView";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "react-toastify";
|
||||
import { Modal } from "react-responsive-modal";
|
||||
import { asset, navigate } from '@/lib/basePath';
|
||||
// import { useMapSearch } from "../hooks/useMapSearch"; // REMOVED TO FIX DUPLICATE SEARCH CALLS - MapView handles search
|
||||
|
||||
const initMakeMap = {
|
||||
open: false,
|
||||
progress: false,
|
||||
name: "",
|
||||
description_short: "",
|
||||
description_long: "",
|
||||
data: "",
|
||||
edit: false,
|
||||
mapId: "",
|
||||
};
|
||||
export default function MapsModal({ gameOptions, mapModalClosing, setGameOptions, shown, onClose, session, text, customChooseMapCallback, chosenMap, showAllCountriesOption, showOptions, showTimerOption }) {
|
||||
const [makeMap, setMakeMap] = useState(initMakeMap);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
|
||||
// REMOVED: const { handleSearch } = useMapSearch(session, setSearchResults);
|
||||
// REMOVED: useEffect for handleSearch - MapView now handles all search logic to avoid duplicate API calls
|
||||
|
||||
const handleMapClick = (map) => {
|
||||
if (customChooseMapCallback) {
|
||||
customChooseMapCallback(map);
|
||||
} else {
|
||||
window.location.href = `${navigate('/map')}?s=${map.slug}${window.location.search.includes("crazygames") ? "&crazygames=true" : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (!shown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
classNames={{ modal: "g2_modal" }}
|
||||
styles={{
|
||||
modal: styles.modalShell,
|
||||
overlay: styles.overlayDisable // Disable library's overlay scroll behavior
|
||||
}}
|
||||
open={shown}
|
||||
onClose={onClose}
|
||||
showCloseIcon={false}
|
||||
animationDuration={0}
|
||||
blockScroll={false} // Critical: prevent library from blocking body scroll
|
||||
closeOnOverlayClick={true}
|
||||
>
|
||||
<div className={`g2_nav_ui map-modal-sidebar ${mapModalClosing ? "g2_slide_out" : ""} desktop`}>
|
||||
<div className="g2_nav_hr desktop"></div>
|
||||
{/* {!makeMap.open && (
|
||||
<>
|
||||
<div className="mapSearch">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={text("searchForMaps")}
|
||||
className="g2_input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="g2_nav_hr"></div>
|
||||
</>
|
||||
)} */}
|
||||
|
||||
|
||||
{!makeMap.open && (
|
||||
<div className="g2_nav_group map_categories">
|
||||
<button className="g2_nav_text singleplayer comm_map_category_header"
|
||||
onClick={() => document.getElementById("countryMaps_map_view_section")?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>{text("countryMaps")}</button>
|
||||
<button className="g2_nav_text singleplayer comm_map_category_header"
|
||||
onClick={() => document.getElementById("spotlight_map_view_section")?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>{text("spotlight")}</button>
|
||||
<button className="g2_nav_text singleplayer comm_map_category_header"
|
||||
onClick={() => document.getElementById("popular_map_view_section")?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>{text("popular")}</button>
|
||||
<button className="g2_nav_text singleplayer comm_map_category_header"
|
||||
onClick={() => document.getElementById("recent_map_view_section")?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>{text("recent")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="g2_nav_hr"></div>
|
||||
{!makeMap.open && (
|
||||
|
||||
<button className="g2_nav_text singleplayer red" onClick={onClose}>{text("back")}</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Single scroll container: only this element scrolls on iOS */}
|
||||
<div className="g2_content map-modal-content" style={styles.scrollWrap}>
|
||||
<div style={styles.modalContent}>
|
||||
<MapView
|
||||
mapModalClosing={mapModalClosing}
|
||||
showOptions={showOptions}
|
||||
showTimerOption={showTimerOption}
|
||||
showAllCountriesOption={showAllCountriesOption}
|
||||
chosenMap={chosenMap}
|
||||
close={onClose}
|
||||
session={session}
|
||||
text={text}
|
||||
onMapClick={handleMapClick}
|
||||
gameOptions={gameOptions}
|
||||
setGameOptions={setGameOptions}
|
||||
makeMap={makeMap} setMakeMap={setMakeMap} initMakeMap={initMakeMap}
|
||||
searchTerm={searchTerm} setSearchTerm={setSearchTerm}
|
||||
searchResults={searchResults} setSearchResults={setSearchResults}
|
||||
/>
|
||||
</div>
|
||||
{/*<button style={styles.closeButton} onClick={onClose}>X</button>*/}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
// Full-viewport modal wrapper - fixed container, no scrolling
|
||||
modalShell: {
|
||||
background: `linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 30, 15, 0.6) 100%), url("${asset('/street2.webp')}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
boxShadow: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
height: "100vh",
|
||||
maxHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "stretch",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
// Sole scrollable area - critical iOS fixes
|
||||
scrollWrap: {
|
||||
height: "100%", // Use 100% instead of 100vh to avoid iOS viewport issues
|
||||
width: "100%",
|
||||
overflowY: "scroll", // Force scroll instead of auto to prevent iOS boundary confusion
|
||||
overflowX: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
touchAction: "pan-y pinch-zoom", // Allow vertical pan and pinch
|
||||
overscrollBehavior: "contain",
|
||||
scrollbarGutter: "stable", // Prevent layout shift from scrollbar
|
||||
padding: "20px",
|
||||
position: "relative",
|
||||
zIndex: 1130,
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
boxSizing: "border-box",
|
||||
// iOS-specific boundary handling
|
||||
transform: "translateZ(0)", // Force hardware acceleration
|
||||
willChange: "scroll-position", // Optimize for scroll performance
|
||||
},
|
||||
// Content inside the scroll area - ensure it can be longer than container
|
||||
modalContent: {
|
||||
width: "100%",
|
||||
overflowY: "visible",
|
||||
overflowX: "hidden",
|
||||
paddingBottom: "40px",
|
||||
minHeight: "calc(100vh + 1px)", // Ensure content is always scrollable on iOS
|
||||
zIndex: 1130,
|
||||
},
|
||||
};
|
||||
66
components/merchModal.js
Normal file
66
components/merchModal.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import {Modal} from "react-responsive-modal";
|
||||
import { useTranslation } from '@/components/useTranslations'
|
||||
import { asset } from '@/lib/basePath';
|
||||
|
||||
export default function MerchModal({ shown, onClose, session }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Modal open={shown} onClose={onClose} center styles={{
|
||||
modal: {
|
||||
backgroundColor: 'black',
|
||||
},
|
||||
closeButton: {
|
||||
scale: 0.5
|
||||
}
|
||||
|
||||
}}>
|
||||
|
||||
<center>
|
||||
<h1 style={{
|
||||
marginBottom: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
}}>Play WorldGuessr ad-free!</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '10px',
|
||||
color: 'white',
|
||||
}}>
|
||||
Get our <i>limited time</i> T-shirt for $20 and remove all ads!
|
||||
<br/>
|
||||
Also comes with a shiny <span className="badge">supporter</span> badge in-game!
|
||||
</p>
|
||||
|
||||
<img src={asset("/merch.png")} style={{width: '100%', maxWidth: '400px', margin: '20px 0'}} />
|
||||
|
||||
<br/>
|
||||
|
||||
{ session && session.token && session.token.supporter ? <p style={{
|
||||
fontSize: '16px',
|
||||
marginBottom: '10px',
|
||||
color: 'white',
|
||||
}}>
|
||||
You are already a supporter!
|
||||
</p> : (
|
||||
<button className="toggleMap" style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'black',
|
||||
background: 'gold',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer'
|
||||
}} onClick={() => {
|
||||
// open https://tshirt.worldguessr.com
|
||||
window.open("https://tshirt.worldguessr.com", "_blank");
|
||||
}}>
|
||||
Let's Go!
|
||||
</button>
|
||||
)}
|
||||
</center>
|
||||
|
||||
</ Modal>
|
||||
)
|
||||
}
|
||||
2538
components/modDashboard.js
Normal file
2538
components/modDashboard.js
Normal file
File diff suppressed because it is too large
Load diff
584
components/moderationView.js
Normal file
584
components/moderationView.js
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '@/components/useTranslations';
|
||||
|
||||
export default function ModerationView({ session }) {
|
||||
const { t: text } = useTranslation("common");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
// Check if user is currently suspended
|
||||
const isBanned = session?.token?.banned;
|
||||
const banType = session?.token?.banType;
|
||||
const banExpiresAt = session?.token?.banExpiresAt;
|
||||
const banPublicNote = session?.token?.banPublicNote;
|
||||
const pendingNameChange = session?.token?.pendingNameChange;
|
||||
const pendingNameChangePublicNote = session?.token?.pendingNameChangePublicNote;
|
||||
|
||||
// Default to history tab if user is suspended so they see the reason
|
||||
const [activeSection, setActiveSection] = useState(isBanned || pendingNameChange ? 'history' : 'refunds');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!session?.token?.secret) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(window.cConfig.apiUrl + '/api/userModerationData', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret: session.token.secret })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch moderation data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [session?.token?.secret]);
|
||||
|
||||
// Calculate time remaining for temp ban
|
||||
const getTimeRemaining = (expiresAt) => {
|
||||
if (!expiresAt) return null;
|
||||
const now = new Date();
|
||||
const expires = new Date(expiresAt);
|
||||
const diff = expires - now;
|
||||
|
||||
if (diff <= 0) return text("expired");
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
const dayText = days > 1 ? text("daysPlural") : text("daysSingular");
|
||||
const hourText = hours !== 1 ? text("hourPlural") : text("hourSingular");
|
||||
const minuteText = minutes !== 1 ? text("minutePlural") : text("minuteSingular");
|
||||
|
||||
if (days > 0) return `${days} ${dayText}, ${hours} ${hourText}`;
|
||||
if (hours > 0) return `${hours} ${hourText}, ${minutes} ${minuteText}`;
|
||||
return `${minutes} ${minuteText}`;
|
||||
};
|
||||
|
||||
const containerStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'clamp(15px, 4vw, 30px)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'clamp(10px, 3vw, 20px)',
|
||||
padding: 'clamp(15px, 4vw, 30px)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: 'clamp(20px, 4vw, 32px)',
|
||||
fontWeight: 600,
|
||||
marginBottom: 'clamp(10px, 3vw, 20px)',
|
||||
color: 'white',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
const tabContainerStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 'clamp(8px, 2vw, 15px)',
|
||||
marginBottom: 'clamp(15px, 4vw, 25px)',
|
||||
flexWrap: 'wrap'
|
||||
};
|
||||
|
||||
const tabStyle = (isActive) => ({
|
||||
padding: 'clamp(8px, 2vw, 12px) clamp(16px, 4vw, 24px)',
|
||||
borderRadius: 'clamp(8px, 2vw, 12px)',
|
||||
background: isActive ? 'rgba(255, 215, 0, 0.2)' : 'rgba(255, 255, 255, 0.05)',
|
||||
border: isActive ? '2px solid #ffd700' : '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: isActive ? '#ffd700' : '#b0b0b0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: isActive ? 'bold' : 'normal',
|
||||
fontSize: 'clamp(12px, 3vw, 16px)',
|
||||
transition: 'all 0.3s ease'
|
||||
});
|
||||
|
||||
const itemStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 'clamp(8px, 2vw, 12px)',
|
||||
padding: 'clamp(12px, 3vw, 18px)',
|
||||
marginBottom: 'clamp(8px, 2vw, 12px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
};
|
||||
|
||||
const emptyStyle = {
|
||||
textAlign: 'center',
|
||||
color: '#888',
|
||||
padding: 'clamp(20px, 5vw, 40px)',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)'
|
||||
};
|
||||
|
||||
const statsGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: 'clamp(10px, 3vw, 15px)',
|
||||
marginBottom: 'clamp(15px, 4vw, 25px)'
|
||||
};
|
||||
|
||||
const statItemStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 'clamp(8px, 2vw, 12px)',
|
||||
padding: 'clamp(10px, 2.5vw, 15px)',
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return '#ffd700';
|
||||
case 'action_taken': return '#4caf50';
|
||||
case 'ignored': return '#888';
|
||||
default: return '#b0b0b0';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return text("reportStatusOpen");
|
||||
case 'action_taken': return text("reportStatusActionTaken");
|
||||
case 'ignored': return text("reportStatusIgnored");
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonText = (reason) => {
|
||||
switch (reason) {
|
||||
case 'inappropriate_username': return text("reportReasonInappropriateUsername");
|
||||
case 'cheating': return text("reportReasonCheating");
|
||||
case 'other': return text("reportReasonOther");
|
||||
default: return reason;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#b0b0b0' }}>
|
||||
{text("loadingModerationData")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#f44336' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Suspension banner styles
|
||||
const suspensionBannerStyle = {
|
||||
background: 'linear-gradient(135deg, rgba(244, 67, 54, 0.2) 0%, rgba(183, 28, 28, 0.3) 100%)',
|
||||
border: '2px solid #f44336',
|
||||
borderRadius: 'clamp(10px, 3vw, 20px)',
|
||||
padding: 'clamp(20px, 5vw, 35px)',
|
||||
marginBottom: 'clamp(15px, 4vw, 25px)',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 8px 32px rgba(244, 67, 54, 0.3)',
|
||||
};
|
||||
|
||||
const nameChangeBannerStyle = {
|
||||
background: 'linear-gradient(135deg, rgba(255, 152, 0, 0.2) 0%, rgba(230, 81, 0, 0.3) 100%)',
|
||||
border: '2px solid #ff9800',
|
||||
borderRadius: 'clamp(10px, 3vw, 20px)',
|
||||
padding: 'clamp(20px, 5vw, 35px)',
|
||||
marginBottom: 'clamp(15px, 4vw, 25px)',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 8px 32px rgba(255, 152, 0, 0.3)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Suspension Banner - Show prominently if user is banned */}
|
||||
{isBanned && !pendingNameChange && (
|
||||
<div style={suspensionBannerStyle}>
|
||||
<div style={{ fontSize: 'clamp(36px, 8vw, 56px)', marginBottom: '15px' }}>
|
||||
🚫
|
||||
</div>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(22px, 5vw, 32px)',
|
||||
color: '#f44336',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '15px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '2px'
|
||||
}}>
|
||||
{banType === 'temporary' ? text("accountTempSuspended") : text("accountSuspended")}
|
||||
</h2>
|
||||
|
||||
{banType === 'temporary' && banExpiresAt && (
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
padding: '15px 25px',
|
||||
borderRadius: '12px',
|
||||
display: 'inline-block',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
<div style={{ color: '#b0b0b0', fontSize: 'clamp(12px, 2.5vw, 14px)', marginBottom: '5px' }}>
|
||||
{text("timeRemaining").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ color: '#ffd700', fontSize: 'clamp(20px, 4vw, 28px)', fontWeight: 'bold' }}>
|
||||
{getTimeRemaining(banExpiresAt)}
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 'clamp(11px, 2.5vw, 13px)', marginTop: '5px' }}>
|
||||
{text("expires")}: {new Date(banExpiresAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{banPublicNote && (
|
||||
<div style={{
|
||||
marginTop: '15px',
|
||||
padding: '15px 20px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '10px',
|
||||
maxWidth: '500px',
|
||||
margin: '15px auto 0'
|
||||
}}>
|
||||
<div style={{ color: '#b0b0b0', fontSize: 'clamp(11px, 2.5vw, 13px)', marginBottom: '8px' }}>
|
||||
{text("reason").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ color: '#e0e0e0', fontSize: 'clamp(14px, 3vw, 16px)', lineHeight: '1.5' }}>
|
||||
{banPublicNote}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{
|
||||
color: '#b0b0b0',
|
||||
fontSize: 'clamp(12px, 2.5vw, 14px)',
|
||||
marginTop: '20px',
|
||||
maxWidth: '450px',
|
||||
margin: '20px auto 0',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
{banType === 'temporary'
|
||||
? text("suspensionExplanationTemp")
|
||||
: text("suspensionExplanationPerm")}
|
||||
</p>
|
||||
|
||||
{/* Appeal Instructions */}
|
||||
<div style={{
|
||||
marginTop: '25px',
|
||||
padding: '20px',
|
||||
background: 'rgba(88, 101, 242, 0.15)',
|
||||
border: '1px solid rgba(88, 101, 242, 0.3)',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '450px',
|
||||
margin: '25px auto 0'
|
||||
}}>
|
||||
<p style={{ color: '#5865F2', fontWeight: 'bold', marginBottom: '10px', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
📋 {text("wantToAppeal") || "Want to Appeal?"}
|
||||
</p>
|
||||
<p style={{ color: '#b0b0b0', fontSize: 'clamp(12px, 2.5vw, 14px)', lineHeight: '1.6', marginBottom: '15px' }}>
|
||||
{text("appealInstructions") || "Appeals are handled through our Discord server. Join, verify, and create a ticket in #appeal-game-ban."}
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.gg/ADw47GAyS5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '10px 20px',
|
||||
background: '#5865F2',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'clamp(12px, 2.5vw, 14px)'
|
||||
}}
|
||||
>
|
||||
{text("joinDiscord") || "Join Discord"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Change Required Banner */}
|
||||
{pendingNameChange && (
|
||||
<div style={nameChangeBannerStyle}>
|
||||
<div style={{ fontSize: 'clamp(36px, 8vw, 56px)', marginBottom: '15px' }}>
|
||||
✏️
|
||||
</div>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(22px, 5vw, 32px)',
|
||||
color: '#ff9800',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
{text("usernameChangeRequired")}
|
||||
</h2>
|
||||
|
||||
{pendingNameChangePublicNote && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '15px 20px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '10px',
|
||||
maxWidth: '500px',
|
||||
margin: '0 auto 20px'
|
||||
}}>
|
||||
<div style={{ color: '#b0b0b0', fontSize: 'clamp(11px, 2.5vw, 13px)', marginBottom: '8px' }}>
|
||||
{text("reason").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ color: '#e0e0e0', fontSize: 'clamp(14px, 3vw, 16px)', lineHeight: '1.5' }}>
|
||||
{pendingNameChangePublicNote}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{
|
||||
color: '#e0e0e0',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)',
|
||||
maxWidth: '450px',
|
||||
margin: '0 auto 15px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
{text("usernameChangeExplanation")}
|
||||
</p>
|
||||
|
||||
<p style={{
|
||||
color: '#888',
|
||||
fontSize: 'clamp(12px, 2.5vw, 14px)'
|
||||
}}>
|
||||
{text("goToProfileToChange")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Card with Stats */}
|
||||
<div style={cardStyle}>
|
||||
<h2 style={titleStyle}>⚖️ {text("moderation")}</h2>
|
||||
|
||||
<div style={statsGridStyle}>
|
||||
<div style={statItemStyle}>
|
||||
<div style={{ fontSize: 'clamp(10px, 2.5vw, 12px)', color: '#b0b0b0', marginBottom: '4px' }}>
|
||||
{text("eloRefunded").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ fontSize: 'clamp(18px, 4vw, 24px)', color: '#4caf50', fontWeight: 'bold' }}>
|
||||
+{data?.totalEloRefunded || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div style={statItemStyle}>
|
||||
<div style={{ fontSize: 'clamp(10px, 2.5vw, 12px)', color: '#b0b0b0', marginBottom: '4px' }}>
|
||||
{text("reportsFiled").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ fontSize: 'clamp(18px, 4vw, 24px)', color: '#ffd700', fontWeight: 'bold' }}>
|
||||
{data?.reportStats?.total || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div style={statItemStyle}>
|
||||
<div style={{ fontSize: 'clamp(10px, 2.5vw, 12px)', color: '#b0b0b0', marginBottom: '4px' }}>
|
||||
{text("effectiveReports").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ fontSize: 'clamp(18px, 4vw, 24px)', color: '#4caf50', fontWeight: 'bold' }}>
|
||||
{data?.reportStats?.actionTaken || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={tabContainerStyle}>
|
||||
<button
|
||||
style={tabStyle(activeSection === 'refunds')}
|
||||
onClick={() => setActiveSection('refunds')}
|
||||
>
|
||||
💰 {text("eloRefundsTab")} ({data?.eloRefunds?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
style={tabStyle(activeSection === 'history')}
|
||||
onClick={() => setActiveSection('history')}
|
||||
>
|
||||
📋 {text("accountHistoryTab")} ({data?.moderationHistory?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
style={tabStyle(activeSection === 'reports')}
|
||||
onClick={() => setActiveSection('reports')}
|
||||
>
|
||||
🚩 {text("myReportsTab")} ({data?.submittedReports?.length || 0})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div style={cardStyle}>
|
||||
{/* ELO Refunds Section */}
|
||||
{activeSection === 'refunds' && (
|
||||
<>
|
||||
<h3 style={{ ...titleStyle, fontSize: 'clamp(16px, 3.5vw, 24px)' }}>
|
||||
{text("eloRefundsTitle")}
|
||||
</h3>
|
||||
<p style={{ color: '#888', textAlign: 'center', marginBottom: '20px', fontSize: 'clamp(12px, 2.5vw, 14px)' }}>
|
||||
{text("eloRefundsDesc")}
|
||||
</p>
|
||||
|
||||
{data?.eloRefunds?.length > 0 ? (
|
||||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{data.eloRefunds.map((refund) => (
|
||||
<div key={refund.id} style={itemStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<div>
|
||||
<span style={{ color: '#4caf50', fontWeight: 'bold', fontSize: 'clamp(16px, 3.5vw, 20px)' }}>
|
||||
{text("eloRefundAmount", { amount: refund.amount })}
|
||||
</span>
|
||||
<span style={{ color: '#888', marginLeft: '10px', fontSize: 'clamp(12px, 2.5vw, 14px)' }}>
|
||||
{text("fromBannedPlayer")}: <span style={{ color: '#f44336' }}>{refund.bannedUsername}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 'clamp(11px, 2.5vw, 13px)' }}>
|
||||
{formatDate(refund.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyStyle}>
|
||||
{text("noEloRefundsYet")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Moderation History Section */}
|
||||
{activeSection === 'history' && (
|
||||
<>
|
||||
<h3 style={{ ...titleStyle, fontSize: 'clamp(16px, 3.5vw, 24px)' }}>
|
||||
{text("accountModerationHistory")}
|
||||
</h3>
|
||||
|
||||
{data?.moderationHistory?.length > 0 ? (
|
||||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{data.moderationHistory.map((item) => (
|
||||
<div key={item.id} style={itemStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'clamp(14px, 3vw, 16px)',
|
||||
color: item.actionType.includes('ban') && item.actionType !== 'unban' ? '#f44336' :
|
||||
item.actionType === 'unban' ? '#4caf50' : '#ffd700'
|
||||
}}>
|
||||
{item.actionDescription}
|
||||
</div>
|
||||
{item.publicNote && (
|
||||
<div style={{ color: '#b0b0b0', marginTop: '8px', fontSize: 'clamp(12px, 2.5vw, 14px)' }}>
|
||||
{item.publicNote}
|
||||
</div>
|
||||
)}
|
||||
{item.expiresAt && new Date(item.expiresAt) > new Date() && (
|
||||
<div style={{ color: '#ffd700', marginTop: '8px', fontSize: 'clamp(11px, 2.5vw, 13px)' }}>
|
||||
{text("expires")}: {formatDate(item.expiresAt)} ({item.durationString})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 'clamp(11px, 2.5vw, 13px)' }}>
|
||||
{formatDate(item.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyStyle}>
|
||||
✅ {text("noModerationActions")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submitted Reports Section */}
|
||||
{activeSection === 'reports' && (
|
||||
<>
|
||||
<h3 style={{ ...titleStyle, fontSize: 'clamp(16px, 3.5vw, 24px)' }}>
|
||||
{text("reportsYouSubmitted")}
|
||||
</h3>
|
||||
<p style={{ color: '#888', textAlign: 'center', marginBottom: '20px', fontSize: 'clamp(12px, 2.5vw, 14px)' }}>
|
||||
{text("reportsPrivacyNote")}
|
||||
</p>
|
||||
|
||||
{data?.submittedReports?.length > 0 ? (
|
||||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{data.submittedReports.map((report) => (
|
||||
<div key={report.id} style={itemStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<div>
|
||||
<span style={{ color: '#e0e0e0', fontWeight: 'bold', fontSize: 'clamp(14px, 3vw, 16px)' }}>
|
||||
{report.reportedUsername}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: '10px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
fontSize: 'clamp(10px, 2.5vw, 12px)',
|
||||
color: '#888'
|
||||
}}>
|
||||
{getReasonText(report.reason)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||
<span style={{
|
||||
color: getStatusColor(report.status),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'clamp(11px, 2.5vw, 13px)'
|
||||
}}>
|
||||
{getStatusText(report.status)}
|
||||
</span>
|
||||
<span style={{ color: '#888', fontSize: 'clamp(11px, 2.5vw, 13px)' }}>
|
||||
{formatDate(report.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyStyle}>
|
||||
{text("noReportsSubmitted")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
components/msToTime.js
Normal file
30
components/msToTime.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export default function msToTime(duration) {
|
||||
const portions = [];
|
||||
const msInDay = 1000 * 60 * 60 * 24;
|
||||
const days = Math.trunc(duration / msInDay);
|
||||
if (days > 0) {
|
||||
portions.push(days + 'd');
|
||||
duration = duration - (days * msInDay);
|
||||
}
|
||||
|
||||
const msInHour = 1000 * 60 * 60;
|
||||
const hours = Math.trunc(duration / msInHour);
|
||||
if (hours > 0) {
|
||||
portions.push(hours + 'h');
|
||||
duration = duration - (hours * msInHour);
|
||||
}
|
||||
|
||||
const msInMinute = 1000 * 60;
|
||||
const minutes = Math.trunc(duration / msInMinute);
|
||||
if (minutes > 0) {
|
||||
portions.push(minutes + 'm');
|
||||
duration = duration - (minutes * msInMinute);
|
||||
}
|
||||
|
||||
const seconds = Math.trunc(duration / 1000);
|
||||
if (seconds > 0) {
|
||||
portions.push(seconds + 's');
|
||||
}
|
||||
|
||||
return portions[0];
|
||||
}
|
||||
105
components/multiplayerHome.js
Normal file
105
components/multiplayerHome.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import BannerText from "./bannerText"
|
||||
import { FaArrowLeft, FaArrowRight } from "react-icons/fa"
|
||||
import PlayerList from "./playerList";
|
||||
import { useTranslation } from '@/components/useTranslations'
|
||||
import MapsModal from "./maps/mapsModal";
|
||||
import PartyModal from "./partyModal";
|
||||
|
||||
|
||||
export default function MultiplayerHome({ ws, setWs, multiplayerError, multiplayerState, setMultiplayerState, session, handleAction, partyModalShown, setPartyModalShown, selectCountryModalShown, setSelectCountryModalShown }) {
|
||||
|
||||
const { t: text } = useTranslation("common");
|
||||
|
||||
// Remove local state since it's now passed from parent
|
||||
// const [selectCountryModalShown, setSelectCountryModalShown] = useState(false);
|
||||
const [gameOptions, setGameOptions] = useState({
|
||||
showRoadName: true, // rate limit fix: showRoadName true
|
||||
nm: false,
|
||||
npz: false
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setMultiplayerState((prev) => ({ ...prev, createOptions: { ...prev.createOptions, ...gameOptions } }));
|
||||
}, [gameOptions]);
|
||||
|
||||
if (multiplayerError) {
|
||||
return (
|
||||
<div className="multiplayerHome">
|
||||
<BannerText position={"auto"} text={text("connectionLost")} shown={true} hideCompass={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!((multiplayerState?.inGame) || (multiplayerState?.enteringGameCode) ||
|
||||
|
||||
(multiplayerState?.gameQueued) || (multiplayerState?.nextGameQueued))) {
|
||||
return (
|
||||
<div className="multiplayerHome">
|
||||
<BannerText position={"auto"} text={text("connectionLost")} shown={true} hideCompass={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`multiplayerHome g2_slide_in ${!["waiting"].includes(multiplayerState?.gameData?.state) ? "inGame" : ""}`}>
|
||||
{/* <BannerText text={multiplayerState.error} shown={multiplayerState.error} hideCompass={true} /> */}
|
||||
|
||||
{multiplayerState.connected && !multiplayerState.inGame && !multiplayerState.gameQueued && multiplayerState.enteringGameCode && (
|
||||
<div className="join-party-container">
|
||||
<div className="join-party-card">
|
||||
<h2 className="join-party-title">{text("joinGame")}</h2>
|
||||
|
||||
<div className="join-party-form">
|
||||
<div className="join-party-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="join-party-input"
|
||||
placeholder={text("gameCode")}
|
||||
value={multiplayerState.joinOptions.gameCode || ""}
|
||||
maxLength={6}
|
||||
onChange={(e) => setMultiplayerState((prev) => ({
|
||||
...prev,
|
||||
joinOptions: {
|
||||
...prev.joinOptions,
|
||||
gameCode: e.target.value.replace(/\D/g, "")
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
<button
|
||||
className="join-party-button"
|
||||
disabled={multiplayerState?.joinOptions?.gameCode?.length !== 6 || multiplayerState?.joinOptions?.progress}
|
||||
onClick={() => handleAction("joinPrivateGame", multiplayerState?.joinOptions?.gameCode)}
|
||||
>
|
||||
{multiplayerState?.joinOptions?.progress ? "..." : text("go")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{multiplayerState?.joinOptions?.error && (
|
||||
<div className="join-party-error">
|
||||
{multiplayerState.joinOptions.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<BannerText text={text("findingGame")} shown={multiplayerState.gameQueued} position={"auto"} subText={
|
||||
multiplayerState?.publicDuelRange ? `${text("eloRange")}: ${multiplayerState?.publicDuelRange[0]} - ${multiplayerState?.publicDuelRange[1]}` : undefined
|
||||
} />
|
||||
|
||||
<BannerText position={"auto"} text={`${text("waiting")}...`} shown={multiplayerState.inGame && multiplayerState.gameData?.state === "waiting" && multiplayerState.gameData?.public} />
|
||||
|
||||
{multiplayerState.inGame && multiplayerState.gameData?.state === "waiting" && !multiplayerState.gameData?.public && (
|
||||
<PlayerList multiplayerState={multiplayerState} startGameHost={() => handleAction("startGameHost")} onEditClick={() => setPartyModalShown(true)} />
|
||||
)}
|
||||
|
||||
<PartyModal selectCountryModalShown={selectCountryModalShown} setSelectCountryModalShown={setSelectCountryModalShown} ws={ws} setWs={setWs} multiplayerError={multiplayerError} multiplayerState={multiplayerState} setMultiplayerState={setMultiplayerState} session={session} handleAction={handleAction} gameOptions={gameOptions} setGameOptions={setGameOptions} onClose={() => setPartyModalShown(false)} shown={partyModalShown} />
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue