Initial commit
Some checks failed
CoolMath Games Build / build-coolmath (push) Failing after 1m31s
GameDistribution Build / build-gamedistribution (push) Failing after 13s

This commit is contained in:
joshii 2026-03-15 13:37:08 +01:00
commit 558f03cb70
299 changed files with 73007 additions and 0 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": ["next"]
}

41
.github/workflows/build.yml vendored Normal file
View 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
View 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 }}"

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

@ -0,0 +1 @@
web: npm start

120
README.md Normal file
View 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

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

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

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

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

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

File diff suppressed because it is too large Load diff

407
api/mod/userLookup.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
}
}

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

View 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
View 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='&copy; <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;

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load diff

176
components/headContent.js Normal file
View 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>
)
}

View 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

File diff suppressed because it is too large Load diff

25
components/homeNotice.js Normal file
View 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>
);
}

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

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

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

View 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
View 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)}
>
&#10006; {/* 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>
</>
);
}

View 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
View 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}&nbsp;<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)}
&nbsp;&nbsp;
</>
)}
{map.accepted && (
<span>
<FaMapMarkerAlt size={12} />
&nbsp;{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
View 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')}&nbsp;</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')}&nbsp;</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>
);
}

View 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
View 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&apos;s Go!
</button>
)}
</center>
</ Modal>
)
}

2538
components/modDashboard.js Normal file

File diff suppressed because it is too large Load diff

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

View 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